Add the ability to share notes
This commit is contained in:
parent
a30edae098
commit
39ec616c03
12 changed files with 937 additions and 19 deletions
|
|
@ -256,6 +256,65 @@ export type Database = {
|
|||
}
|
||||
Relationships: []
|
||||
}
|
||||
note_access: {
|
||||
Row: {
|
||||
created_at: string | null
|
||||
id: number
|
||||
is_active: boolean | null
|
||||
note_id: string
|
||||
tablo_id: string | null
|
||||
updated_at: string | null
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string | null
|
||||
id?: number
|
||||
is_active?: boolean | null
|
||||
note_id: string
|
||||
tablo_id?: string | null
|
||||
updated_at?: string | null
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string | null
|
||||
id?: number
|
||||
is_active?: boolean | null
|
||||
note_id?: string
|
||||
tablo_id?: string | null
|
||||
updated_at?: string | null
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "fk_note_access_note_id"
|
||||
columns: ["note_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "notes"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "fk_note_access_tablo_id"
|
||||
columns: ["tablo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "events_and_tablos"
|
||||
referencedColumns: ["tablo_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "fk_note_access_tablo_id"
|
||||
columns: ["tablo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "tablos"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "fk_note_access_tablo_id"
|
||||
columns: ["tablo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_tablos"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
notes: {
|
||||
Row: {
|
||||
content: string | null
|
||||
|
|
@ -319,6 +378,38 @@ export type Database = {
|
|||
}
|
||||
Relationships: []
|
||||
}
|
||||
shared_notes: {
|
||||
Row: {
|
||||
created_at: string | null
|
||||
is_public: boolean | null
|
||||
note_id: string
|
||||
updated_at: string | null
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string | null
|
||||
is_public?: boolean | null
|
||||
note_id: string
|
||||
updated_at?: string | null
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string | null
|
||||
is_public?: boolean | null
|
||||
note_id?: string
|
||||
updated_at?: string | null
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "fk_shared_notes_note_id"
|
||||
columns: ["note_id"]
|
||||
isOneToOne: true
|
||||
referencedRelation: "notes"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
tablo_access: {
|
||||
Row: {
|
||||
created_at: string | null
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@ import { useTheme } from "@xtablo/shared/contexts/ThemeContext";
|
|||
|
||||
interface NotesEditorProps {
|
||||
initialContent: string;
|
||||
onChange: (content: string) => void;
|
||||
onChange?: (content: string) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function NotesEditor({ initialContent, onChange }: NotesEditorProps) {
|
||||
export function NotesEditor({ initialContent, onChange, readOnly = false }: NotesEditorProps) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
// Create editor instance
|
||||
|
|
@ -20,19 +21,19 @@ export function NotesEditor({ initialContent, onChange }: NotesEditorProps) {
|
|||
|
||||
// Handle changes
|
||||
const handleChange = () => {
|
||||
if (onChange) {
|
||||
if (onChange && !readOnly) {
|
||||
const blocks = editor.document;
|
||||
onChange(JSON.stringify(blocks));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="w-full p-4">
|
||||
<BlockNoteView
|
||||
editor={editor}
|
||||
theme={theme === "dark" ? "dark" : "light"}
|
||||
editable
|
||||
onChange={handleChange}
|
||||
editable={!readOnly}
|
||||
onChange={readOnly ? undefined : handleChange}
|
||||
data-theming-css
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "../lib/supabase";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
import { Database } from "@xtablo/shared/types/database.types";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { supabase } from "../lib/supabase";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
|
||||
type Note = Database["public"]["Tables"]["notes"]["Row"];
|
||||
type CreateNoteInput = Pick<Database["public"]["Tables"]["notes"]["Insert"], "title" | "content">;
|
||||
|
|
@ -127,3 +127,209 @@ export function useDeleteNote() {
|
|||
});
|
||||
return { mutate, isPending };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch sharing settings for a specific note
|
||||
*/
|
||||
export function useNoteSharing(noteId: string | undefined) {
|
||||
const user = useUser();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["note-sharing", noteId],
|
||||
queryFn: async () => {
|
||||
if (!noteId) return { isPublic: false, isSharedWithAllTablos: false };
|
||||
|
||||
// Fetch public sharing status
|
||||
const { data: sharedNote, error: sharedError } = await supabase
|
||||
.from("shared_notes")
|
||||
.select("is_public")
|
||||
.eq("note_id", noteId)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (sharedError) throw sharedError;
|
||||
|
||||
// Fetch tablo sharing status (tablo_id IS NULL means shared with all tablos)
|
||||
const { data: noteAccess, error: accessError } = await supabase
|
||||
.from("note_access")
|
||||
.select("is_active, tablo_id")
|
||||
.eq("note_id", noteId)
|
||||
.eq("user_id", user.id)
|
||||
.is("tablo_id", null)
|
||||
.maybeSingle();
|
||||
|
||||
if (accessError) throw accessError;
|
||||
|
||||
return {
|
||||
isPublic: sharedNote?.is_public ?? false,
|
||||
isSharedWithAllTablos: noteAccess?.is_active ?? false,
|
||||
};
|
||||
},
|
||||
enabled: !!noteId && !!user.id,
|
||||
});
|
||||
return { isPublic: data?.isPublic, isSharedWithAllTablos: data?.isSharedWithAllTablos, isLoading };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to update sharing settings for a note
|
||||
*/
|
||||
export function useUpdateNoteSharing() {
|
||||
const user = useUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
void,
|
||||
Error,
|
||||
{ noteId: string; isPublic: boolean; isSharedWithAllTablos: boolean }
|
||||
>({
|
||||
mutationFn: async ({ noteId, isPublic, isSharedWithAllTablos }) => {
|
||||
// Upsert shared_notes for public sharing
|
||||
const { error: sharedError } = await supabase.from("shared_notes").upsert(
|
||||
{
|
||||
note_id: noteId,
|
||||
user_id: user.id,
|
||||
is_public: isPublic,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
onConflict: "note_id",
|
||||
}
|
||||
);
|
||||
|
||||
if (sharedError) throw sharedError;
|
||||
|
||||
// Handle tablo sharing
|
||||
if (isSharedWithAllTablos) {
|
||||
// First, try to update existing "all tablos" sharing record
|
||||
const { data: existingRecord, error: selectError } = await supabase
|
||||
.from("note_access")
|
||||
.select("id")
|
||||
.eq("note_id", noteId)
|
||||
.eq("user_id", user.id)
|
||||
.is("tablo_id", null)
|
||||
.maybeSingle();
|
||||
|
||||
if (selectError) throw selectError;
|
||||
|
||||
if (existingRecord) {
|
||||
// Update existing record
|
||||
const { error: updateError } = await supabase
|
||||
.from("note_access")
|
||||
.update({
|
||||
is_active: true,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", existingRecord.id);
|
||||
|
||||
if (updateError) throw updateError;
|
||||
} else {
|
||||
// Insert new record
|
||||
const { error: insertError } = await supabase.from("note_access").insert({
|
||||
note_id: noteId,
|
||||
user_id: user.id,
|
||||
tablo_id: null,
|
||||
is_active: true,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (insertError) throw insertError;
|
||||
}
|
||||
} else {
|
||||
// Remove tablo sharing by deleting
|
||||
const { error: deleteError } = await supabase
|
||||
.from("note_access")
|
||||
.delete()
|
||||
.eq("note_id", noteId)
|
||||
.eq("user_id", user.id)
|
||||
.is("tablo_id", null);
|
||||
|
||||
if (deleteError) throw deleteError;
|
||||
}
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
// Invalidate sharing queries
|
||||
queryClient.invalidateQueries({ queryKey: ["note-sharing", variables.noteId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a public note (unauthenticated access allowed)
|
||||
*/
|
||||
export function usePublicNote(noteId: string | undefined) {
|
||||
const { data: note, isLoading } = useQuery<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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,11 @@ import { FeedbackPage } from "../pages/feedback";
|
|||
import { JoinPage } from "../pages/join";
|
||||
import { LandingPage } from "../pages/landing";
|
||||
import { LoginPage } from "../pages/login";
|
||||
import NotesPage from "../pages/notes";
|
||||
import { NotFoundPage } from "../pages/NotFoundPage";
|
||||
import NotesPage from "../pages/notes";
|
||||
import { OAuthSigninPage } from "../pages/oauth-signin";
|
||||
import { PublicBookingPage } from "../pages/PublicBookingPage";
|
||||
import { PublicNotePage } from "../pages/PublicNotePage";
|
||||
import { PlanningPage } from "../pages/planning";
|
||||
import { ResetPasswordPage } from "../pages/reset-password";
|
||||
import SettingsPage from "../pages/settings";
|
||||
|
|
@ -135,6 +136,11 @@ export const routes: RouteObject[] = [
|
|||
path: "/book/:user_info/:event_type_standard_name",
|
||||
element: <PublicBookingPage />,
|
||||
},
|
||||
// Public notes route (unauthenticated access)
|
||||
{
|
||||
path: "/notes/public/:noteId",
|
||||
element: <PublicNotePage />,
|
||||
},
|
||||
// Authentication pages (redirected to "/" if user is authenticated)
|
||||
{
|
||||
path: "/",
|
||||
|
|
|
|||
|
|
@ -35,5 +35,19 @@
|
|||
"weeksAgo_other": "{{count}} weeks ago",
|
||||
"monthsAgo": "{{count}} months ago",
|
||||
"monthsAgo_one": "{{count}} month ago",
|
||||
"monthsAgo_other": "{{count}} months ago"
|
||||
"monthsAgo_other": "{{count}} months ago",
|
||||
"share": "Share",
|
||||
"shareNote": "Share Note",
|
||||
"shareNoteDescription": "Control who can access this note",
|
||||
"publicAccess": "Public Access",
|
||||
"publicAccessDescription": "Anyone with the link can view this note",
|
||||
"shareWithAllTablos": "Share with All Tablos",
|
||||
"shareWithAllTablosDescription": "Members of all your tablos can view this note",
|
||||
"close": "Close",
|
||||
"saveNoteBeforeSharing": "Please save the note before sharing",
|
||||
"sharingSettingsUpdated": "Sharing settings updated",
|
||||
"failedToUpdateSharing": "Failed to update sharing",
|
||||
"publicLinkCopied": "Public link copied to clipboard",
|
||||
"copyLink": "Copy link",
|
||||
"openLink": "Open link"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,5 +35,19 @@
|
|||
"weeksAgo_other": "Il y a {{count}} semaines",
|
||||
"monthsAgo": "Il y a {{count}} mois",
|
||||
"monthsAgo_one": "Il y a {{count}} mois",
|
||||
"monthsAgo_other": "Il y a {{count}} mois"
|
||||
"monthsAgo_other": "Il y a {{count}} mois",
|
||||
"share": "Partager",
|
||||
"shareNote": "Partager la note",
|
||||
"shareNoteDescription": "Contrôlez qui peut accéder à cette note",
|
||||
"publicAccess": "Accès public",
|
||||
"publicAccessDescription": "Toute personne avec le lien peut voir cette note",
|
||||
"shareWithAllTablos": "Partager avec tous les tablos",
|
||||
"shareWithAllTablosDescription": "Les membres de tous vos tablos peuvent voir cette note",
|
||||
"close": "Fermer",
|
||||
"saveNoteBeforeSharing": "Veuillez enregistrer la note avant de la partager",
|
||||
"sharingSettingsUpdated": "Paramètres de partage mis à jour",
|
||||
"failedToUpdateSharing": "Échec de la mise à jour du partage",
|
||||
"publicLinkCopied": "Lien public copié dans le presse-papiers",
|
||||
"copyLink": "Copier le lien",
|
||||
"openLink": "Ouvrir le lien"
|
||||
}
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,24 +1,45 @@
|
|||
import { toast } from "@xtablo/shared";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { Card, CardContent, CardHeader } from "@xtablo/ui/components/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@xtablo/ui/components/dialog";
|
||||
import { Input } from "@xtablo/ui/components/input";
|
||||
import { Label } from "@xtablo/ui/components/label";
|
||||
import { Switch } from "@xtablo/ui/components/switch";
|
||||
import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
ExternalLinkIcon,
|
||||
FileTextIcon,
|
||||
PlusIcon,
|
||||
SaveIcon,
|
||||
SearchXIcon,
|
||||
Share2Icon,
|
||||
StickyNoteIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NotesEditor } from "../components/NotesEditor";
|
||||
import { useCreateNote, useDeleteNote, useNote, useNotes, useUpdateNote } from "../hooks/notes";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { NotesEditor } from "../components/NotesEditor";
|
||||
import {
|
||||
useCreateNote,
|
||||
useDeleteNote,
|
||||
useNote,
|
||||
useNoteSharing,
|
||||
useNotes,
|
||||
useUpdateNote,
|
||||
useUpdateNoteSharing,
|
||||
} from "../hooks/notes";
|
||||
|
||||
const useNoteId = () => {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -47,6 +68,7 @@ export default function NotesPage({ mode }: { mode: "create" | "edit" }) {
|
|||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [isSidebarVisible, setIsSidebarVisible] = useState(true);
|
||||
const [editorKey, setEditorKey] = useState(0);
|
||||
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
|
||||
|
||||
const { notes, isLoading } = useNotes();
|
||||
const { note, isLoading: isNoteLoading } = useNote(noteId);
|
||||
|
|
@ -54,6 +76,10 @@ export default function NotesPage({ mode }: { mode: "create" | "edit" }) {
|
|||
const { mutate: updateNote, isPending: isUpdating } = useUpdateNote();
|
||||
const { mutate: deleteNote, isPending: isDeleting } = useDeleteNote();
|
||||
|
||||
// Sharing hooks
|
||||
const { isPublic, isSharedWithAllTablos, isLoading: isSharingLoading } = useNoteSharing(noteId);
|
||||
const { mutate: updateSharing, isPending: isSharingUpdating } = useUpdateNoteSharing();
|
||||
|
||||
useEffect(() => {
|
||||
if (noteId) {
|
||||
const note = notes?.find((note) => note.id === noteId);
|
||||
|
|
@ -144,6 +170,52 @@ export default function NotesPage({ mode }: { mode: "create" | "edit" }) {
|
|||
resetEditor();
|
||||
};
|
||||
|
||||
const handleSaveSharing = (isPublic: boolean, isSharedWithAllTablos: boolean) => {
|
||||
if (!noteId) {
|
||||
toast.add({
|
||||
title: t("common:error", "Error"),
|
||||
description: t("saveNoteBeforeSharing"),
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateSharing(
|
||||
{
|
||||
noteId,
|
||||
isPublic,
|
||||
isSharedWithAllTablos,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.add({
|
||||
title: t("common:success", "Success"),
|
||||
description: t("sharingSettingsUpdated"),
|
||||
type: "success",
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.add({
|
||||
title: t("common:error", "Error"),
|
||||
description: `${t("failedToUpdateSharing")}: ${error.message}`,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleCopyPublicLink = () => {
|
||||
if (!noteId) return;
|
||||
const publicUrl = `${window.location.origin}/notes/public/${noteId}`;
|
||||
navigator.clipboard.writeText(publicUrl);
|
||||
toast.add({
|
||||
title: t("common:success", "Success"),
|
||||
description: t("publicLinkCopied"),
|
||||
type: "success",
|
||||
});
|
||||
};
|
||||
|
||||
const renderNote = () => {
|
||||
if (mode === "edit" && noteId) {
|
||||
if (isNoteLoading) {
|
||||
|
|
@ -213,7 +285,7 @@ export default function NotesPage({ mode }: { mode: "create" | "edit" }) {
|
|||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden p-0">
|
||||
<CardContent className="flex-1 overflow-y-auto p-0">
|
||||
<NotesEditor
|
||||
key={editorKey}
|
||||
initialContent={note?.content ?? content}
|
||||
|
|
@ -235,10 +307,110 @@ export default function NotesPage({ mode }: { mode: "create" | "edit" }) {
|
|||
<TypographyMuted>{t("description")}</TypographyMuted>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleSave} disabled={!hasUnsavedChanges || isCreating || isUpdating}>
|
||||
<SaveIcon className="mr-2 size-4" />
|
||||
{isCreating ? t("saving") : t("saveNote")}
|
||||
</Button>
|
||||
<div 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 */}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -256,6 +256,65 @@ export type Database = {
|
|||
}
|
||||
Relationships: []
|
||||
}
|
||||
note_access: {
|
||||
Row: {
|
||||
created_at: string | null
|
||||
id: number
|
||||
is_active: boolean | null
|
||||
note_id: string
|
||||
tablo_id: string | null
|
||||
updated_at: string | null
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string | null
|
||||
id?: number
|
||||
is_active?: boolean | null
|
||||
note_id: string
|
||||
tablo_id?: string | null
|
||||
updated_at?: string | null
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string | null
|
||||
id?: number
|
||||
is_active?: boolean | null
|
||||
note_id?: string
|
||||
tablo_id?: string | null
|
||||
updated_at?: string | null
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "fk_note_access_note_id"
|
||||
columns: ["note_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "notes"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "fk_note_access_tablo_id"
|
||||
columns: ["tablo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "events_and_tablos"
|
||||
referencedColumns: ["tablo_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "fk_note_access_tablo_id"
|
||||
columns: ["tablo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "tablos"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "fk_note_access_tablo_id"
|
||||
columns: ["tablo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_tablos"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
notes: {
|
||||
Row: {
|
||||
content: string | null
|
||||
|
|
@ -319,6 +378,38 @@ export type Database = {
|
|||
}
|
||||
Relationships: []
|
||||
}
|
||||
shared_notes: {
|
||||
Row: {
|
||||
created_at: string | null
|
||||
is_public: boolean | null
|
||||
note_id: string
|
||||
updated_at: string | null
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string | null
|
||||
is_public?: boolean | null
|
||||
note_id: string
|
||||
updated_at?: string | null
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string | null
|
||||
is_public?: boolean | null
|
||||
note_id?: string
|
||||
updated_at?: string | null
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "fk_shared_notes_note_id"
|
||||
columns: ["note_id"]
|
||||
isOneToOne: true
|
||||
referencedRelation: "notes"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
tablo_access: {
|
||||
Row: {
|
||||
created_at: string | null
|
||||
|
|
|
|||
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';
|
||||
|
||||
|
|
@ -256,6 +256,65 @@ export type Database = {
|
|||
}
|
||||
Relationships: []
|
||||
}
|
||||
note_access: {
|
||||
Row: {
|
||||
created_at: string | null
|
||||
id: number
|
||||
is_active: boolean | null
|
||||
note_id: string
|
||||
tablo_id: string | null
|
||||
updated_at: string | null
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string | null
|
||||
id?: number
|
||||
is_active?: boolean | null
|
||||
note_id: string
|
||||
tablo_id?: string | null
|
||||
updated_at?: string | null
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string | null
|
||||
id?: number
|
||||
is_active?: boolean | null
|
||||
note_id?: string
|
||||
tablo_id?: string | null
|
||||
updated_at?: string | null
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "fk_note_access_note_id"
|
||||
columns: ["note_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "notes"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "fk_note_access_tablo_id"
|
||||
columns: ["tablo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "events_and_tablos"
|
||||
referencedColumns: ["tablo_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "fk_note_access_tablo_id"
|
||||
columns: ["tablo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "tablos"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "fk_note_access_tablo_id"
|
||||
columns: ["tablo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_tablos"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
notes: {
|
||||
Row: {
|
||||
content: string | null
|
||||
|
|
@ -319,6 +378,38 @@ export type Database = {
|
|||
}
|
||||
Relationships: []
|
||||
}
|
||||
shared_notes: {
|
||||
Row: {
|
||||
created_at: string | null
|
||||
is_public: boolean | null
|
||||
note_id: string
|
||||
updated_at: string | null
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string | null
|
||||
is_public?: boolean | null
|
||||
note_id: string
|
||||
updated_at?: string | null
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string | null
|
||||
is_public?: boolean | null
|
||||
note_id?: string
|
||||
updated_at?: string | null
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "fk_shared_notes_note_id"
|
||||
columns: ["note_id"]
|
||||
isOneToOne: true
|
||||
referencedRelation: "notes"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
tablo_access: {
|
||||
Row: {
|
||||
created_at: string | null
|
||||
|
|
|
|||
Loading…
Reference in a new issue