Add notes

This commit is contained in:
Arthur Belleville 2025-10-25 21:21:04 +02:00
parent e8c84eeadb
commit 5b68ad4c5c
No known key found for this signature in database
18 changed files with 3125 additions and 1323 deletions

File diff suppressed because it is too large Load diff

View file

@ -68,6 +68,9 @@
"wrangler": "^4.24.3"
},
"dependencies": {
"@blocknote/core": "^0.41.1",
"@blocknote/mantine": "^0.41.1",
"@blocknote/react": "^0.41.1",
"@datadog/browser-rum": "^6.13.0",
"@datadog/browser-rum-react": "^6.13.0",
"@hookform/resolvers": "^5.2.2",

View file

@ -17,6 +17,7 @@ import {
CalendarIcon,
Circle,
ConstructionIcon,
FileTextIcon,
Kanban,
LogOutIcon,
MessageCircleIcon,
@ -75,7 +76,7 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
const { t } = useTranslation("navigation");
const MenuSeparator = () => {
return <DropdownMenuSeparator className="!bg-gray-500" />;
return <DropdownMenuSeparator className="bg-gray-500!" />;
};
const itemVariants = cva("", {
@ -325,6 +326,11 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
label: t("discussions"),
icon: <MessageCircleIcon className="w-5 h-5" />,
},
{
path: "/notes",
label: t("notes"),
icon: <FileTextIcon className="w-5 h-5" />,
},
];
return (
<nav className="flex flex-1 flex-col" aria-label="Primary navigation">

View file

@ -0,0 +1,40 @@
import "@blocknote/core/fonts/inter.css";
import type { BlockNoteEditor } from "@blocknote/core";
import "@blocknote/mantine/style.css";
import { BlockNoteView } from "@blocknote/mantine";
import { useCreateBlockNote } from "@blocknote/react";
import { useTheme } from "@xtablo/shared/contexts/ThemeContext";
interface NotesEditorProps {
initialContent: string;
onChange: (content: string) => void;
}
export function NotesEditor({ initialContent, onChange }: NotesEditorProps) {
const { theme } = useTheme();
// Create editor instance
const editor: BlockNoteEditor = useCreateBlockNote({
initialContent: initialContent ? JSON.parse(initialContent) : undefined,
});
// Handle changes
const handleChange = () => {
if (onChange) {
const blocks = editor.document;
onChange(JSON.stringify(blocks));
}
};
return (
<div className="h-full w-full">
<BlockNoteView
editor={editor}
theme={theme === "dark" ? "dark" : "light"}
editable
onChange={handleChange}
data-theming-css
/>
</div>
);
}

View file

@ -0,0 +1,129 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "../lib/supabase";
import { useUser } from "../providers/UserStoreProvider";
import { Database } from "@xtablo/shared/types/database.types";
import { useNavigate } from "react-router-dom";
type Note = Database["public"]["Tables"]["notes"]["Row"];
type CreateNoteInput = Pick<Database["public"]["Tables"]["notes"]["Insert"], "title" | "content">;
type UpdateNoteInput = Pick<
Database["public"]["Tables"]["notes"]["Update"],
"id" | "title" | "content"
>;
export function useNotes() {
const user = useUser();
const { data: notes, isLoading } = useQuery<Note[]>({
queryKey: ["notes"],
queryFn: async () => {
const { data, error } = await supabase
.from("notes")
.select("*")
.eq("user_id", user.id)
.is("deleted_at", null)
.order("created_at", { ascending: false });
if (error) throw error;
return data as Note[];
},
enabled: !!user.id,
});
return { notes, isLoading };
}
export function useNote(id: string | undefined) {
const user = useUser();
const { data: note, isLoading } = useQuery<Note | null>({
queryKey: ["notes", id],
queryFn: async () => {
const { data, error } = await supabase
.from("notes")
.select("*")
.eq("id", id)
.eq("user_id", user.id)
.is("deleted_at", null);
if (error) throw error;
if (data.length === 0) return null;
return data[0] as Note;
},
enabled: !!id,
});
return { note, isLoading };
}
export function useCreateNote() {
const user = useUser();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation<Note, Error, CreateNoteInput>({
mutationFn: async (input: CreateNoteInput) => {
const { data, error } = await supabase
.from("notes")
.insert({
title: input.title,
content: input.content,
user_id: user.id,
})
.select()
.single();
if (error) throw error;
return data as Note;
},
onSuccess: (note) => {
// Invalidate notes list to refetch
queryClient.invalidateQueries({ queryKey: ["notes"] });
navigate(`/notes/${note.id}`);
},
});
return { mutate, isPending };
}
/**
* Update an existing note
*/
export function useUpdateNote() {
const user = useUser();
const queryClient = useQueryClient();
return useMutation<void, Error, UpdateNoteInput>({
mutationFn: async (input: UpdateNoteInput) => {
const { error } = await supabase
.from("notes")
.update({
title: input.title,
content: input.content,
})
.eq("id", input.id)
.eq("user_id", user.id)
.is("deleted_at", null);
if (error) throw error;
return;
},
onSuccess: () => {
// Invalidate both the specific note and notes list
queryClient.invalidateQueries({ queryKey: ["notes"] });
},
});
}
export function useDeleteNote() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation<void, Error, string>({
mutationFn: async (id: string) => {
const { error } = await supabase
.from("notes")
.update({ deleted_at: new Date().toISOString() })
.eq("id", id);
if (error) throw error;
return;
},
onSuccess: () => {
// Invalidate notes list to refetch
queryClient.invalidateQueries({ queryKey: ["notes"] });
navigate("/notes/create", { replace: true });
},
});
return { mutate, isPending };
}

View file

@ -7,6 +7,7 @@ import commonEn from "./locales/en/common.json";
import componentsEn from "./locales/en/components.json";
import modalsEn from "./locales/en/modals.json";
import navigationEn from "./locales/en/navigation.json";
import notesEn from "./locales/en/notes.json";
import pagesEn from "./locales/en/pages.json";
import planningEn from "./locales/en/planning.json";
import settingsEn from "./locales/en/settings.json";
@ -17,6 +18,7 @@ import commonFr from "./locales/fr/common.json";
import componentsFr from "./locales/fr/components.json";
import modalsFr from "./locales/fr/modals.json";
import navigationFr from "./locales/fr/navigation.json";
import notesFr from "./locales/fr/notes.json";
import pagesFr from "./locales/fr/pages.json";
import planningFr from "./locales/fr/planning.json";
import settingsFr from "./locales/fr/settings.json";
@ -36,6 +38,7 @@ i18n
planning: planningFr,
modals: modalsFr,
components: componentsFr,
notes: notesFr,
},
en: {
common: commonEn,
@ -47,8 +50,10 @@ i18n
planning: planningEn,
modals: modalsEn,
components: componentsEn,
notes: notesEn,
},
},
lng: "fr",
fallbackLng: "fr",
defaultNS: "common",
interpolation: {

View file

@ -11,6 +11,7 @@ import { FeedbackPage } from "../pages/feedback";
import { JoinPage } from "../pages/join";
import { LandingPage } from "../pages/landing";
import { LoginPage } from "../pages/login";
import NotesPage from "../pages/notes";
import { NotFoundPage } from "../pages/NotFoundPage";
import { OAuthSigninPage } from "../pages/oauth-signin";
import { PublicBookingPage } from "../pages/PublicBookingPage";
@ -74,6 +75,14 @@ export const routes: RouteObject[] = [
),
children: [{ index: true }, { path: ":channelId" }],
},
{
path: "notes",
children: [
{ index: true, element: <NotesPage mode="create" /> },
{ path: ":noteId", element: <NotesPage mode="edit" /> },
{ path: "create", element: <NotesPage mode="create" /> },
],
},
{
path: "availabilities",
element: <AvailabilitiesPage />,

View file

@ -3,6 +3,7 @@
"myEvents": "My Events",
"planning": "Planning",
"discussions": "Discussions",
"notes": "Notes",
"feedback": "Feedback",
"settings": "Settings",
"availabilities": "Availabilities",

View file

@ -0,0 +1,25 @@
{
"title": "Notes",
"description": "Create and organize your notes with a block-based editor",
"newNote": "New Note",
"saveNote": "Save Note",
"saving": "Saving...",
"noteTitlePlaceholder": "Note title...",
"yourNotes": "Your Notes",
"loadingNotes": "Loading notes...",
"loadingNote": "Loading note...",
"pleaseWait": "Please wait while we fetch your note",
"noNotes": "No notes yet. Create your first note!",
"noteNotFound": "Note not found",
"noteNotFoundDescription": "The note you're looking for doesn't exist or has been deleted",
"createNewNote": "Create a new note",
"savedSuccessfully": "Note saved successfully",
"saveFailed": "Failed to save note",
"addContentBeforeSaving": "Please add some content before saving",
"unsavedChangesWarning": "You have unsaved changes. Are you sure you want to create a new note?",
"showSidebar": "Show notes list",
"hideSidebar": "Hide notes list",
"deleteNote": "Delete note",
"untitledNote": "Untitled Note",
"clickNewNoteToStart": "Click 'New Note' to get started"
}

View file

@ -3,6 +3,7 @@
"myEvents": "Mes Événements",
"planning": "Planning",
"discussions": "Discussions",
"notes": "Notes",
"feedback": "Feedback",
"settings": "Paramètres",
"availabilities": "Disponibilités",

View file

@ -0,0 +1,25 @@
{
"title": "Notes",
"description": "Créez et organisez vos notes",
"newNote": "Nouvelle note",
"saveNote": "Enregistrer",
"saving": "Enregistrement...",
"noteTitlePlaceholder": "Titre de la note...",
"yourNotes": "Vos notes",
"loadingNotes": "Chargement des notes...",
"loadingNote": "Chargement de la note...",
"pleaseWait": "Veuillez patienter pendant que nous récupérons votre note",
"noNotes": "Aucune note pour le moment. Créez votre première note !",
"noteNotFound": "Note introuvable",
"noteNotFoundDescription": "La note que vous recherchez n'existe pas ou a été supprimée",
"createNewNote": "Créer une nouvelle note",
"savedSuccessfully": "Note enregistrée avec succès",
"saveFailed": "Échec de l'enregistrement de la note",
"addContentBeforeSaving": "Veuillez ajouter du contenu avant d'enregistrer",
"unsavedChangesWarning": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir créer une nouvelle note ?",
"showSidebar": "Afficher la liste des notes",
"hideSidebar": "Masquer la liste des notes",
"deleteNote": "Supprimer la note",
"untitledNote": "Note sans titre",
"clickNewNoteToStart": "Cliquez sur 'Nouvelle note' pour commencer"
}

View file

@ -0,0 +1,358 @@
import { toast } from "@xtablo/shared";
import { Button } from "@xtablo/ui/components/button";
import { Card, CardContent, CardHeader } from "@xtablo/ui/components/card";
import { Input } from "@xtablo/ui/components/input";
import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
import {
ChevronLeftIcon,
ChevronRightIcon,
ClockIcon,
FileTextIcon,
PlusIcon,
SaveIcon,
SearchXIcon,
StickyNoteIcon,
TrashIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { NotesEditor } from "../components/NotesEditor";
import { useCreateNote, useDeleteNote, useNote, useNotes, useUpdateNote } from "../hooks/notes";
import { useNavigate, useParams } from "react-router-dom";
// Helper function to format dates in a human-readable way
const formatRelativeDate = (date: Date): string => {
const now = new Date();
const diffInMs = now.getTime() - date.getTime();
const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
if (diffInMinutes < 1) return "Just now";
if (diffInMinutes < 60) return `${diffInMinutes}m ago`;
if (diffInHours < 24) return `${diffInHours}h ago`;
if (diffInDays === 0) return "Today";
if (diffInDays === 1) return "Yesterday";
if (diffInDays < 7) return `${diffInDays} days ago`;
if (diffInDays < 30) return `${Math.floor(diffInDays / 7)} weeks ago`;
if (diffInDays < 365) return `${Math.floor(diffInDays / 30)} months ago`;
return date.toLocaleDateString();
};
const useNoteId = () => {
const navigate = useNavigate();
const params = useParams();
const [noteId, setNoteId] = useState<string | undefined>(params.noteId as string | undefined);
const updateNoteId = (id: string) => {
navigate(`/notes/${id}`, { replace: true });
setNoteId(id);
};
const goCreateNote = () => {
navigate("/notes/create", { replace: true });
setNoteId(undefined);
};
return { noteId, setNoteId: updateNoteId, goCreateNote };
};
export default function NotesPage({ mode }: { mode: "create" | "edit" }) {
const { t } = useTranslation(["notes", "common"]);
const { noteId, setNoteId, goCreateNote } = useNoteId();
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [isSidebarVisible, setIsSidebarVisible] = useState(true);
const [editorKey, setEditorKey] = useState(0);
const { notes, isLoading } = useNotes();
const { note, isLoading: isNoteLoading } = useNote(noteId);
const { mutate: createNote, isPending: isCreating } = useCreateNote();
const { mutate: updateNote, isPending: isUpdating } = useUpdateNote();
const { mutate: deleteNote, isPending: isDeleting } = useDeleteNote();
useEffect(() => {
if (noteId) {
const note = notes?.find((note) => note.id === noteId);
if (note) {
setTitle(note.title ?? "");
setContent(note.content ?? "");
setHasUnsavedChanges(false);
}
} else if (mode === "create") {
setTitle("");
setContent("");
setHasUnsavedChanges(false);
}
setEditorKey((prev) => prev + 1);
}, [mode, noteId, notes]);
const handleContentChange = (newContent: string) => {
setContent(newContent);
setHasUnsavedChanges(true);
};
const handleSave = () => {
if (!content && !title) {
toast.add({
title: t("common:error", "Error"),
description: t("addContentBeforeSaving"),
type: "error",
});
return;
}
const params = {
onSuccess: () => {
toast.add({
title: t("common:success", "Success"),
description: t("savedSuccessfully"),
type: "success",
});
},
onError: (error: Error) => {
toast.add({
title: t("common:error", "Error"),
description: `${t("saveFailed")}: ${error.message}`,
type: "error",
});
},
};
if (mode === "create") {
createNote({ title: title || "Untitled Note", content }, params);
} else {
updateNote({ id: noteId, title: title || "Untitled Note", content }, params);
}
};
const resetEditor = () => {
setTitle("");
setContent("");
setHasUnsavedChanges(false);
setEditorKey((prev) => prev + 1);
};
const handleNewNote = () => {
if (hasUnsavedChanges) {
const confirmed = window.confirm(t("unsavedChangesWarning"));
if (!confirmed) return;
}
resetEditor();
goCreateNote();
};
const renderNote = () => {
if (mode === "edit" && noteId) {
if (isNoteLoading) {
return (
<Card className="flex flex-1 flex-col items-center justify-center overflow-hidden"></Card>
);
}
if (!note) {
return (
<Card className="flex flex-1 flex-col items-center justify-center overflow-hidden">
<div className="flex flex-col items-center gap-4 p-8">
<div className="rounded-full bg-muted p-6">
<SearchXIcon className="size-12 text-muted-foreground" />
</div>
<div className="space-y-2 text-center">
<TypographyH3 className="text-lg">
{t("noteNotFound", "Note not found")}
</TypographyH3>
<TypographyMuted className="text-sm max-w-md">
{t(
"noteNotFoundDescription",
"The note you're looking for doesn't exist or has been deleted"
)}
</TypographyMuted>
</div>
<Button variant="outline" onClick={handleNewNote} className="mt-2">
<PlusIcon className="mr-2 size-4" />
{t("createNewNote", "Create a new note")}
</Button>
</div>
</Card>
);
}
}
return (
<Card className="flex flex-1 flex-col overflow-hidden">
<CardHeader className="border-b flex flex-row items-center justify-between">
<div className="flex flex-1 items-center gap-2">
<Input
placeholder={t("noteTitlePlaceholder")}
value={title}
onChange={(e) => {
setTitle(e.target.value);
setHasUnsavedChanges(true);
}}
className="border-0 text-2xl font-bold focus-visible:ring-0"
/>
{noteId && (
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
deleteNote(noteId, {
onSuccess: () => {
resetEditor();
},
});
}}
disabled={isDeleting}
title={t("deleteNote")}
className="hover:text-red-500 hover:bg-red-50/10 dark:hover:bg-red-950/10"
>
<TrashIcon className="size-4" />
</Button>
)}
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden p-0">
<NotesEditor
key={editorKey}
initialContent={note?.content ?? content}
onChange={handleContentChange}
/>
</CardContent>
</Card>
);
};
return (
<div className="flex h-full flex-col gap-6 p-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FileTextIcon className="size-8 text-primary" />
<div>
<TypographyH3>{t("title")}</TypographyH3>
<TypographyMuted>{t("description")}</TypographyMuted>
</div>
</div>
<Button onClick={handleSave} disabled={!hasUnsavedChanges || isCreating || isUpdating}>
<SaveIcon className="mr-2 size-4" />
{isCreating ? t("saving") : t("saveNote")}
</Button>
</div>
{/* Main Content */}
<div className="flex flex-1 gap-6 overflow-hidden">
{/* Toggle Button (shown when sidebar is hidden) */}
{!isSidebarVisible && (
<Button
variant="outline"
size="icon"
onClick={() => setIsSidebarVisible(true)}
className="shrink-0 self-start"
title={t("showSidebar")}
>
<ChevronRightIcon className="size-4" />
</Button>
)}
{/* Sidebar - Notes List */}
{isSidebarVisible && (
<Card className="w-72 shrink-0 overflow-hidden transition-all border-r">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b">
<div className="flex items-center gap-2">
<TypographyH3 className="text-base font-semibold">{t("yourNotes")}</TypographyH3>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setIsSidebarVisible(false)}
className="size-7 hover:bg-muted"
title={t("hideSidebar")}
>
<ChevronLeftIcon className="size-4" />
</Button>
</CardHeader>
<CardContent className="overflow-y-auto pt-1 px-3 max-h-[calc(100vh-16rem)]">
{/* New Note Button */}
<Button
onClick={handleNewNote}
disabled={isCreating}
className="w-full mb-3"
variant="default"
>
<PlusIcon className="mr-2 size-4" />
{t("newNote")}
</Button>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="rounded-lg border bg-muted/30 p-3 animate-pulse">
<div className="h-4 bg-muted rounded w-3/4 mb-2" />
<div className="h-3 bg-muted rounded w-1/2" />
</div>
))}
</div>
) : notes && notes.length > 0 ? (
<div className="space-y-1">
{notes.map((note) => {
const isActive = noteId === note.id;
const formattedDate = note.updated_at
? formatRelativeDate(new Date(note.updated_at))
: "";
return (
<button
key={note.id}
onClick={() => {
setNoteId(note.id);
}}
className={`
w-full rounded-lg p-3 text-left transition-all
hover:bg-accent hover:shadow-sm
${
isActive
? "bg-primary/10 border-2 border-primary/30 shadow-sm"
: "border border-border hover:border-primary/20"
}
`}
>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<div
className={`font-medium text-sm truncate ${isActive ? "text-primary" : ""}`}
>
{note.title || t("untitledNote", "Untitled Note")}
</div>
<div className="flex items-center gap-1 mt-1">
<ClockIcon className="size-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">{formattedDate}</span>
</div>
</div>
</div>
</button>
);
})}
</div>
) : (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="rounded-full bg-muted p-4 mb-3">
<StickyNoteIcon className="size-8 text-muted-foreground" />
</div>
<TypographyMuted className="text-sm mb-2 font-medium">
{t("noNotes")}
</TypographyMuted>
<TypographyMuted className="text-xs">
{t("clickNewNoteToStart", "Click 'New Note' to get started")}
</TypographyMuted>
</div>
)}
</CardContent>
</Card>
)}
{/* Editor */}
{renderNote()}
</div>
</div>
);
}

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@ merge-to-main:
# Types recipes
update-types:
npx supabase gen types typescript --project-id "mhcafqvzbrrwvahpvvzd" --schema public > ui/src/types/database.types.ts && cp ui/src/types/database.types.ts api/src/database.types.ts && cp ui/src/types/database.types.ts xtablo-expo/lib/database.types.ts
npx supabase gen types typescript --project-id "mhcafqvzbrrwvahpvvzd" --schema public > packages/shared/src/types/database.types.ts && cp packages/shared/src/types/database.types.ts api/src/database.types.ts && cp packages/shared/src/types/database.types.ts xtablo-expo/lib/database.types.ts
# Expo recipes

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

81
sql/25_notes.sql Normal file
View file

@ -0,0 +1,81 @@
-- Create notes table for user notes functionality
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY DEFAULT generate_random_string(24),
title VARCHAR(255) NOT NULL,
content TEXT,
user_id UUID NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
-- Foreign key constraint to users table (auth.users)
CONSTRAINT fk_notes_user_id
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE
);
-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_notes_user_id ON notes(user_id);
CREATE INDEX IF NOT EXISTS idx_notes_deleted_at ON notes(deleted_at);
CREATE INDEX IF NOT EXISTS idx_notes_created_at ON notes(created_at);
-- Enable Row Level Security
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
-- Policy to allow users to view their own notes
CREATE POLICY "Users can view their own notes" ON notes
FOR SELECT
TO authenticated
USING (user_id = (SELECT auth.uid()));
-- Policy to allow users to insert their own notes
CREATE POLICY "Users can insert their own notes" ON notes
FOR INSERT
TO authenticated
WITH CHECK (
user_id = (SELECT auth.uid())
);
-- Policy to allow users to update their own notes
CREATE POLICY "Users can update their own notes" ON notes
FOR UPDATE
TO authenticated
USING (
user_id = (SELECT auth.uid())
)
WITH CHECK (
user_id = (SELECT auth.uid())
);
CREATE POLICY "Users can delete their own notes (soft)" ON notes
FOR UPDATE
TO authenticated
USING (user_id = auth.uid() AND deleted_at IS NULL)
WITH CHECK (user_id = auth.uid());
-- Policy to allow users to delete their own notes (soft delete)
CREATE POLICY "Users can delete their own notes" ON notes
FOR DELETE
TO authenticated
USING (
user_id = (SELECT auth.uid())
);
-- Add comments to document the table
COMMENT ON TABLE notes IS
'User notes with Row Level Security to ensure users can only access their own notes';
COMMENT ON COLUMN notes.id IS
'Primary key: random 24-character alphanumeric string';
COMMENT ON COLUMN notes.title IS
'Title of the note';
COMMENT ON COLUMN notes.content IS
'Content of the note (can be plain text or formatted text)';
COMMENT ON COLUMN notes.user_id IS
'Foreign key reference to auth.users.id - owner of the note';
COMMENT ON COLUMN notes.deleted_at IS
'Soft delete timestamp - when not NULL, the note is considered deleted';

File diff suppressed because it is too large Load diff