diff --git a/api/src/database.types.ts b/api/src/database.types.ts index 34c7e6d..c22bd47 100644 --- a/api/src/database.types.ts +++ b/api/src/database.types.ts @@ -256,6 +256,65 @@ export type Database = { } Relationships: [] } + note_access: { + Row: { + created_at: string | null + id: number + is_active: boolean | null + note_id: string + tablo_id: string | null + updated_at: string | null + user_id: string + } + Insert: { + created_at?: string | null + id?: number + is_active?: boolean | null + note_id: string + tablo_id?: string | null + updated_at?: string | null + user_id: string + } + Update: { + created_at?: string | null + id?: number + is_active?: boolean | null + note_id?: string + tablo_id?: string | null + updated_at?: string | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: "fk_note_access_note_id" + columns: ["note_id"] + isOneToOne: false + referencedRelation: "notes" + referencedColumns: ["id"] + }, + { + foreignKeyName: "fk_note_access_tablo_id" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "events_and_tablos" + referencedColumns: ["tablo_id"] + }, + { + foreignKeyName: "fk_note_access_tablo_id" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "tablos" + referencedColumns: ["id"] + }, + { + foreignKeyName: "fk_note_access_tablo_id" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "user_tablos" + referencedColumns: ["id"] + }, + ] + } notes: { Row: { content: string | null @@ -319,6 +378,38 @@ export type Database = { } Relationships: [] } + shared_notes: { + Row: { + created_at: string | null + is_public: boolean | null + note_id: string + updated_at: string | null + user_id: string + } + Insert: { + created_at?: string | null + is_public?: boolean | null + note_id: string + updated_at?: string | null + user_id: string + } + Update: { + created_at?: string | null + is_public?: boolean | null + note_id?: string + updated_at?: string | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: "fk_shared_notes_note_id" + columns: ["note_id"] + isOneToOne: true + referencedRelation: "notes" + referencedColumns: ["id"] + }, + ] + } tablo_access: { Row: { created_at: string | null diff --git a/apps/main/src/components/NotesEditor.tsx b/apps/main/src/components/NotesEditor.tsx index 768360f..ec79788 100644 --- a/apps/main/src/components/NotesEditor.tsx +++ b/apps/main/src/components/NotesEditor.tsx @@ -7,10 +7,11 @@ import { useTheme } from "@xtablo/shared/contexts/ThemeContext"; interface NotesEditorProps { initialContent: string; - onChange: (content: string) => void; + onChange?: (content: string) => void; + readOnly?: boolean; } -export function NotesEditor({ initialContent, onChange }: NotesEditorProps) { +export function NotesEditor({ initialContent, onChange, readOnly = false }: NotesEditorProps) { const { theme } = useTheme(); // Create editor instance @@ -20,19 +21,19 @@ export function NotesEditor({ initialContent, onChange }: NotesEditorProps) { // Handle changes const handleChange = () => { - if (onChange) { + if (onChange && !readOnly) { const blocks = editor.document; onChange(JSON.stringify(blocks)); } }; return ( -
+
diff --git a/apps/main/src/hooks/notes.ts b/apps/main/src/hooks/notes.ts index 2d7cefb..eed3a40 100644 --- a/apps/main/src/hooks/notes.ts +++ b/apps/main/src/hooks/notes.ts @@ -1,8 +1,8 @@ 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"; +import { supabase } from "../lib/supabase"; +import { useUser } from "../providers/UserStoreProvider"; type Note = Database["public"]["Tables"]["notes"]["Row"]; type CreateNoteInput = Pick; @@ -127,3 +127,209 @@ export function useDeleteNote() { }); return { mutate, isPending }; } + +/** + * Hook to fetch sharing settings for a specific note + */ +export function useNoteSharing(noteId: string | undefined) { + const user = useUser(); + const { data, isLoading } = useQuery({ + queryKey: ["note-sharing", noteId], + queryFn: async () => { + if (!noteId) return { isPublic: false, isSharedWithAllTablos: false }; + + // Fetch public sharing status + const { data: sharedNote, error: sharedError } = await supabase + .from("shared_notes") + .select("is_public") + .eq("note_id", noteId) + .eq("user_id", user.id) + .maybeSingle(); + + if (sharedError) throw sharedError; + + // Fetch tablo sharing status (tablo_id IS NULL means shared with all tablos) + const { data: noteAccess, error: accessError } = await supabase + .from("note_access") + .select("is_active, tablo_id") + .eq("note_id", noteId) + .eq("user_id", user.id) + .is("tablo_id", null) + .maybeSingle(); + + if (accessError) throw accessError; + + return { + isPublic: sharedNote?.is_public ?? false, + isSharedWithAllTablos: noteAccess?.is_active ?? false, + }; + }, + enabled: !!noteId && !!user.id, + }); + return { isPublic: data?.isPublic, isSharedWithAllTablos: data?.isSharedWithAllTablos, isLoading }; +} + +/** + * Hook to update sharing settings for a note + */ +export function useUpdateNoteSharing() { + const user = useUser(); + const queryClient = useQueryClient(); + + return useMutation< + void, + Error, + { noteId: string; isPublic: boolean; isSharedWithAllTablos: boolean } + >({ + mutationFn: async ({ noteId, isPublic, isSharedWithAllTablos }) => { + // Upsert shared_notes for public sharing + const { error: sharedError } = await supabase.from("shared_notes").upsert( + { + note_id: noteId, + user_id: user.id, + is_public: isPublic, + updated_at: new Date().toISOString(), + }, + { + onConflict: "note_id", + } + ); + + if (sharedError) throw sharedError; + + // Handle tablo sharing + if (isSharedWithAllTablos) { + // First, try to update existing "all tablos" sharing record + const { data: existingRecord, error: selectError } = await supabase + .from("note_access") + .select("id") + .eq("note_id", noteId) + .eq("user_id", user.id) + .is("tablo_id", null) + .maybeSingle(); + + if (selectError) throw selectError; + + if (existingRecord) { + // Update existing record + const { error: updateError } = await supabase + .from("note_access") + .update({ + is_active: true, + updated_at: new Date().toISOString(), + }) + .eq("id", existingRecord.id); + + if (updateError) throw updateError; + } else { + // Insert new record + const { error: insertError } = await supabase.from("note_access").insert({ + note_id: noteId, + user_id: user.id, + tablo_id: null, + is_active: true, + updated_at: new Date().toISOString(), + }); + + if (insertError) throw insertError; + } + } else { + // Remove tablo sharing by deleting + const { error: deleteError } = await supabase + .from("note_access") + .delete() + .eq("note_id", noteId) + .eq("user_id", user.id) + .is("tablo_id", null); + + if (deleteError) throw deleteError; + } + }, + onSuccess: (_, variables) => { + // Invalidate sharing queries + queryClient.invalidateQueries({ queryKey: ["note-sharing", variables.noteId] }); + }, + }); +} + +/** + * Hook to fetch a public note (unauthenticated access allowed) + */ +export function usePublicNote(noteId: string | undefined) { + const { data: note, isLoading } = useQuery({ + queryKey: ["public-note", noteId], + queryFn: async () => { + if (!noteId) return null; + + // First check if the note is public + const { data: sharedNote, error: sharedError } = await supabase + .from("shared_notes") + .select("note_id, is_public") + .eq("note_id", noteId) + .eq("is_public", true) + .maybeSingle(); + + if (sharedError) throw sharedError; + if (!sharedNote) return null; + + // Fetch the actual note + const { data: noteData, error: noteError } = await supabase + .from("notes") + .select("*") + .eq("id", noteId) + .is("deleted_at", null) + .maybeSingle(); + + if (noteError) throw noteError; + return noteData as Note | null; + }, + enabled: !!noteId, + }); + + return { note, isLoading }; +} + +/** + * Hook to fetch notes shared with user's tablos + */ +export function useSharedTabloNotes() { + const user = useUser(); + const { data: notes, isLoading } = useQuery({ + queryKey: ["shared-tablo-notes"], + queryFn: async () => { + // Find notes shared with tablos the user has access to + const { data, error } = await supabase + .from("note_access") + .select( + ` + note_id, + notes!inner ( + id, + title, + content, + user_id, + created_at, + updated_at, + deleted_at + ) + ` + ) + .eq("is_active", true) + .neq("user_id", user.id) // Don't include user's own notes + .is("notes.deleted_at", null); + + if (error) throw error; + + // Extract notes from the join result + type JoinedResult = { note_id: string; notes: Note | Note[] }; + const extractedNotes = (data as JoinedResult[]) + .map((item) => (Array.isArray(item.notes) ? item.notes[0] : item.notes)) + .filter((note) => note !== null && note !== undefined) as Note[]; + + return extractedNotes; + }, + enabled: !!user.id, + }); + + return { notes, isLoading }; +} diff --git a/apps/main/src/lib/routes.tsx b/apps/main/src/lib/routes.tsx index f4c26df..1df0551 100644 --- a/apps/main/src/lib/routes.tsx +++ b/apps/main/src/lib/routes.tsx @@ -11,10 +11,11 @@ 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 NotesPage from "../pages/notes"; import { OAuthSigninPage } from "../pages/oauth-signin"; import { PublicBookingPage } from "../pages/PublicBookingPage"; +import { PublicNotePage } from "../pages/PublicNotePage"; import { PlanningPage } from "../pages/planning"; import { ResetPasswordPage } from "../pages/reset-password"; import SettingsPage from "../pages/settings"; @@ -135,6 +136,11 @@ export const routes: RouteObject[] = [ path: "/book/:user_info/:event_type_standard_name", element: , }, + // Public notes route (unauthenticated access) + { + path: "/notes/public/:noteId", + element: , + }, // Authentication pages (redirected to "/" if user is authenticated) { path: "/", diff --git a/apps/main/src/locales/en/notes.json b/apps/main/src/locales/en/notes.json index 9b1b4a2..54c74b4 100644 --- a/apps/main/src/locales/en/notes.json +++ b/apps/main/src/locales/en/notes.json @@ -35,5 +35,19 @@ "weeksAgo_other": "{{count}} weeks ago", "monthsAgo": "{{count}} months ago", "monthsAgo_one": "{{count}} month ago", - "monthsAgo_other": "{{count}} months ago" + "monthsAgo_other": "{{count}} months ago", + "share": "Share", + "shareNote": "Share Note", + "shareNoteDescription": "Control who can access this note", + "publicAccess": "Public Access", + "publicAccessDescription": "Anyone with the link can view this note", + "shareWithAllTablos": "Share with All Tablos", + "shareWithAllTablosDescription": "Members of all your tablos can view this note", + "close": "Close", + "saveNoteBeforeSharing": "Please save the note before sharing", + "sharingSettingsUpdated": "Sharing settings updated", + "failedToUpdateSharing": "Failed to update sharing", + "publicLinkCopied": "Public link copied to clipboard", + "copyLink": "Copy link", + "openLink": "Open link" } diff --git a/apps/main/src/locales/fr/notes.json b/apps/main/src/locales/fr/notes.json index f1ec569..16b16a7 100644 --- a/apps/main/src/locales/fr/notes.json +++ b/apps/main/src/locales/fr/notes.json @@ -35,5 +35,19 @@ "weeksAgo_other": "Il y a {{count}} semaines", "monthsAgo": "Il y a {{count}} mois", "monthsAgo_one": "Il y a {{count}} mois", - "monthsAgo_other": "Il y a {{count}} mois" + "monthsAgo_other": "Il y a {{count}} mois", + "share": "Partager", + "shareNote": "Partager la note", + "shareNoteDescription": "Contrôlez qui peut accéder à cette note", + "publicAccess": "Accès public", + "publicAccessDescription": "Toute personne avec le lien peut voir cette note", + "shareWithAllTablos": "Partager avec tous les tablos", + "shareWithAllTablosDescription": "Les membres de tous vos tablos peuvent voir cette note", + "close": "Fermer", + "saveNoteBeforeSharing": "Veuillez enregistrer la note avant de la partager", + "sharingSettingsUpdated": "Paramètres de partage mis à jour", + "failedToUpdateSharing": "Échec de la mise à jour du partage", + "publicLinkCopied": "Lien public copié dans le presse-papiers", + "copyLink": "Copier le lien", + "openLink": "Ouvrir le lien" } diff --git a/apps/main/src/pages/PublicNotePage.tsx b/apps/main/src/pages/PublicNotePage.tsx new file mode 100644 index 0000000..729d14e --- /dev/null +++ b/apps/main/src/pages/PublicNotePage.tsx @@ -0,0 +1,68 @@ +import { Card, CardContent, CardHeader } from "@xtablo/ui/components/card"; +import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography"; +import { FileTextIcon, SearchXIcon } from "lucide-react"; +import { useParams } from "react-router-dom"; +import { NotesEditor } from "../components/NotesEditor"; +import { usePublicNote } from "../hooks/notes"; + +export function PublicNotePage() { + const { noteId } = useParams<{ noteId: string }>(); + const { note, isLoading } = usePublicNote(noteId); + + if (isLoading) { + return ( +
+ + +
+ Loading note... +
+
+
+
+ ); + } + + if (!note) { + return ( +
+ + +
+ +
+
+ Note not found + + This note doesn't exist, isn't public, or has been deleted. + +
+
+
+
+ ); + } + + return ( +
+
+ + +
+ +
+ + {note.title || "Untitled Note"} + + Read-only public note +
+
+
+ + + +
+
+
+ ); +} diff --git a/apps/main/src/pages/notes.tsx b/apps/main/src/pages/notes.tsx index 6283a07..4117faa 100644 --- a/apps/main/src/pages/notes.tsx +++ b/apps/main/src/pages/notes.tsx @@ -1,24 +1,45 @@ import { toast } from "@xtablo/shared"; import { Button } from "@xtablo/ui/components/button"; import { Card, CardContent, CardHeader } from "@xtablo/ui/components/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@xtablo/ui/components/dialog"; import { Input } from "@xtablo/ui/components/input"; +import { Label } from "@xtablo/ui/components/label"; +import { Switch } from "@xtablo/ui/components/switch"; import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography"; import { ChevronLeftIcon, ChevronRightIcon, ClockIcon, + CopyIcon, + ExternalLinkIcon, FileTextIcon, PlusIcon, SaveIcon, SearchXIcon, + Share2Icon, 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"; +import { NotesEditor } from "../components/NotesEditor"; +import { + useCreateNote, + useDeleteNote, + useNote, + useNoteSharing, + useNotes, + useUpdateNote, + useUpdateNoteSharing, +} from "../hooks/notes"; const useNoteId = () => { const navigate = useNavigate(); @@ -47,6 +68,7 @@ export default function NotesPage({ mode }: { mode: "create" | "edit" }) { const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [isSidebarVisible, setIsSidebarVisible] = useState(true); const [editorKey, setEditorKey] = useState(0); + const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); const { notes, isLoading } = useNotes(); const { note, isLoading: isNoteLoading } = useNote(noteId); @@ -54,6 +76,10 @@ export default function NotesPage({ mode }: { mode: "create" | "edit" }) { const { mutate: updateNote, isPending: isUpdating } = useUpdateNote(); const { mutate: deleteNote, isPending: isDeleting } = useDeleteNote(); + // Sharing hooks + const { isPublic, isSharedWithAllTablos, isLoading: isSharingLoading } = useNoteSharing(noteId); + const { mutate: updateSharing, isPending: isSharingUpdating } = useUpdateNoteSharing(); + useEffect(() => { if (noteId) { const note = notes?.find((note) => note.id === noteId); @@ -144,6 +170,52 @@ export default function NotesPage({ mode }: { mode: "create" | "edit" }) { resetEditor(); }; + const handleSaveSharing = (isPublic: boolean, isSharedWithAllTablos: boolean) => { + if (!noteId) { + toast.add({ + title: t("common:error", "Error"), + description: t("saveNoteBeforeSharing"), + type: "error", + }); + return; + } + + updateSharing( + { + noteId, + isPublic, + isSharedWithAllTablos, + }, + { + onSuccess: () => { + toast.add({ + title: t("common:success", "Success"), + description: t("sharingSettingsUpdated"), + type: "success", + }); + }, + onError: (error: Error) => { + toast.add({ + title: t("common:error", "Error"), + description: `${t("failedToUpdateSharing")}: ${error.message}`, + type: "error", + }); + }, + } + ); + }; + + const handleCopyPublicLink = () => { + if (!noteId) return; + const publicUrl = `${window.location.origin}/notes/public/${noteId}`; + navigator.clipboard.writeText(publicUrl); + toast.add({ + title: t("common:success", "Success"), + description: t("publicLinkCopied"), + type: "success", + }); + }; + const renderNote = () => { if (mode === "edit" && noteId) { if (isNoteLoading) { @@ -213,7 +285,7 @@ export default function NotesPage({ mode }: { mode: "create" | "edit" }) { )}
- + {t("description")} - +
+ {noteId && ( + + + + + + + {t("shareNote")} + {t("shareNoteDescription")} + +
+ {/* Public Sharing */} +
+
+
+ + + {t("publicAccessDescription")} + +
+ + handleSaveSharing(checked, isSharedWithAllTablos) + } + disabled={isSharingLoading || isSharingUpdating} + /> +
+ + {isPublic && ( +
+ + + +
+ )} +
+ + {/* Tablo Sharing */} +
+
+ + + {t("shareWithAllTablosDescription")} + +
+ handleSaveSharing(isPublic, checked)} + disabled={isSharingLoading || isSharingUpdating} + /> +
+
+
+ +
+
+
+ )} + +
{/* Main Content */} diff --git a/apps/main/stats.html b/apps/main/stats.html index 08aac9d..84b6825 100644 --- a/apps/main/stats.html +++ b/apps/main/stats.html @@ -4929,7 +4929,7 @@ var drawChart = (function (exports) {