Add notes
This commit is contained in:
parent
e8c84eeadb
commit
5b68ad4c5c
18 changed files with 3125 additions and 1323 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
40
apps/main/src/components/NotesEditor.tsx
Normal file
40
apps/main/src/components/NotesEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
apps/main/src/hooks/notes.ts
Normal file
129
apps/main/src/hooks/notes.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 />,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"myEvents": "My Events",
|
||||
"planning": "Planning",
|
||||
"discussions": "Discussions",
|
||||
"notes": "Notes",
|
||||
"feedback": "Feedback",
|
||||
"settings": "Settings",
|
||||
"availabilities": "Availabilities",
|
||||
|
|
|
|||
25
apps/main/src/locales/en/notes.json
Normal file
25
apps/main/src/locales/en/notes.json
Normal 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"
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
"myEvents": "Mes Événements",
|
||||
"planning": "Planning",
|
||||
"discussions": "Discussions",
|
||||
"notes": "Notes",
|
||||
"feedback": "Feedback",
|
||||
"settings": "Paramètres",
|
||||
"availabilities": "Disponibilités",
|
||||
|
|
|
|||
25
apps/main/src/locales/fr/notes.json
Normal file
25
apps/main/src/locales/fr/notes.json
Normal 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"
|
||||
}
|
||||
358
apps/main/src/pages/notes.tsx
Normal file
358
apps/main/src/pages/notes.tsx
Normal 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
2
justfile
2
justfile
|
|
@ -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
1020
pnpm-lock.yaml
1020
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
81
sql/25_notes.sql
Normal file
81
sql/25_notes.sql
Normal 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
Loading…
Reference in a new issue