commit
0c3cd43dec
22 changed files with 3808 additions and 890 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">
|
||||
|
|
|
|||
41
apps/main/src/components/NotesEditor.tsx
Normal file
41
apps/main/src/components/NotesEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
apps/main/src/components/TabloNotesSection.tsx
Normal file
110
apps/main/src/components/TabloNotesSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
394
apps/main/src/hooks/notes.ts
Normal file
394
apps/main/src/hooks/notes.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: "/",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"myEvents": "My Events",
|
||||
"planning": "Planning",
|
||||
"discussions": "Discussions",
|
||||
"notes": "Notes",
|
||||
"feedback": "Feedback",
|
||||
"settings": "Settings",
|
||||
"availabilities": "Availabilities",
|
||||
|
|
|
|||
53
apps/main/src/locales/en/notes.json
Normal file
53
apps/main/src/locales/en/notes.json
Normal 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"
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
"myEvents": "Mes Événements",
|
||||
"planning": "Planning",
|
||||
"discussions": "Discussions",
|
||||
"notes": "Notes",
|
||||
"feedback": "Feedback",
|
||||
"settings": "Paramètres",
|
||||
"availabilities": "Disponibilités",
|
||||
|
|
|
|||
53
apps/main/src/locales/fr/notes.json
Normal file
53
apps/main/src/locales/fr/notes.json
Normal 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"
|
||||
}
|
||||
68
apps/main/src/pages/PublicNotePage.tsx
Normal file
68
apps/main/src/pages/PublicNotePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
532
apps/main/src/pages/notes.tsx
Normal file
532
apps/main/src/pages/notes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
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';
|
||||
|
||||
164
sql/26_create_note_sharing_tables.sql
Normal file
164
sql/26_create_note_sharing_tables.sql
Normal 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
Loading…
Reference in a new issue