Update notes response model and implement error handling

This commit is contained in:
james fitzsimons 2025-12-22 22:47:28 +00:00
parent 3fe4b9ea88
commit ffbf485935
12 changed files with 213 additions and 126 deletions

View file

@ -2,14 +2,14 @@ from datetime import datetime
from app.auth import require_auth from app.auth import require_auth
from app.database import get_session from app.database import get_session
from app.models import Note, NoteCreate, NoteUpdate, User from app.models import Note, NoteCreate, NoteRead, NoteUpdate, User
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select from sqlmodel import Session, select
router = APIRouter(prefix="/notes", tags=["notes"]) router = APIRouter(prefix="/notes", tags=["notes"])
@router.get("/") @router.get("/", response_model=list[NoteRead])
def list_notes(session: Session = Depends(get_session)): def list_notes(session: Session = Depends(get_session)):
notes = session.exec(select(Note).order_by(Note.updated_at.desc())).all() # pyright: ignore[reportAttributeAccessIssue] notes = session.exec(select(Note).order_by(Note.updated_at.desc())).all() # pyright: ignore[reportAttributeAccessIssue]
return notes return notes

View file

@ -10,6 +10,7 @@ export type NoteRead = CamelCasedPropertiesDeep<
export type NoteCreate = CamelCasedPropertiesDeep< export type NoteCreate = CamelCasedPropertiesDeep<
components["schemas"]["NoteCreate"] components["schemas"]["NoteCreate"]
>; >;
export type Note = CamelCasedPropertiesDeep<components["schemas"]["Note"]>;
const createNote = async (note: NoteCreate) => { const createNote = async (note: NoteCreate) => {
const encryptionKey = useAuthStore.getState().encryptionKey; const encryptionKey = useAuthStore.getState().encryptionKey;
@ -31,23 +32,31 @@ const fetchNotes = async () => {
const encryptionKey = useAuthStore.getState().encryptionKey; const encryptionKey = useAuthStore.getState().encryptionKey;
if (!encryptionKey) throw new Error("Not authenticated"); if (!encryptionKey) throw new Error("Not authenticated");
const { data } = await client.GET(`/api/notes/`); const { data, error } = await client.GET(`/api/notes/`);
if (error) {
throw new Error(error);
}
console.log(data); console.log(data);
const decryptedNotes = await Promise.all(
data.map(async (note: NoteRead) => ({ if (data) {
...note, const decryptedNotes = await Promise.all(
title: await decryptString(note.title, encryptionKey), data.map(async (note) => ({
content: await decryptString(note.content, encryptionKey), ...note,
tags: await Promise.all( title: await decryptString(note.title, encryptionKey),
note.tags.map(async (tag) => ({ content: await decryptString(note.content, encryptionKey),
...tag, tags: note.tags
name: await decryptString(tag.name, encryptionKey), ? await Promise.all(
})), note.tags.map(async (tag) => ({
), ...tag,
})), name: await decryptString(tag.name, encryptionKey),
); })),
return decryptedNotes; )
: [],
})),
);
return decryptedNotes;
}
}; };
const updateNote = async (id: number, note: Partial<NoteRead>) => { const updateNote = async (id: number, note: Partial<NoteRead>) => {
@ -64,9 +73,7 @@ const updateNote = async (id: number, note: Partial<NoteRead>) => {
if (note.folderId) { if (note.folderId) {
encryptedNote.folderId = note.folderId; encryptedNote.folderId = note.folderId;
} }
// if (!note.folderId){
// throw new Error("Folder id missing from note.")
// }
const { data, error } = await client.PATCH(`/api/notes/{note_id}`, { const { data, error } = await client.PATCH(`/api/notes/{note_id}`, {
body: encryptedNote, body: encryptedNote,
params: { params: {

View file

@ -104,10 +104,14 @@ export const useUpdateFolder = () => {
folders: FolderTreeNode[], folders: FolderTreeNode[],
): FolderTreeNode[] => { ): FolderTreeNode[] => {
return folders.map((f) => { return folders.map((f) => {
if (f.id == folderId) { if (f.id === folderId) {
return { return {
...f, ...f,
...folder, ...(folder.name !== undefined &&
folder.name !== null && { name: folder.name }),
...(folder.parentId !== undefined && {
parentId: folder.parentId,
}),
}; };
} }
return { return {

View file

@ -9,14 +9,16 @@ import { Sidebar } from "./components/sidebar/SideBar";
import { StatusIndicator } from "./components/StatusIndicator"; import { StatusIndicator } from "./components/StatusIndicator";
import { useCreateTag, useTagTree } from "@/hooks/useTags"; import { useCreateTag, useTagTree } from "@/hooks/useTags";
import { useFolderTree, useUpdateNote } from "@/hooks/useFolders"; import { useFolderTree, useUpdateNote } from "@/hooks/useFolders";
import { Note } from "@/api/notes"; import { Note, NoteRead } from "@/api/notes";
import { DecryptedTagNode } from "@/api/encryption"; import { DecryptedTagNode } from "@/api/encryption";
// @ts-ignore
import XmarkIcon from "@/assets/fontawesome/svg/xmark.svg?react";
function Home() { function Home() {
const [newFolder] = useState(false); const [newFolder] = useState(false);
// Local state for editing the current note // Local state for editing the current note
const [editingNote, setEditingNote] = useState<Note | null>(null); const [editingNote, setEditingNote] = useState<NoteRead | null>(null);
const [lastSavedNote, setLastSavedNote] = useState<{ const [lastSavedNote, setLastSavedNote] = useState<{
id: number; id: number;
title: string; title: string;
@ -85,6 +87,7 @@ function Home() {
} }
try { try {
if (!editingNote.id) throw new Error("Editing note has no id.");
await updateNoteMutation.mutateAsync({ await updateNoteMutation.mutateAsync({
noteId: editingNote.id, noteId: editingNote.id,
note: { note: {
@ -116,51 +119,41 @@ function Home() {
return ( return (
<div className="flex bg-ctp-base h-screen text-ctp-text overflow-hidden"> <div className="flex bg-ctp-base h-screen text-ctp-text overflow-hidden">
{/* Sidebar */} {/* Sidebar */}
{showModal && <Modal />} <AnimatePresence>{showModal && <Modal />}</AnimatePresence>
<Sidebar /> <Sidebar />
{/*<div className="flex flex-col">
<input
type="text"
value={tagName}
onChange={(e) => setTagName(e.target.value)}
/>
{tags.map((tag) => (
<button onClick={() => deleteTag(tag.id)} key={tag.id}>
{tag.name}
</button>
))}
</div>*/}
{/* 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-y-auto items-center justify-center">
{/*<Editor />*/} {/*<Editor />*/}
<input <div className="h-full lg:w-3xl w-full">
type="text" <input
placeholder="Untitled note..." type="text"
value={editingNote?.title || ""} placeholder="Untitled note..."
onChange={(e) => setTitle(e.target.value)} value={editingNote?.title || ""}
className="w-full px-4 py-3 text-3xl font-semibold bg-transparentfocus:outline-none focus:border-ctp-mauve transition-colors placeholder:text-ctp-overlay0 text-ctp-text" onChange={(e) => setTitle(e.target.value)}
/> className="w-full p-4 pb-0 text-3xl font-semibold bg-transparent focus:outline-none border-transparent focus:border-ctp-mauve transition-colors placeholder:text-ctp-overlay0 text-ctp-text"
<div className="px-4 py-2 border-b border-ctp-surface2 flex items-center gap-2 flex-wrap"> />
{editingNote?.tags && {/*<div className="px-4 py-2 border-ctp-surface2 flex items-center gap-2 flex-wrap">
editingNote.tags.map((tag) => ( {editingNote?.tags &&
<button editingNote.tags.map((tag) => (
onClick={() => null} <button
key={tag.id} onClick={() => null}
className="bg-ctp-surface0 px-1.5 text-sm rounded-full" key={tag.id}
> className="bg-ctp-surface0 hover:bg-ctp-surface1 px-2 py-0.5 text-sm rounded-full transition-colors"
{tag.parentId && "..."} >
{tag.name} {tag.parentId && "..."}
</button> {tag.name}
))} </button>
</div> ))}
</div>*/}
<TiptapEditor <TiptapEditor
key={editingNote?.id} key={editingNote?.id}
content={editingNote?.content || ""} content={editingNote?.content || ""}
onChange={setContent} onChange={setContent}
/> />
</div>
</div> </div>
<StatusIndicator /> <StatusIndicator />
@ -176,16 +169,28 @@ const Modal = () => {
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowModal(false)} onClick={() => setShowModal(false)}
className="absolute h-screen w-screen flex items-center justify-center bg-ctp-crust/60 z-50" className="fixed inset-0 h-screen w-screen flex items-center justify-center bg-ctp-crust/70 backdrop-blur-sm z-50"
> >
<div <motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ type: "spring", duration: 0.3 }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="w-2/3 h-2/3 bg-ctp-base rounded-xl border-ctp-surface2 border p-5" className="relative w-full max-w-md mx-4 bg-ctp-base rounded-xl border-ctp-surface2 border p-8 shadow-2xl"
> >
<button
onClick={() => setShowModal(false)}
className="absolute top-4 right-4 p-2 hover:bg-ctp-surface0 rounded-sm transition-colors group"
aria-label="Close modal"
>
<XmarkIcon className="w-5 h-5 fill-ctp-overlay0 group-hover:fill-ctp-text transition-colors" />
</button>
<Login /> <Login />
{/*<TagSelector />*/} {/*<TagSelector />*/}
</div> </motion.div>
</motion.div> </motion.div>
); );
}; };

View file

@ -25,29 +25,29 @@ export const SidebarHeader = ({
}; };
return ( return (
<div className="w-full p-2 border-b border-ctp-surface2 bg-ctp-mantle"> <div className="w-full p-2 border-b border-ctp-surface2 bg-ctp-mantle">
<div className="flex items-center justify-around bg-ctp-surface0 rounded-lg p-0.5"> <div className="flex items-center justify-around bg-ctp-surface0 rounded-lg p-1 gap-1">
<button <button
onClick={() => setNewFolder(true)} onClick={() => setNewFolder(true)}
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-1 m-1" className="hover:bg-ctp-mauve active:scale-95 group transition-all duration-200 rounded-md p-2 hover:shadow-md"
title="New folder" title="New folder"
> >
<FolderPlusIcon className="w-5 h-5 group-hover:fill-ctp-base transition-colors fill-ctp-mauve" /> <FolderPlusIcon className="w-5 h-5 group-hover:fill-ctp-base transition-all duration-200 fill-ctp-mauve" />
</button> </button>
<button <button
onClick={() => onClick={() =>
setSideBarView(sideBarView == "tags" ? "folders" : "tags") setSideBarView(sideBarView == "tags" ? "folders" : "tags")
} }
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-1 m-1" className={`${sideBarView === "tags" ? "bg-ctp-mauve/20" : ""} hover:bg-ctp-mauve active:scale-95 group transition-all duration-200 rounded-md p-2 hover:shadow-md`}
title="Tags" title="Tags"
> >
<TagsIcon className="w-5 h-5 group-hover:fill-ctp-base transition-colors fill-ctp-mauve" /> <TagsIcon className="w-5 h-5 group-hover:fill-ctp-base transition-all duration-200 fill-ctp-mauve" />
</button> </button>
<button <button
onClick={handleCreate} onClick={handleCreate}
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-1 m-1 fill-ctp-mauve hover:fill-ctp-base" className="hover:bg-ctp-mauve active:scale-95 group transition-all duration-200 rounded-md p-2 hover:shadow-md"
title="New note" title="New note"
> >
<FileCirclePlusIcon className="w-5 h-5 text-ctp-mauve group-hover:text-ctp-base transition-colors" /> <FileCirclePlusIcon className="w-5 h-5 group-hover:fill-ctp-base transition-all duration-200 fill-ctp-mauve" />
</button> </button>
</div> </div>
</div> </div>

View file

@ -26,31 +26,68 @@ export const Login = () => {
}; };
return ( return (
<form onSubmit={handleSubmit} className="gap-2 flex flex-col"> <form
<input onSubmit={handleSubmit}
type="text" className="gap-4 flex flex-col max-w-md mx-auto"
placeholder="Username" >
className="standard-input" <h2 className="text-2xl font-semibold text-ctp-text mb-2">
value={username} Welcome Back
onChange={(e) => setUsername(e.target.value)} </h2>
/>
<input <div className="flex flex-col gap-2">
type="password" <label className="text-sm font-medium text-ctp-subtext0">
className="standard-input" Username
placeholder="Password" </label>
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <div>{error}</div>}
<button type="submit">Login</button>
<div className="flex gap-2">
<input <input
type="check box" type="text"
placeholder="Enter your username"
className="standard-input"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-ctp-subtext0">
Password
</label>
<input
type="password"
className="standard-input"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && (
<div className="bg-ctp-red/10 border border-ctp-red text-ctp-red px-3 py-2 rounded-sm text-sm">
{error}
</div>
)}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="remember"
checked={remember} checked={remember}
onChange={(e) => setRemember(e.target.checked)} onChange={(e) => setRemember(e.target.checked)}
className="accent-ctp-mauve cursor-pointer"
/> />
<div>Remember me?</div> <label
htmlFor="remember"
className="text-sm text-ctp-subtext0 cursor-pointer"
>
Remember me
</label>
</div> </div>
<button
type="submit"
className="bg-ctp-mauve hover:bg-ctp-mauve/90 text-ctp-base font-semibold px-4 py-2.5 rounded-sm transition-colors focus:outline-none focus:ring-2 focus:ring-ctp-mauve focus:ring-offset-2 focus:ring-offset-ctp-base"
>
Login
</button>
</form> </form>
); );
}; };

View file

@ -23,29 +23,63 @@ export const Register = () => {
}; };
return ( return (
<form onSubmit={handleSubmit}> <form
<input onSubmit={handleSubmit}
type="text" className="gap-4 flex flex-col max-w-md mx-auto"
placeholder="Username" >
value={username} <h2 className="text-2xl font-semibold text-ctp-text mb-2">
onChange={(e) => setUsername(e.target.value)} Create Account
/> </h2>
<input
type="email" <div className="flex flex-col gap-2">
placeholder="Email" <label className="text-sm font-medium text-ctp-subtext0">
value={email} Username
onChange={(e) => setEmail(e.target.value)} </label>
/> <input
<input type="text"
type="password" placeholder="Choose a username"
placeholder="Password" className="standard-input"
value={password} value={username}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setUsername(e.target.value)}
/> />
{error && <div>{error}</div>} </div>
<button type="submit">Login</button>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-ctp-subtext0">Email</label>
<input
type="email"
placeholder="Enter your email"
className="standard-input"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-ctp-subtext0">
Password
</label>
<input
type="password"
className="standard-input"
placeholder="Create a password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && (
<div className="bg-ctp-red/10 border border-ctp-red text-ctp-red px-3 py-2 rounded-sm text-sm">
{error}
</div>
)}
<button
type="submit"
className="bg-ctp-mauve hover:bg-ctp-mauve/90 text-ctp-base font-semibold px-4 py-2.5 rounded-sm transition-colors focus:outline-none focus:ring-2 focus:ring-ctp-mauve focus:ring-offset-2 focus:ring-offset-ctp-base"
>
Register
</button>
</form> </form>
); );
}; };
// Similar pattern for Register.tsx

View file

@ -79,7 +79,7 @@ export const TiptapEditor = ({
} }
return ( return (
<div className="tiptap-editor h-full"> <div className="tiptap-editor pt-0">
{/* Toolbar */} {/* Toolbar */}
{/*<div className="editor-toolbar"> {/*<div className="editor-toolbar">
<div className="toolbar-group"> <div className="toolbar-group">

View file

@ -80,7 +80,7 @@
} }
.ProseMirror h3 { .ProseMirror h3 {
@apply text-xl font-semibold text-ctp-teal mt-5 mb-2; @apply text-xl font-semibold text-ctp-mauve mt-5 mb-2;
} }
.ProseMirror code { .ProseMirror code {
@ -149,11 +149,12 @@
.ProseMirror li[data-checked="true"] > div > p { .ProseMirror li[data-checked="true"] > div > p {
@apply line-through text-ctp-overlay0; @apply line-through text-ctp-overlay0;
text-decoration-style: wavy; text-decoration-style: wavy;
text-decoration-thickness: 1px;
} }
.ProseMirror u { .ProseMirror u {
@apply decoration-ctp-mauve; @apply decoration-ctp-mauve;
text-decoration-style: wavy; /*text-decoration-style: wavy;*/
} }
.ProseMirror li::marker { .ProseMirror li::marker {

View file

@ -1,6 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import { useNoteStore } from "./notesStore";
import { import {
deriveKey, deriveKey,
generateMasterKey, generateMasterKey,

View file

@ -1,4 +1,4 @@
import { Note } from "@/api/notes"; import { Note, NoteRead } from "@/api/notes";
import { create } from "zustand"; import { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
@ -15,8 +15,8 @@ interface UIState {
sideBarView: string; sideBarView: string;
setSideBarView: (view: string) => void; setSideBarView: (view: string) => void;
selectedNote: Note | null; selectedNote: NoteRead | null;
setSelectedNote: (note: Note | null) => void; setSelectedNote: (note: NoteRead | null) => void;
selectedFolder: number | null; selectedFolder: number | null;
setSelectedFolder: (id: number | null) => void; setSelectedFolder: (id: number | null) => void;
@ -43,7 +43,7 @@ export const useUIStore = create<UIState>()(
}, },
selectedNote: null, selectedNote: null,
setSelectedNote: (id: Note | null) => { setSelectedNote: (id: NoteRead | null) => {
set({ selectedNote: id }); set({ selectedNote: id });
}, },
selectedFolder: null, selectedFolder: null,

View file

@ -548,7 +548,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": unknown; "application/json": components["schemas"]["NoteRead"][];
}; };
}; };
}; };