Refactor Home UI and add StatusIndicator

This commit is contained in:
james fitzsimons 2025-12-11 22:20:23 +00:00
parent 502d78f244
commit b596c9f34d
14 changed files with 192 additions and 143 deletions

View file

@ -1,7 +1,7 @@
// src/App.tsx // src/App.tsx
import { useEffect } from "react"; import { useEffect } from "react";
import { BrowserRouter, Routes, Route, Link } from "react-router-dom"; import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
import Home from "./pages/Home"; // existing home page import Home from "./pages/Home/Home.tsx"; // existing home page
import { Import } from "./pages/Import"; import { Import } from "./pages/Import";
import { Login } from "./pages/Login"; import { Login } from "./pages/Login";
import { Register } from "./pages/Register"; import { Register } from "./pages/Register";

View file

@ -1,47 +1,33 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { notesApi } from "../api/notes"; import "../../main.css";
import "../main.css";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import "@mdxeditor/editor/style.css"; import { useAuthStore } from "@/stores/authStore";
// @ts-ignore import { useNoteStore } from "@/stores/notesStore";
import CheckIcon from "../assets/fontawesome/svg/circle-check.svg?react"; import { useUIStore } from "@/stores/uiStore";
// @ts-ignore import { Login } from "../Login";
import SpinnerIcon from "../assets/fontawesome/svg/rotate.svg?react"; import { TiptapEditor } from "../TipTap";
// @ts-ignore import { Sidebar } from "./components/sidebar/SideBar";
import WarningIcon from "../assets/fontawesome/svg/circle-exclamation.svg?react"; import { StatusIndicator } from "./components/StatusIndicator";
import { useNoteStore } from "../stores/notesStore";
import { Sidebar } from "../components/sidebar/SideBar";
import { useUIStore } from "../stores/uiStore";
import { TiptapEditor } from "./TipTap";
import { useAuthStore } from "../stores/authStore";
import { Login } from "./Login";
function Home() { function Home() {
const [newFolder, setNewFolder] = useState(false); const [newFolder] = useState(false);
const [lastSavedNote, setLastSavedNote] = useState<{ const [lastSavedNote, setLastSavedNote] = useState<{
id: number; id: number;
title: string; title: string;
content: string; content: string;
} | null>(null); } | null>(null);
const { const { loadFolderTree, updateNote, setContent, selectedNote, setTitle } =
loadFolderTree, useNoteStore();
updateNote,
setSelectedNote,
setContent,
selectedNote,
setTitle,
} = useNoteStore();
const { isAuthenticated, encryptionKey } = useAuthStore(); const { encryptionKey } = useAuthStore();
const { showModal, setShowModal } = useUIStore(); const { showModal, setUpdating } = useUIStore();
const newFolderRef = useRef<HTMLInputElement>(null); const newFolderRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
// if (!isAuthenticated) return; if (!encryptionKey) return;
console.log(encryptionKey);
loadFolderTree(); loadFolderTree();
}, []); }, []);
@ -51,12 +37,6 @@ function Home() {
} }
}, [newFolder]); }, [newFolder]);
const clearSelection = () => {
setSelectedNote(null);
};
const { updating, setUpdating } = useUIStore();
useEffect(() => { useEffect(() => {
if (!selectedNote) return; if (!selectedNote) return;
if (!encryptionKey) return; // Don't try to save without encryption key if (!encryptionKey) return; // Don't try to save without encryption key
@ -117,7 +97,7 @@ function Home() {
{/* Sidebar */} {/* Sidebar */}
{showModal && <Modal />} {showModal && <Modal />}
<Sidebar clearSelection={clearSelection} /> <Sidebar />
{/* Main editor area */} {/* Main editor area */}
<div className="flex flex-col w-full h-screen overflow-hidden"> <div className="flex flex-col w-full h-screen overflow-hidden">
@ -136,31 +116,7 @@ function Home() {
/> />
</div> </div>
{/* Status indicator */} <StatusIndicator />
<div
className="fixed bottom-2 right-3 bg-ctp-surface0 border border-ctp-surface2 rounded-sm px-2 py-0.5 flex items-center gap-2.5 shadow-lg backdrop-blur-sm"
onClick={() => {
if (!encryptionKey) {
setShowModal(true);
}
}}
>
{!encryptionKey ? (
<WarningIcon className="h-4 w-4 my-1 [&_.fa-primary]:fill-ctp-yellow [&_.fa-secondary]:fill-ctp-orange" />
) : updating ? (
<>
<SpinnerIcon className="animate-spin h-4 w-4 [&_.fa-primary]:fill-ctp-blue [&_.fa-secondary]:fill-ctp-sapphire" />
<span className="text-sm text-ctp-subtext0 font-medium">
Saving...
</span>
</>
) : (
<>
<CheckIcon className="h-4 w-4 [&_.fa-primary]:fill-ctp-green [&_.fa-secondary]:fill-ctp-teal" />
<span className="text-sm text-ctp-subtext0 font-medium">Saved</span>
</>
)}
</div>
</div> </div>
); );
} }

View file

@ -0,0 +1,39 @@
import { useAuthStore } from "../../../stores/authStore";
import { useUIStore } from "../../../stores/uiStore";
// @ts-ignore
import CheckIcon from "../../../assets/fontawesome/svg/circle-check.svg?react";
// @ts-ignore
import SpinnerIcon from "../../../assets/fontawesome/svg/rotate.svg?react";
// @ts-ignore
import WarningIcon from "../../../assets/fontawesome/svg/circle-exclamation.svg?react";
export const StatusIndicator = () => {
const { encryptionKey } = useAuthStore();
const { updating, setShowModal } = useUIStore();
return (
<div
className="fixed bottom-2 right-3 bg-ctp-surface0 border border-ctp-surface2 rounded-sm px-2 py-0.5 flex items-center gap-2.5 shadow-lg backdrop-blur-sm"
onClick={() => {
if (!encryptionKey) {
setShowModal(true);
}
}}
>
{!encryptionKey ? (
<WarningIcon className="h-4 w-4 my-1 [&_.fa-primary]:fill-ctp-yellow [&_.fa-secondary]:fill-ctp-orange" />
) : updating ? (
<>
<SpinnerIcon className="animate-spin h-4 w-4 [&_.fa-primary]:fill-ctp-blue [&_.fa-secondary]:fill-ctp-sapphire" />
<span className="text-sm text-ctp-subtext0 font-medium">
Saving...
</span>
</>
) : (
<>
<CheckIcon className="h-4 w-4 [&_.fa-primary]:fill-ctp-green [&_.fa-secondary]:fill-ctp-teal" />
<span className="text-sm text-ctp-subtext0 font-medium">Saved</span>
</>
)}
</div>
);
};

View file

@ -1,12 +1,9 @@
import React, { useState, useRef, useEffect, SetStateAction } from "react"; import React, { useState, useRef, useEffect, SetStateAction } from "react";
// @ts-ignore // @ts-ignore
import FolderPlusIcon from "../../assets/fontawesome/svg/folder-plus.svg?react"; import FolderIcon from "@/assets/fontawesome/svg/folder.svg?react";
// @ts-ignore import { DraggableNote } from "./subcomponents/DraggableNote";
import FileCirclePlusIcon from "../../assets/fontawesome/svg/file-circle-plus.svg?react";
// @ts-ignore
import FolderIcon from "../../assets/fontawesome/svg/folder.svg?react";
import { DraggableNote } from "./DraggableNote";
import { useNoteStore } from "../../stores/notesStore";
import { import {
DndContext, DndContext,
DragEndEvent, DragEndEvent,
@ -17,12 +14,13 @@ import {
useSensors, useSensors,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { RecursiveFolder } from "./RecursiveFolder"; import { FolderTree } from "./subcomponents/FolderTree.tsx";
import { useAuthStore } from "../../stores/authStore"; import { SidebarHeader } from "./subcomponents/SideBarHeader.tsx";
import { useUIStore } from "../../stores/uiStore"; import { useAuthStore } from "@/stores/authStore.ts";
import { NoteRead } from "../../api/folders"; import { useNoteStore } from "@/stores/notesStore.ts";
import { useUIStore } from "@/stores/uiStore.ts";
export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => { export const Sidebar = () => {
const [newFolder, setNewFolder] = useState(false); const [newFolder, setNewFolder] = useState(false);
const [newFolderText, setNewFolderText] = useState(""); const [newFolderText, setNewFolderText] = useState("");
const [activeItem, setActiveItem] = useState<{ const [activeItem, setActiveItem] = useState<{
@ -39,7 +37,7 @@ export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => {
createFolder, createFolder,
} = useNoteStore(); } = useNoteStore();
const { isAuthenticated } = useAuthStore(); const { encryptionKey } = useAuthStore();
const { setSideBarResize, sideBarResize } = useUIStore(); const { setSideBarResize, sideBarResize } = useUIStore();
useEffect(() => { useEffect(() => {
@ -49,9 +47,9 @@ export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => {
}, [newFolder]); }, [newFolder]);
useEffect(() => { useEffect(() => {
// if (!isAuthenticated) return; if (!encryptionKey) return;
loadFolderTree(); loadFolderTree();
}, []); }, [encryptionKey]);
const handleCreateFolder = async () => { const handleCreateFolder = async () => {
if (!newFolderText.trim()) return; if (!newFolderText.trim()) return;
@ -170,10 +168,7 @@ export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => {
className="flex flex-col min-h-full" className="flex flex-col min-h-full"
style={{ width: `${sideBarResize}px` }} style={{ width: `${sideBarResize}px` }}
> >
<SidebarHeader <SidebarHeader setNewFolder={setNewFolder} />
clearSelection={clearSelection}
setNewFolder={setNewFolder}
/>
<div <div
className="bg-ctp-mantle min-h-full border-r border-ctp-surface2 w-full p-4 overflow-y-auto sm:block hidden flex-col gap-3" className="bg-ctp-mantle min-h-full border-r border-ctp-surface2 w-full p-4 overflow-y-auto sm:block hidden flex-col gap-3"
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
@ -205,7 +200,7 @@ export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => {
{/* Folder tree */} {/* Folder tree */}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{folderTree?.folders.map((folder) => ( {folderTree?.folders.map((folder) => (
<RecursiveFolder key={folder.id} folder={folder} depth={0} /> <FolderTree key={folder.id} folder={folder} depth={0} />
))} ))}
</div> </div>
@ -238,38 +233,3 @@ export const Sidebar = ({ clearSelection }: { clearSelection: () => void }) => {
</DndContext> </DndContext>
); );
}; };
export const SidebarHeader = ({
clearSelection,
setNewFolder,
}: {
clearSelection: () => void;
setNewFolder: React.Dispatch<SetStateAction<boolean>>;
}) => {
const { createNote, selectedFolder } = useNoteStore();
const handleCreate = async () => {
await createNote({
title: "Untitled",
content: "",
folder_id: selectedFolder,
});
};
return (
<div className="flex items-center justify-center w-full gap-2 bg-ctp-mantle border-b border-ctp-surface0 p-1">
<button
onClick={() => setNewFolder(true)}
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-2"
title="New folder"
>
<FolderPlusIcon className="w-4 h-4 group-hover:fill-ctp-base transition-colors fill-ctp-mauve" />
</button>
<button
onClick={handleCreate}
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-2 fill-ctp-mauve hover:fill-ctp-base"
title="New note"
>
<FileCirclePlusIcon className="w-4 h-4 text-ctp-mauve group-hover:text-ctp-base transition-colors" />
</button>
</div>
);
};

View file

@ -1,8 +1,7 @@
import React from "react";
import { useDraggable } from "@dnd-kit/core"; import { useDraggable } from "@dnd-kit/core";
import { NoteRead } from "../../api/folders"; import { useContextMenu } from "@/contexts/ContextMenuContext";
import { useNoteStore } from "../../stores/notesStore"; import { useNoteStore } from "@/stores/notesStore";
import { useContextMenu } from "../../contexts/ContextMenuContext"; import { NoteRead } from "@/api/folders";
export const DraggableNote = ({ note }: { note: NoteRead }) => { export const DraggableNote = ({ note }: { note: NoteRead }) => {
const { selectedNote, setSelectedNote } = useNoteStore(); const { selectedNote, setSelectedNote } = useNoteStore();
@ -35,7 +34,7 @@ export const DraggableNote = ({ note }: { note: NoteRead }) => {
> >
<div <div
key={note.id} key={note.id}
onClick={(e) => { onClick={() => {
setSelectedNote(note); setSelectedNote(note);
}} }}
className={` rounded-sm px-2 mb-0.5 select-none cursor-pointer font-light transition-all duration-150 flex items-center gap-1 ${ className={` rounded-sm px-2 mb-0.5 select-none cursor-pointer font-light transition-all duration-150 flex items-center gap-1 ${
@ -44,7 +43,7 @@ export const DraggableNote = ({ note }: { note: NoteRead }) => {
: "hover:bg-ctp-surface1" : "hover:bg-ctp-surface1"
}`} }`}
> >
<span> <span className="truncate">
{selectedNote?.id == note.id ? selectedNote.title : note.title} {selectedNote?.id == note.id ? selectedNote.title : note.title}
</span> </span>
</div> </div>

View file

@ -1,11 +1,11 @@
import React from "react"; import React from "react";
import { useDroppable, useDraggable } from "@dnd-kit/core"; import { useDroppable, useDraggable } from "@dnd-kit/core";
import { Folder } from "../../api/folders";
import { useContextMenu } from "../../contexts/ContextMenuContext";
// @ts-ignore // @ts-ignore
import CaretRightIcon from "../../assets/fontawesome/svg/caret-right.svg?react"; import CaretRightIcon from "@/assets/fontawesome/svg/caret-right.svg?react";
// @ts-ignore // @ts-ignore
import FolderIcon from "../../assets/fontawesome/svg/folder.svg?react"; import FolderIcon from "@/assets/fontawesome/svg/folder.svg?react";
import { Folder } from "@/api/folders";
import { useContextMenu } from "@/contexts/ContextMenuContext";
export const DroppableFolder = ({ export const DroppableFolder = ({
folder, folder,
@ -59,15 +59,15 @@ export const DroppableFolder = ({
e.stopPropagation(); e.stopPropagation();
openContextMenu(e.clientX, e.clientY, "folder", folder); openContextMenu(e.clientX, e.clientY, "folder", folder);
}} }}
className={`font-semibold mb-1 flex items-center gap-1 pr-1 py-1 rounded cursor-pointer select-none`} className={`font-semibold mb-1 flex items-center gap-1 pr-1 py-1 rounded cursor-pointer select-none min-w-0`}
{...listeners} {...listeners}
{...attributes} {...attributes}
> >
<CaretRightIcon <CaretRightIcon
className={`w-4 h-4 mr-1 transition-all duration-200 ease-in-out ${collapse ? "rotate-90" : ""} fill-ctp-mauve`} className={`w-4 h-4 min-h-4 min-w-4 mr-1 transition-all duration-200 ease-in-out ${collapse ? "rotate-90" : ""} fill-ctp-mauve`}
/> />
<FolderIcon className="w-4 h-4 fill-ctp-mauve mr-1" /> <FolderIcon className="w-4 h-4 min-h-4 min-w-4 fill-ctp-mauve mr-1" />
{folder.name} <span className="truncate">{folder.name}</span>
</div> </div>
</div> </div>
); );

View file

@ -1,18 +1,15 @@
import { useState } from "react"; import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { FolderTreeNode, NoteRead } from "../../api/folders";
import { DraggableNote } from "./DraggableNote"; import { DraggableNote } from "./DraggableNote";
import { DroppableFolder } from "./DroppableFolder"; import { DroppableFolder } from "./DroppableFolder";
import { FolderTreeNode } from "../../../../../api/folders";
interface RecursiveFolderProps { interface FolderTreeProps {
folder: FolderTreeNode; folder: FolderTreeNode;
depth?: number; depth?: number;
} }
export const RecursiveFolder = ({ export const FolderTree = ({ folder, depth = 0 }: FolderTreeProps) => {
folder,
depth = 0,
}: RecursiveFolderProps) => {
const [collapse, setCollapse] = useState(false); const [collapse, setCollapse] = useState(false);
return ( return (
@ -42,11 +39,7 @@ export const RecursiveFolder = ({
{/* Child Folders */} {/* Child Folders */}
{folder.children.map((child) => ( {folder.children.map((child) => (
<RecursiveFolder <FolderTree key={child.id} folder={child} depth={depth + 1} />
key={child.id}
folder={child}
depth={depth + 1}
/>
))} ))}
</div> </div>
</motion.div> </motion.div>

View file

@ -0,0 +1,39 @@
import { SetStateAction } from "react";
// @ts-ignore
import FolderPlusIcon from "@assets/fontawesome/svg/folder-plus.svg?react";
// @ts-ignore
import FileCirclePlusIcon from "@assets/fontawesome/svg/file-circle-plus.svg?react";
import { useNoteStore } from "@/stores/notesStore";
export const SidebarHeader = ({
setNewFolder,
}: {
setNewFolder: React.Dispatch<SetStateAction<boolean>>;
}) => {
const { createNote, selectedFolder } = useNoteStore();
const handleCreate = async () => {
await createNote({
title: "Untitled",
content: "",
folder_id: selectedFolder,
});
};
return (
<div className="flex items-center justify-center w-full gap-2 bg-ctp-mantle border-b border-ctp-surface0 p-1">
<button
onClick={() => setNewFolder(true)}
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-2"
title="New folder"
>
<FolderPlusIcon className="w-4 h-4 group-hover:fill-ctp-base transition-colors fill-ctp-mauve" />
</button>
<button
onClick={handleCreate}
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-2 fill-ctp-mauve hover:fill-ctp-base"
title="New note"
>
<FileCirclePlusIcon className="w-4 h-4 text-ctp-mauve group-hover:text-ctp-base transition-colors" />
</button>
</div>
);
};

View file

@ -21,7 +21,7 @@ export const Login = () => {
setShowModal(false); setShowModal(false);
navigate("/"); navigate("/");
} catch (err) { } catch (err) {
setError(err.message); setError((err as Error).message);
} }
}; };

View file

@ -1,3 +1,4 @@
/* @tailwind */
@reference "../main.css"; @reference "../main.css";
/* Custom Scrollbar */ /* Custom Scrollbar */

View file

@ -43,7 +43,9 @@ const updateFolder = (
if (folder.children) { if (folder.children) {
return { return {
...folder, ...folder,
children: folder.children.map((f) => updateFolder(id, f, newFolder)), children: folder.children.map((folder) =>
updateFolder(id, folder, newFolder),
),
}; };
} }
return folder; return folder;

37
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,37 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@components/*": ["./src/components/*"],
"@pages/*": ["./src/pages/*"],
"@stores/*": ["./src/stores/*"],
"@api/*": ["./src/api/*"],
"@contexts/*": ["./src/contexts/*"],
"@assets/*": ["./src/assets/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.mts"]
}

View file

@ -2,9 +2,21 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import svgr from "vite-plugin-svgr"; import svgr from "vite-plugin-svgr";
import path from "path";
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), react(), svgr()], plugins: [tailwindcss(), react(), svgr()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"@components": path.resolve(__dirname, "./src/components"),
"@pages": path.resolve(__dirname, "./src/pages"),
"@stores": path.resolve(__dirname, "./src/stores"),
"@api": path.resolve(__dirname, "./src/api"),
"@contexts": path.resolve(__dirname, "./src/contexts"),
"@assets": path.resolve(__dirname, "./src/assets"),
},
},
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {