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