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.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 sqlmodel import Session, select
router = APIRouter(prefix="/notes", tags=["notes"])
@router.get("/")
@router.get("/", response_model=list[NoteRead])
def list_notes(session: Session = Depends(get_session)):
notes = session.exec(select(Note).order_by(Note.updated_at.desc())).all() # pyright: ignore[reportAttributeAccessIssue]
return notes

View file

@ -10,6 +10,7 @@ export type NoteRead = CamelCasedPropertiesDeep<
export type NoteCreate = CamelCasedPropertiesDeep<
components["schemas"]["NoteCreate"]
>;
export type Note = CamelCasedPropertiesDeep<components["schemas"]["Note"]>;
const createNote = async (note: NoteCreate) => {
const encryptionKey = useAuthStore.getState().encryptionKey;
@ -31,23 +32,31 @@ const fetchNotes = async () => {
const encryptionKey = useAuthStore.getState().encryptionKey;
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);
const decryptedNotes = await Promise.all(
data.map(async (note: NoteRead) => ({
...note,
title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey),
tags: await Promise.all(
note.tags.map(async (tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
),
})),
);
return decryptedNotes;
if (data) {
const decryptedNotes = await Promise.all(
data.map(async (note) => ({
...note,
title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey),
tags: note.tags
? await Promise.all(
note.tags.map(async (tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
)
: [],
})),
);
return decryptedNotes;
}
};
const updateNote = async (id: number, note: Partial<NoteRead>) => {
@ -64,9 +73,7 @@ const updateNote = async (id: number, note: Partial<NoteRead>) => {
if (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}`, {
body: encryptedNote,
params: {

View file

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

View file

@ -9,14 +9,16 @@ import { Sidebar } from "./components/sidebar/SideBar";
import { StatusIndicator } from "./components/StatusIndicator";
import { useCreateTag, useTagTree } from "@/hooks/useTags";
import { useFolderTree, useUpdateNote } from "@/hooks/useFolders";
import { Note } from "@/api/notes";
import { Note, NoteRead } from "@/api/notes";
import { DecryptedTagNode } from "@/api/encryption";
// @ts-ignore
import XmarkIcon from "@/assets/fontawesome/svg/xmark.svg?react";
function Home() {
const [newFolder] = useState(false);
// 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<{
id: number;
title: string;
@ -85,6 +87,7 @@ function Home() {
}
try {
if (!editingNote.id) throw new Error("Editing note has no id.");
await updateNoteMutation.mutateAsync({
noteId: editingNote.id,
note: {
@ -116,51 +119,41 @@ function Home() {
return (
<div className="flex bg-ctp-base h-screen text-ctp-text overflow-hidden">
{/* Sidebar */}
{showModal && <Modal />}
<AnimatePresence>{showModal && <Modal />}</AnimatePresence>
<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 */}
<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 />*/}
<input
type="text"
placeholder="Untitled note..."
value={editingNote?.title || ""}
onChange={(e) => setTitle(e.target.value)}
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"
/>
<div className="px-4 py-2 border-b border-ctp-surface2 flex items-center gap-2 flex-wrap">
{editingNote?.tags &&
editingNote.tags.map((tag) => (
<button
onClick={() => null}
key={tag.id}
className="bg-ctp-surface0 px-1.5 text-sm rounded-full"
>
{tag.parentId && "..."}
{tag.name}
</button>
))}
</div>
<div className="h-full lg:w-3xl w-full">
<input
type="text"
placeholder="Untitled note..."
value={editingNote?.title || ""}
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-ctp-surface2 flex items-center gap-2 flex-wrap">
{editingNote?.tags &&
editingNote.tags.map((tag) => (
<button
onClick={() => null}
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}
</button>
))}
</div>*/}
<TiptapEditor
key={editingNote?.id}
content={editingNote?.content || ""}
onChange={setContent}
/>
<TiptapEditor
key={editingNote?.id}
content={editingNote?.content || ""}
onChange={setContent}
/>
</div>
</div>
<StatusIndicator />
@ -176,16 +169,28 @@ const Modal = () => {
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
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()}
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 />
{/*<TagSelector />*/}
</div>
</motion.div>
</motion.div>
);
};

View file

@ -25,29 +25,29 @@ export const SidebarHeader = ({
};
return (
<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
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"
>
<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
onClick={() =>
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"
>
<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
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"
>
<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>
</div>
</div>

View file

@ -26,31 +26,68 @@ export const Login = () => {
};
return (
<form onSubmit={handleSubmit} className="gap-2 flex flex-col">
<input
type="text"
placeholder="Username"
className="standard-input"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
type="password"
className="standard-input"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <div>{error}</div>}
<button type="submit">Login</button>
<div className="flex gap-2">
<form
onSubmit={handleSubmit}
className="gap-4 flex flex-col max-w-md mx-auto"
>
<h2 className="text-2xl font-semibold text-ctp-text mb-2">
Welcome Back
</h2>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-ctp-subtext0">
Username
</label>
<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}
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>
<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>
);
};

View file

@ -23,29 +23,63 @@ export const Register = () => {
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <div>{error}</div>}
<button type="submit">Login</button>
<form
onSubmit={handleSubmit}
className="gap-4 flex flex-col max-w-md mx-auto"
>
<h2 className="text-2xl font-semibold text-ctp-text mb-2">
Create Account
</h2>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-ctp-subtext0">
Username
</label>
<input
type="text"
placeholder="Choose a 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">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>
);
};
// Similar pattern for Register.tsx

View file

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

View file

@ -80,7 +80,7 @@
}
.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 {
@ -149,11 +149,12 @@
.ProseMirror li[data-checked="true"] > div > p {
@apply line-through text-ctp-overlay0;
text-decoration-style: wavy;
text-decoration-thickness: 1px;
}
.ProseMirror u {
@apply decoration-ctp-mauve;
text-decoration-style: wavy;
/*text-decoration-style: wavy;*/
}
.ProseMirror li::marker {

View file

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

View file

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

View file

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