Merge pull request #24 from artslidd/develop

develop
This commit is contained in:
Arthur Belleville 2025-10-26 09:20:37 +01:00 committed by GitHub
commit 0c3cd43dec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 3808 additions and 890 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,41 @@
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;
readOnly?: boolean;
}
export function NotesEditor({ initialContent, onChange, readOnly = false }: NotesEditorProps) {
const { theme } = useTheme();
// Create editor instance
const editor: BlockNoteEditor = useCreateBlockNote({
initialContent: initialContent ? JSON.parse(initialContent) : undefined,
});
// Handle changes
const handleChange = () => {
if (onChange && !readOnly) {
const blocks = editor.document;
onChange(JSON.stringify(blocks));
}
};
return (
<div className="w-full p-4">
<BlockNoteView
editor={editor}
theme={theme === "dark" ? "dark" : "light"}
editable={!readOnly}
onChange={readOnly ? undefined : handleChange}
data-theming-css
/>
</div>
);
}

View file

@ -0,0 +1,110 @@
import { UserTablo } from "@xtablo/shared/types/tablos.types";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@xtablo/ui/components/select";
import { BookOpen } from "lucide-react";
import { useState } from "react";
import { useTabloNotes } from "../hooks/notes";
import { LoadingSpinner } from "./LoadingSpinner";
import { NotesEditor } from "./NotesEditor";
interface TabloNotesSectionProps {
tablo: UserTablo;
isAdmin: boolean;
}
export const TabloNotesSection = ({ tablo }: TabloNotesSectionProps) => {
const { notes, isLoading } = useTabloNotes(tablo.id);
const [selectedNoteId, setSelectedNoteId] = useState<string | null>(null);
// Auto-select first note when notes are loaded
if (notes && notes.length > 0 && !selectedNoteId) {
setSelectedNoteId(notes[0].id);
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<LoadingSpinner />
</div>
);
}
const selectedNote = notes?.find((note) => note.id === selectedNoteId);
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Notes</h1>
<p className="text-muted-foreground mt-1">Notes partagées avec ce tablo (lecture seule)</p>
</div>
{/* Empty State */}
{!notes || notes.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
<BookOpen className="w-8 h-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium text-foreground mb-2">Aucune note partagée</h3>
<p className="text-muted-foreground max-w-md">
Il n'y a actuellement aucune note partagée avec ce tablo.
</p>
</div>
) : (
<div className="space-y-6">
{/* Note Selector */}
<div className="flex items-center gap-4">
<label className="text-sm font-medium text-foreground whitespace-nowrap">
Sélectionner une note:
</label>
<Select value={selectedNoteId || undefined} onValueChange={setSelectedNoteId}>
<SelectTrigger className="w-full max-w-md">
<SelectValue placeholder="Choisir une note..." />
</SelectTrigger>
<SelectContent>
{notes.map((note) => (
<SelectItem key={note.id} value={note.id}>
{note.title || "Sans titre"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Selected Note Display */}
{selectedNote && (
<div className="border border-border rounded-lg bg-card overflow-hidden">
<div className="border-b border-border bg-muted/30 px-6 py-4">
<h2 className="text-xl font-semibold text-foreground">
{selectedNote.title || "Sans titre"}
</h2>
<p className="text-xs text-muted-foreground mt-1">
Dernière modification:{" "}
{selectedNote.updated_at &&
new Date(selectedNote.updated_at).toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
<div className="bg-background">
<NotesEditor
key={selectedNote.id}
initialContent={selectedNote.content || ""}
readOnly={true}
/>
</div>
</div>
)}
</div>
)}
</div>
);
};

View file

@ -0,0 +1,394 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
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<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,
updated_at: new Date().toISOString(),
})
.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"] });
queryClient.invalidateQueries({ queryKey: ["tablo-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 };
}
/**
* 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] });
queryClient.invalidateQueries({ queryKey: ["tablo-notes"] });
},
});
}
/**
* Hook to fetch a public note (unauthenticated access allowed)
*/
export function usePublicNote(noteId: string | undefined) {
const { data: note, isLoading } = useQuery<Note | null>({
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<Note[]>({
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 };
}
/**
* Hook to fetch notes shared with a specific tablo
*/
export function useTabloNotes(tabloId: string | undefined) {
const user = useUser();
const { data: notes, isLoading } = useQuery<Note[]>({
queryKey: ["tablo-notes", tabloId],
queryFn: async () => {
if (!tabloId) return [];
// Find notes shared with this specific tablo or all tablos
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)
.or(`tablo_id.eq.${tabloId},tablo_id.is.null`)
.is("notes.deleted_at", null);
if (error) throw error;
// Extract notes from the join result and remove duplicates
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[];
// Remove duplicates by note id (in case a note is shared both with all tablos and this specific tablo)
const uniqueNotes = Array.from(
new Map(extractedNotes.map((note) => [note.id, note])).values()
);
return uniqueNotes;
},
enabled: !!tabloId && !!user.id,
});
return { notes, isLoading };
}

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

@ -12,8 +12,10 @@ import { JoinPage } from "../pages/join";
import { LandingPage } from "../pages/landing";
import { LoginPage } from "../pages/login";
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";
@ -74,6 +76,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 />,
@ -126,6 +136,11 @@ export const routes: RouteObject[] = [
path: "/book/:user_info/:event_type_standard_name",
element: <PublicBookingPage />,
},
// Public notes route (unauthenticated access)
{
path: "/notes/public/:noteId",
element: <PublicNotePage />,
},
// Authentication pages (redirected to "/" if user is authenticated)
{
path: "/",

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,53 @@
{
"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",
"justNow": "Just now",
"minutesAgo": "{{count}}m ago",
"hoursAgo": "{{count}}h ago",
"today": "Today",
"yesterday": "Yesterday",
"daysAgo": "{{count}} days ago",
"daysAgo_one": "{{count}} day ago",
"daysAgo_other": "{{count}} days ago",
"weeksAgo": "{{count}} weeks ago",
"weeksAgo_one": "{{count}} week ago",
"weeksAgo_other": "{{count}} weeks ago",
"monthsAgo": "{{count}} months ago",
"monthsAgo_one": "{{count}} month 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"
}

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,53 @@
{
"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",
"justNow": "À l'instant",
"minutesAgo": "Il y a {{count}} minutes",
"hoursAgo": "Il y a {{count}} heures",
"today": "Aujourd'hui",
"yesterday": "Hier",
"daysAgo": "Il y a {{count}} jours",
"daysAgo_one": "Il y a {{count}} jour",
"daysAgo_other": "Il y a {{count}} jours",
"weeksAgo": "Il y a {{count}} semaines",
"weeksAgo_one": "Il y a {{count}} semaine",
"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",
"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"
}

View file

@ -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 (
<div className="flex h-screen items-center justify-center bg-background">
<Card className="w-full max-w-4xl mx-4">
<CardContent className="flex items-center justify-center p-8">
<div className="text-center">
<TypographyMuted>Loading note...</TypographyMuted>
</div>
</CardContent>
</Card>
</div>
);
}
if (!note) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<Card className="w-full max-w-4xl mx-4">
<CardContent className="flex flex-col items-center justify-center p-8 gap-4">
<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">Note not found</TypographyH3>
<TypographyMuted className="text-sm max-w-md">
This note doesn't exist, isn't public, or has been deleted.
</TypographyMuted>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex h-screen flex-col bg-background">
<div className="flex-1 p-6">
<Card className="flex flex-col h-full max-w-5xl mx-auto">
<CardHeader className="border-b flex flex-row items-center justify-between">
<div className="flex items-center gap-3">
<FileTextIcon className="size-6 text-primary" />
<div className="flex-1">
<TypographyH3 className="text-2xl font-bold">
{note.title || "Untitled Note"}
</TypographyH3>
<TypographyMuted className="text-sm">Read-only public note</TypographyMuted>
</div>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-y-auto p-0">
<NotesEditor initialContent={note.content ?? ""} readOnly={true} />
</CardContent>
</Card>
</div>
</div>
);
}

View file

@ -0,0 +1,532 @@
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 { 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();
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 [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
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();
// 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);
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]);
// 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 t("justNow", "Just now");
if (diffInMinutes < 60) return t("minutesAgo", "{{count}}m ago", { count: diffInMinutes });
if (diffInHours < 24) return t("hoursAgo", "{{count}}h ago", { count: diffInHours });
if (diffInDays === 0) return t("today", "Today");
if (diffInDays === 1) return t("yesterday", "Yesterday");
if (diffInDays < 7) return t("daysAgo", "{{count}} days ago", { count: diffInDays });
if (diffInDays < 30)
return t("weeksAgo", "{{count}} weeks ago", { count: Math.floor(diffInDays / 7) });
if (diffInDays < 365)
return t("monthsAgo", "{{count}} months ago", { count: Math.floor(diffInDays / 30) });
return date.toLocaleDateString();
};
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);
goCreateNote();
};
const handleNewNote = () => {
if (hasUnsavedChanges) {
const confirmed = window.confirm(t("unsavedChangesWarning"));
if (!confirmed) return;
}
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) {
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-y-auto 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>
<div className="flex items-center gap-2">
{noteId && (
<Dialog open={isShareDialogOpen} onOpenChange={setIsShareDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Share2Icon className="mr-2 size-4" />
{t("share")}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("shareNote")}</DialogTitle>
<DialogDescription>{t("shareNoteDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Public Sharing */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="public-toggle" className="text-base font-medium">
{t("publicAccess")}
</Label>
<TypographyMuted className="text-sm">
{t("publicAccessDescription")}
</TypographyMuted>
</div>
<Switch
id="public-toggle"
checked={isPublic}
onCheckedChange={(checked) =>
handleSaveSharing(checked, isSharedWithAllTablos)
}
disabled={isSharingLoading || isSharingUpdating}
/>
</div>
{isPublic && (
<div className="flex items-center gap-2 rounded-md bg-muted p-2">
<Input
readOnly
value={`${window.location.origin}/notes/public/${noteId}`}
className="text-xs"
/>
<Button
size="icon"
variant="ghost"
onClick={handleCopyPublicLink}
title={t("copyLink")}
>
<CopyIcon className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
onClick={() =>
window.open(
`${window.location.origin}/notes/public/${noteId}`,
"_blank"
)
}
title={t("openLink")}
>
<ExternalLinkIcon className="size-4" />
</Button>
</div>
)}
</div>
{/* Tablo Sharing */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="tablo-toggle" className="text-base font-medium">
{t("shareWithAllTablos")}
</Label>
<TypographyMuted className="text-sm">
{t("shareWithAllTablosDescription")}
</TypographyMuted>
</div>
<Switch
id="tablo-toggle"
checked={isSharedWithAllTablos}
onCheckedChange={(checked) => handleSaveSharing(isPublic, checked)}
disabled={isSharingLoading || isSharingUpdating}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => {
setIsShareDialogOpen(false);
}}
>
{t("close")}
</Button>
</div>
</DialogContent>
</Dialog>
)}
<Button onClick={handleSave} disabled={!hasUnsavedChanges || isCreating || isUpdating}>
<SaveIcon className="mr-2 size-4" />
{isCreating ? t("saving") : t("saveNote")}
</Button>
</div>
</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>
);
}

View file

@ -1,16 +1,17 @@
import { toast } from "@xtablo/shared";
import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button";
import { ArrowLeft, FileText, MessageSquare, Settings } from "lucide-react";
import { ArrowLeft, BookOpen, FileText, MessageSquare, Settings } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { LoadingSpinner } from "../components/LoadingSpinner";
import { TabloDiscussionSection } from "../components/TabloDiscussionSection";
import { TabloFilesSection } from "../components/TabloFilesSection";
import { TabloNotesSection } from "../components/TabloNotesSection";
import { TabloSettingsSection } from "../components/TabloSettingsSection";
import { useTablosList, useUpdateTablo } from "../hooks/tablos";
type TabSection = "files" | "discussion" | "settings";
type TabSection = "files" | "discussion" | "notes" | "settings";
export const TabloDetailsPage = () => {
const { tabloId } = useParams<{ tabloId: string }>();
@ -93,6 +94,11 @@ export const TabloDetailsPage = () => {
label: "Discussion",
icon: <MessageSquare className="w-5 h-5" />,
},
{
id: "notes",
label: "Notes",
icon: <BookOpen className="w-5 h-5" />,
},
{
id: "settings",
label: "Paramètres",
@ -170,6 +176,7 @@ export const TabloDetailsPage = () => {
{activeSection === "discussion" && (
<TabloDiscussionSection tablo={tablo} isAdmin={isAdmin} />
)}
{activeSection === "notes" && <TabloNotesSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "settings" && (
<TabloSettingsSection tablo={tablo} isAdmin={isAdmin} onEdit={handleEdit} />
)}

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

View file

@ -250,6 +250,95 @@ 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;
created_at: string | null;
deleted_at: string | null;
id: string;
title: string;
updated_at: string | null;
user_id: string;
};
Insert: {
content?: string | null;
created_at?: string | null;
deleted_at?: string | null;
id?: string;
title: string;
updated_at?: string | null;
user_id: string;
};
Update: {
content?: string | null;
created_at?: string | null;
deleted_at?: string | null;
id?: string;
title?: string;
updated_at?: string | null;
user_id?: string;
};
Relationships: [];
};
profiles: {
Row: {
avatar_url: string | null;
@ -283,6 +372,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;
@ -488,10 +609,7 @@ export type Database = {
};
};
Functions: {
generate_random_string: {
Args: { length?: number };
Returns: string;
};
generate_random_string: { Args: { length?: number }; Returns: string };
};
Enums: {
devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired";

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';

View file

@ -0,0 +1,164 @@
-- Create shared_notes table for public sharing functionality
CREATE TABLE IF NOT EXISTS shared_notes (
note_id TEXT PRIMARY KEY,
user_id UUID NOT NULL,
is_public BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- Foreign key constraint to notes table
CONSTRAINT fk_shared_notes_note_id
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
-- Foreign key constraint to users table (auth.users)
CONSTRAINT fk_shared_notes_user_id
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE
);
-- Create index for performance on public notes
CREATE INDEX IF NOT EXISTS idx_shared_notes_is_public ON shared_notes(is_public);
CREATE INDEX IF NOT EXISTS idx_shared_notes_user_id ON shared_notes(user_id);
-- Enable Row Level Security
ALTER TABLE shared_notes ENABLE ROW LEVEL SECURITY;
-- Policy to allow users to view their own shared_notes entries
CREATE POLICY "Users can view their own shared notes" ON shared_notes
FOR SELECT
TO authenticated
USING (user_id = (SELECT auth.uid()));
-- Policy to allow anonymous users to view public notes
CREATE POLICY "Anyone can view public notes" ON shared_notes
FOR SELECT
TO anon
USING (is_public = TRUE);
-- Policy to allow users to insert their own shared_notes entries
CREATE POLICY "Users can insert their own shared notes" ON shared_notes
FOR INSERT
TO authenticated
WITH CHECK (user_id = (SELECT auth.uid()));
-- Policy to allow users to update their own shared_notes entries
CREATE POLICY "Users can update their own shared notes" ON shared_notes
FOR UPDATE
TO authenticated
USING (user_id = (SELECT auth.uid()))
WITH CHECK (user_id = (SELECT auth.uid()));
-- Policy to allow users to delete their own shared_notes entries
CREATE POLICY "Users can delete their own shared notes" ON shared_notes
FOR DELETE
TO authenticated
USING (user_id = (SELECT auth.uid()));
-- Create note_access table for tablo-based sharing
CREATE TABLE IF NOT EXISTS note_access (
id SERIAL PRIMARY KEY,
note_id TEXT NOT NULL,
user_id UUID NOT NULL,
tablo_id TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- Foreign key constraint to notes table
CONSTRAINT fk_note_access_note_id
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
-- Foreign key constraint to users table (auth.users)
CONSTRAINT fk_note_access_user_id
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE,
-- Foreign key constraint to tablos table (nullable for "all tablos" sharing)
CONSTRAINT fk_note_access_tablo_id
FOREIGN KEY (tablo_id) REFERENCES tablos(id) ON DELETE CASCADE
);
-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_note_access_note_id ON note_access(note_id);
CREATE INDEX IF NOT EXISTS idx_note_access_user_id ON note_access(user_id);
CREATE INDEX IF NOT EXISTS idx_note_access_tablo_id ON note_access(tablo_id);
CREATE INDEX IF NOT EXISTS idx_note_access_is_active ON note_access(is_active);
-- Unique constraint for specific tablo sharing (when tablo_id IS NOT NULL)
CREATE UNIQUE INDEX IF NOT EXISTS unique_note_access_with_tablo
ON note_access(note_id, user_id, tablo_id)
WHERE tablo_id IS NOT NULL;
-- Unique constraint for "all tablos" sharing (when tablo_id IS NULL)
-- Only one NULL tablo_id per (note_id, user_id) combination
CREATE UNIQUE INDEX IF NOT EXISTS unique_note_access_all_tablos
ON note_access(note_id, user_id)
WHERE tablo_id IS NULL;
-- Enable Row Level Security
ALTER TABLE note_access ENABLE ROW LEVEL SECURITY;
-- Policy to allow users to view their own note_access entries
CREATE POLICY "Users can view their own note access" ON note_access
FOR SELECT
TO authenticated
USING (user_id = (SELECT auth.uid()));
-- Policy to allow users to view notes shared with their tablos
CREATE POLICY "Users can view notes shared with their tablos" ON note_access
FOR SELECT
TO authenticated
USING (
is_active = TRUE
AND (
-- Shared with all tablos (tablo_id is NULL)
tablo_id IS NULL
-- Or shared with a specific tablo where the user has access
OR EXISTS (
SELECT 1 FROM tablo_access
WHERE tablo_access.tablo_id = note_access.tablo_id
AND tablo_access.user_id = (SELECT auth.uid())
AND tablo_access.is_active = TRUE
)
)
);
-- Policy to allow users to insert their own note_access entries
CREATE POLICY "Users can insert their own note access" ON note_access
FOR INSERT
TO authenticated
WITH CHECK (user_id = (SELECT auth.uid()));
-- Policy to allow users to update their own note_access entries
CREATE POLICY "Users can update their own note access" ON note_access
FOR UPDATE
TO authenticated
USING (user_id = (SELECT auth.uid()))
WITH CHECK (user_id = (SELECT auth.uid()));
-- Policy to allow users to delete their own note_access entries
CREATE POLICY "Users can delete their own note access" ON note_access
FOR DELETE
TO authenticated
USING (user_id = (SELECT auth.uid()));
-- Add comments to document the tables
COMMENT ON TABLE shared_notes IS
'Tracks which notes are shared publicly with Row Level Security';
COMMENT ON COLUMN shared_notes.note_id IS
'Foreign key reference to notes.id';
COMMENT ON COLUMN shared_notes.user_id IS
'Foreign key reference to auth.users.id - owner of the note';
COMMENT ON COLUMN shared_notes.is_public IS
'When TRUE, the note is publicly accessible via /notes/public/:noteId';
COMMENT ON TABLE note_access IS
'Tracks which notes are shared with tablos. When tablo_id IS NULL and is_active = TRUE, the note is shared with all user tablos. Uses partial unique indexes to handle NULL values correctly.';
COMMENT ON COLUMN note_access.tablo_id IS
'Foreign key reference to tablos.id - NULL means shared with all user tablos. Partial unique indexes ensure only one NULL per (note_id, user_id) combination.';
COMMENT ON COLUMN note_access.is_active IS
'When TRUE, the sharing is active';

File diff suppressed because it is too large Load diff