diff --git a/apps/db/supabase/migrations/20250620034755_create_whiteboards.sql b/apps/db/supabase/migrations/20250620034755_create_whiteboards.sql new file mode 100644 index 0000000000..0a16db4fe9 --- /dev/null +++ b/apps/db/supabase/migrations/20250620034755_create_whiteboards.sql @@ -0,0 +1,88 @@ +create table "public"."workspace_whiteboards" ( + "id" uuid not null default gen_random_uuid(), + "title" text not null, + "description" text, + "snapshot" jsonb, + "ws_id" uuid not null, + "created_at" timestamp with time zone not null default now(), + "thumbnail_url" text, + "updated_at" timestamp with time zone not null default now(), + "creator_id" uuid not null +); + +alter table "public"."workspace_whiteboards" enable row level security; + +CREATE UNIQUE INDEX workspace_whiteboards_pkey ON public.workspace_whiteboards USING btree (id); + +alter table "public"."workspace_whiteboards" add constraint "workspace_whiteboards_pkey" PRIMARY KEY using index "workspace_whiteboards_pkey"; + +alter table "public"."workspace_whiteboards" add constraint "workspace_whiteboards_creator_id_fkey" FOREIGN KEY (creator_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."workspace_whiteboards" validate constraint "workspace_whiteboards_creator_id_fkey"; + +alter table "public"."workspace_whiteboards" add constraint "workspace_whiteboards_ws_id_fkey" FOREIGN KEY (ws_id) REFERENCES workspaces(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."workspace_whiteboards" validate constraint "workspace_whiteboards_ws_id_fkey"; + +grant delete on table "public"."workspace_whiteboards" to "anon"; + +grant insert on table "public"."workspace_whiteboards" to "anon"; + +grant references on table "public"."workspace_whiteboards" to "anon"; + +grant select on table "public"."workspace_whiteboards" to "anon"; + +grant trigger on table "public"."workspace_whiteboards" to "anon"; + +grant truncate on table "public"."workspace_whiteboards" to "anon"; + +grant update on table "public"."workspace_whiteboards" to "anon"; + +grant delete on table "public"."workspace_whiteboards" to "authenticated"; + +grant insert on table "public"."workspace_whiteboards" to "authenticated"; + +grant references on table "public"."workspace_whiteboards" to "authenticated"; + +grant select on table "public"."workspace_whiteboards" to "authenticated"; + +grant trigger on table "public"."workspace_whiteboards" to "authenticated"; + +grant truncate on table "public"."workspace_whiteboards" to "authenticated"; + +grant update on table "public"."workspace_whiteboards" to "authenticated"; + +grant delete on table "public"."workspace_whiteboards" to "service_role"; + +grant insert on table "public"."workspace_whiteboards" to "service_role"; + +grant references on table "public"."workspace_whiteboards" to "service_role"; + +grant select on table "public"."workspace_whiteboards" to "service_role"; + +grant trigger on table "public"."workspace_whiteboards" to "service_role"; + +grant truncate on table "public"."workspace_whiteboards" to "service_role"; + +grant update on table "public"."workspace_whiteboards" to "service_role"; + +-- Add an index to improve join performance +CREATE INDEX IF NOT EXISTS idx_whiteboards_ws_id ON workspace_whiteboards(ws_id); +CREATE INDEX IF NOT EXISTS idx_whiteboards_creator_id ON workspace_whiteboards(creator_id); + +CREATE INDEX idx_whiteboards_snapshot_gin ON public.workspace_whiteboards USING GIN (snapshot); + +CREATE POLICY "Workspace members can read and write whiteboards" ON public.workspace_whiteboards + FOR ALL TO authenticated + USING ( + ws_id IN ( + SELECT ws_id FROM public.workspace_members + WHERE user_id = auth.uid() + ) + ) + WITH CHECK ( + ws_id IN ( + SELECT ws_id FROM public.workspace_members + WHERE user_id = auth.uid() + ) + ); \ No newline at end of file diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 5f3be0b1e0..a783dc9b69 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -290,7 +290,10 @@ "whats_the_title": "What's the title?", "write_something": "Write something", "error_saving_content": "There was an error saving your content", - "not_completed": "Not completed" + "not_completed": "Not completed", + "whiteboards": "Whiteboards", + "whiteboards_description": "Collaborate and visualize ideas with your team", + "new_whiteboard": "New whiteboard" }, "date_helper": { "time-unit": "Time unit", diff --git a/apps/web/messages/vi.json b/apps/web/messages/vi.json index 78b6a8644d..e91398edf8 100644 --- a/apps/web/messages/vi.json +++ b/apps/web/messages/vi.json @@ -288,7 +288,10 @@ "whats_the_title": "Tiêu đề là gì?", "write_something": "Hãy viết gì đó...", "error_saving_content": "Lỗi khi lưu nội dung của bạn", - "not_completed": "Chưa hoàn thành" + "not_completed": "Chưa hoàn thành", + "whiteboards": "Bảng trắng", + "whiteboards_description": "Hợp tác và minh họa ý tưởng với đội của bạn", + "new_whiteboard": "Bảng trắng mới" }, "date_helper": { "time-unit": "Đơn vị thời gian", diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/custom-tldraw.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/custom-tldraw.tsx similarity index 66% rename from apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/custom-tldraw.tsx rename to apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/custom-tldraw.tsx index a809062afe..8839788f93 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/custom-tldraw.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/custom-tldraw.tsx @@ -1,14 +1,25 @@ 'use client'; +import Toolbar from './toolbar'; import { LoadingIndicator } from '@tuturuuu/ui/custom/loading-indicator'; import { useTheme } from 'next-themes'; import { useEffect, useState } from 'react'; -import { type Editor, Tldraw } from 'tldraw'; +import { type Editor, type TLStoreSnapshot, Tldraw } from 'tldraw'; import 'tldraw/tldraw.css'; type Theme = 'system' | 'dark' | 'light'; -export function CustomTldraw({ persistenceKey }: { persistenceKey: string }) { +interface CustomTldrawProps { + wsId: string; + boardId: string; + initialData?: TLStoreSnapshot; +} + +export function CustomTldraw({ + wsId, + boardId, + initialData, +}: CustomTldrawProps) { const { resolvedTheme } = useTheme(); const [editor, setEditor] = useState(null); @@ -28,7 +39,10 @@ export function CustomTldraw({ persistenceKey }: { persistenceKey: string }) { )} , + }} onMount={setEditor} /> diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/loading.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/loading.tsx new file mode 100644 index 0000000000..1f1d9dbe2e --- /dev/null +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/loading.tsx @@ -0,0 +1,8 @@ +export default function Loading() { + return ( +
+
+
+
+ ); +} diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/page.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/page.tsx new file mode 100644 index 0000000000..8cf001ebe6 --- /dev/null +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/page.tsx @@ -0,0 +1,37 @@ +import { CustomTldraw } from './custom-tldraw'; +import { createClient } from '@tuturuuu/supabase/next/server'; +import { notFound } from 'next/navigation'; +import { TLStoreSnapshot } from 'tldraw'; + +interface TLDrawPageProps { + params: Promise<{ wsId: string; boardId: string }>; +} + +export default async function TLDrawPage({ params }: TLDrawPageProps) { + const { wsId, boardId } = await params; + + const supabase = await createClient(); + + const { data: whiteboard } = await supabase + .from('workspace_whiteboards') + .select('*') + .eq('id', boardId) + .eq('ws_id', wsId) + .single(); + + if (!whiteboard) return notFound(); + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/toolbar.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/toolbar.tsx new file mode 100644 index 0000000000..677bddd89d --- /dev/null +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/toolbar.tsx @@ -0,0 +1,150 @@ +import { createClient } from '@tuturuuu/supabase/next/client'; +import { Button } from '@tuturuuu/ui/button'; +import { useToast } from '@tuturuuu/ui/hooks/use-toast'; +import { ArrowLeftIcon } from '@tuturuuu/ui/icons'; +import Link from 'next/link'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEditor } from 'tldraw'; + +export default function Toolbar({ + wsId, + boardId, +}: { + wsId: string; + boardId: string; +}) { + const supabase = createClient(); + + const editor = useEditor(); + const { toast } = useToast(); + + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const lastSavedSnapshotRef = useRef(null); + + // Initialize the last saved snapshot on mount + useEffect(() => { + if (editor && lastSavedSnapshotRef.current === null) { + const currentSnapshot = JSON.stringify(editor.getSnapshot()); + lastSavedSnapshotRef.current = currentSnapshot; + } + }, [editor]); + + // Listen for changes in the editor + useEffect(() => { + if (!editor) return; + + const checkForChanges = () => { + const currentSnapshot = JSON.stringify(editor.getSnapshot()); + const hasChanges = currentSnapshot !== lastSavedSnapshotRef.current; + setHasUnsavedChanges(hasChanges); + }; + + // Check for changes on various editor events + const unsubscribeHistory = editor.store.listen(() => { + checkForChanges(); + }); + + return () => { + unsubscribeHistory(); + }; + }, [editor]); + + const generateThumbnail = async () => { + const shapeIds = editor.getCurrentPageShapeIds(); + if (shapeIds.size === 0) { + throw new Error('No shapes on canvas'); + } + + const { blob } = await editor.toImage([...shapeIds], { + format: 'png', + background: true, + scale: 0.3, + padding: 16, + }); + + return blob; + }; + + const generateFileName = (name: string) => { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + const seconds = String(now.getSeconds()).padStart(2, '0'); + + return `${year}${month}${day}-${hours}-${minutes}-${seconds}-${name}`; + }; + + const save = useCallback(async () => { + try { + const thumbnailBlob = await generateThumbnail(); + if (!thumbnailBlob) { + throw new Error('Failed to generate thumbnail'); + } + + const thumbnailFileName = generateFileName(`${boardId}.png`); + const thumbnailFile = new File([thumbnailBlob], thumbnailFileName, { + type: 'image/png', + }); + + const { error: thumbnailError } = await supabase.storage + .from('workspaces') + .upload( + `${wsId}/whiteboards/${boardId}/${thumbnailFileName}`, + thumbnailFile + ); + + if (thumbnailError) throw thumbnailError; + + const { data: thumbnailUrl } = supabase.storage + .from('workspaces') + .getPublicUrl(`${wsId}/whiteboards/${boardId}/${thumbnailFileName}`); + + const snapshot = editor.getSnapshot(); + + const { error: updateError } = await supabase + .from('workspace_whiteboards') + .update({ + snapshot: JSON.stringify(snapshot), + thumbnail_url: thumbnailUrl.publicUrl, + updated_at: new Date().toISOString(), + }) + .eq('id', boardId); + + if (updateError) throw updateError; + + // Update the last saved snapshot reference + lastSavedSnapshotRef.current = JSON.stringify(snapshot); + setHasUnsavedChanges(false); + + toast({ + title: 'Saved successfully!', + description: 'Your whiteboard has been saved.', + variant: 'default', + }); + } catch (error: any) { + console.error(error); + toast({ + title: 'Error saving', + description: 'Failed to save whiteboard', + variant: 'destructive', + }); + } + }, [editor, boardId, supabase, toast, wsId]); + + return ( +
+ + + + +
+ ); +} diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/client.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/client.tsx new file mode 100644 index 0000000000..1aa222e02f --- /dev/null +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/client.tsx @@ -0,0 +1,421 @@ +'use client'; + +import EditWhiteboardDialog from './editWhiteboardDialog'; +import { createClient } from '@tuturuuu/supabase/next/client'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@tuturuuu/ui/alert-dialog'; +import { Button } from '@tuturuuu/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@tuturuuu/ui/dropdown-menu'; +import { + Calendar, + Grid3X3, + IconChevronUpDown, + IconEdit, + IconTrash, + IconUsers, + ImageIcon, + LetterText, + List, + MoreHorizontal, + Pen, + Search, +} from '@tuturuuu/ui/icons'; +import { Input } from '@tuturuuu/ui/input'; +import { toast } from '@tuturuuu/ui/sonner'; +import { Toggle } from '@tuturuuu/ui/toggle'; +import Image from 'next/image'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +type SortOption = 'alphabetical' | 'dateCreated' | 'lastModified'; +type ViewMode = 'grid' | 'list'; + +export interface Whiteboard { + id: string; + title: string; + description?: string; + dateCreated: Date; + lastModified: Date; + thumbnail_url?: string; + creatorName: string; +} + +interface WhiteboardsListProps { + wsId: string; + whiteboards: Whiteboard[]; +} + +export default function WhiteboardsList({ + wsId, + whiteboards, +}: WhiteboardsListProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [sortBy, setSortBy] = useState('lastModified'); + const [viewMode, setViewMode] = useState('grid'); + + const getSortLabel = (option: SortOption) => { + switch (option) { + case 'alphabetical': + return 'Alphabetical'; + case 'dateCreated': + return 'Date Created'; + case 'lastModified': + return 'Last Modified'; + } + }; + + const getSortIcon = (option: SortOption) => { + switch (option) { + case 'alphabetical': + return ; + case 'dateCreated': + return ; + case 'lastModified': + return ; + } + }; + + const sortedWhiteboards = [...whiteboards] + .filter((whiteboard) => + whiteboard.title.toLowerCase().includes(searchQuery.toLowerCase()) + ) + .sort((a, b) => { + switch (sortBy) { + case 'alphabetical': + return a.title.localeCompare(b.title); + case 'dateCreated': + return b.dateCreated.getTime() - a.dateCreated.getTime(); + case 'lastModified': + return b.lastModified.getTime() - a.lastModified.getTime(); + default: + return 0; + } + }); + + const formatDate = (date: Date) => { + const now = new Date(); + const diffInDays = Math.floor( + (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24) + ); + + if (diffInDays === 0) return 'Today'; + if (diffInDays === 1) return 'Yesterday'; + if (diffInDays < 7) return `${diffInDays} days ago`; + return date.toLocaleDateString(); + }; + + return ( + <> + {/* Controls */} +
+
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="w-64 pl-10" + /> +
+ + {/* Sort Dropdown */} + + + + + + setSortBy('alphabetical')} + className="gap-2" + > + + Alphabetical + + setSortBy('dateCreated')} + className="gap-2" + > + + Date Created + + setSortBy('lastModified')} + className="gap-2" + > + + Last Modified + + + +
+ + {/* View Mode Toggle */} +
+ setViewMode('grid')} + className="rounded-r-none" + size="sm" + > + + + setViewMode('list')} + className="rounded-l-none border-l" + size="sm" + > + + +
+
+ + {/* Whiteboard Content */} + {sortedWhiteboards.length === 0 ? ( +
+
+ +
+

No whiteboards found

+

+ {searchQuery + ? `No whiteboards match "${searchQuery}"` + : 'Get started by creating your first whiteboard'} +

+
+ ) : viewMode === 'grid' ? ( +
+ {sortedWhiteboards.map((whiteboard) => ( + + + +
+ + {whiteboard.title} + + + +
+
+ + {/* Thumbnail */} +
+ {whiteboard.thumbnail_url ? ( + {whiteboard.title} + ) : ( +
+
+ No preview +
+
+ )} +
+ + {whiteboard.description && ( +

+ {whiteboard.description} +

+ )} + +
+
+ + {whiteboard.creatorName} +
+
+ + {formatDate(whiteboard.lastModified)} +
+
+
+
+ + ))} +
+ ) : ( +
+ {sortedWhiteboards.map((whiteboard) => ( + + + + {/* Thumbnail */} +
+ {whiteboard.thumbnail_url ? ( + {whiteboard.title} + ) : ( +
+
+ +
+
+ )} +
+ + {/* Content */} +
+

+ {whiteboard.title} +

+ {whiteboard.description && ( +

+ {whiteboard.description} +

+ )} +
+ + {/* Metadata */} +
+
+ + {whiteboard.creatorName} +
+
+ + {formatDate(whiteboard.lastModified)} +
+
+ + {formatDate(whiteboard.dateCreated)} +
+
+ + +
+
+ + ))} +
+ )} + + ); +} + +function CardAction({ whiteboard }: { whiteboard: Whiteboard }) { + const supabase = createClient(); + const router = useRouter(); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + const handleDelete = async (whiteboard: Whiteboard) => { + try { + const { error } = await supabase + .from('workspace_whiteboards') + .delete() + .eq('id', whiteboard.id); + + if (error) { + throw new Error('Failed to delete whiteboard'); + } + + toast.success('Whiteboard deleted successfully!'); + router.refresh(); + } catch (error) { + console.error('Error deleting whiteboard:', error); + toast.error('Failed to delete whiteboard. Please try again.'); + } + }; + + return ( + <> + + + + + + e.preventDefault()} + > + + Edit + + } + /> + e.preventDefault()} + > + + Share + + e.preventDefault()} + onClick={() => setShowDeleteDialog(true)} + > + + Delete + + + + + + + + Delete Whiteboard + + Are you sure you want to delete {`"${whiteboard.title}"`}? This + action cannot be undone. + + + + Cancel + handleDelete(whiteboard)}> + Delete + + + + + + ); +} diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/createWhiteboardDialog.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/createWhiteboardDialog.tsx new file mode 100644 index 0000000000..bdd8435bd2 --- /dev/null +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/createWhiteboardDialog.tsx @@ -0,0 +1,82 @@ +'use client'; + +import WhiteboardForm, { type WhiteboardFormValues } from './whiteboardForm'; +import { createClient } from '@tuturuuu/supabase/next/client'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@tuturuuu/ui/dialog'; +import { toast } from '@tuturuuu/ui/sonner'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +interface CreateWhiteboardDialogProps { + wsId: string; + trigger?: React.ReactNode; +} + +export default function CreateWhiteboardDialog({ + wsId, + trigger, +}: CreateWhiteboardDialogProps) { + const [open, setOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const router = useRouter(); + + const handleSubmit = async (values: WhiteboardFormValues) => { + setIsSubmitting(true); + + try { + const supabase = createClient(); + + // Get the current user + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError || !user) { + toast.error('You must be logged in to create a whiteboard'); + return; + } + + // Create the whiteboard + const { error } = await supabase.from('workspace_whiteboards').insert({ + title: values.title, + description: values.description || null, + ws_id: wsId, + creator_id: user.id, + }); + + if (error) { + console.error('Error creating whiteboard:', error); + toast.error('Failed to create whiteboard. Please try again.'); + return; + } + + toast.success('Whiteboard created successfully!'); + setOpen(false); + router.refresh(); + } catch (error) { + console.error('Unexpected error:', error); + toast.error('An unexpected error occurred. Please try again.'); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + {trigger} + + + Create New Whiteboard + + + + + ); +} diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/editWhiteboardDialog.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/editWhiteboardDialog.tsx new file mode 100644 index 0000000000..27be3e04b2 --- /dev/null +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/editWhiteboardDialog.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { type Whiteboard } from './client'; +import WhiteboardForm, { type WhiteboardFormValues } from './whiteboardForm'; +import { createClient } from '@tuturuuu/supabase/next/client'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@tuturuuu/ui/dialog'; +import { toast } from '@tuturuuu/ui/sonner'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +interface EditWhiteboardDialogProps { + whiteboard: Whiteboard; + trigger: React.ReactNode; +} + +export default function EditWhiteboardDialog({ + whiteboard, + trigger, +}: EditWhiteboardDialogProps) { + const [open, setOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const router = useRouter(); + + const handleSubmit = async (values: WhiteboardFormValues) => { + setIsSubmitting(true); + + try { + const supabase = createClient(); + + // Get the current user + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError || !user) { + toast.error('You must be logged in to edit a whiteboard'); + return; + } + + // Update the whiteboard + const { error } = await supabase + .from('workspace_whiteboards') + .update({ + title: values.title, + description: values.description || null, + updated_at: new Date().toISOString(), + }) + .eq('id', whiteboard.id); + + if (error) { + console.error('Error updating whiteboard:', error); + toast.error('Failed to update whiteboard. Please try again.'); + return; + } + + toast.success('Whiteboard updated successfully!'); + setOpen(false); + router.refresh(); + } catch (error) { + console.error('Unexpected error:', error); + toast.error('An unexpected error occurred. Please try again.'); + } finally { + setIsSubmitting(false); + } + }; + + const defaultValues: WhiteboardFormValues = { + title: whiteboard.title, + description: whiteboard.description || '', + }; + + return ( + + {trigger} + + + Edit Whiteboard + + + + + ); +} diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/page.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/page.tsx index 917cbba0ae..334b80f9f9 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/page.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/page.tsx @@ -1,15 +1,83 @@ -import { CustomTldraw } from './custom-tldraw'; +import WhiteboardsList, { type Whiteboard } from './client'; +import CreateWhiteboardDialog from './createWhiteboardDialog'; +import { createClient } from '@tuturuuu/supabase/next/server'; +import { Button } from '@tuturuuu/ui/button'; +import { IconPlus } from '@tuturuuu/ui/icons'; +import { Separator } from '@tuturuuu/ui/separator'; +import { getTranslations } from 'next-intl/server'; +import { notFound } from 'next/navigation'; -export default async function TLDrawPage({ - params, -}: { +interface WhiteboardsPageProps { params: Promise<{ wsId: string }>; -}) { +} + +async function getWhiteboards(wsId: string): Promise { + const supabase = await createClient(); + + const { data: whiteboards, error } = await supabase + .from('workspace_whiteboards') + .select( + `*, + creator:users(display_name) + ` + ) + .eq('ws_id', wsId) + .order('updated_at', { ascending: false }); + + if (error) { + console.error('Error fetching whiteboards:', error); + throw error; + } + + return whiteboards.map((whiteboard) => ({ + id: whiteboard.id, + title: whiteboard.title, + description: whiteboard.description || undefined, + dateCreated: new Date(whiteboard.created_at), + lastModified: new Date(whiteboard.updated_at), + thumbnail_url: whiteboard.thumbnail_url || undefined, + creatorName: whiteboard.creator.display_name || 'Unknown User', + })); +} + +export default async function WhiteboardsPage({ + params, +}: WhiteboardsPageProps) { const { wsId } = await params; + const t = await getTranslations('common'); + + try { + const whiteboards = await getWhiteboards(wsId); + + return ( +
+ {/* Header */} +
+
+

+ {t('whiteboards')} +

+

+ {t('whiteboards_description')} +

+
+ + + {t('new_whiteboard')} + + } + /> +
- return ( -
- -
- ); + + +
+ ); + } catch (error) { + console.error('Failed to load whiteboards:', error); + return notFound(); + } } diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/whiteboardForm.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/whiteboardForm.tsx new file mode 100644 index 0000000000..44eca15f66 --- /dev/null +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/whiteboardForm.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { Button } from '@tuturuuu/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@tuturuuu/ui/form'; +import { useForm } from '@tuturuuu/ui/hooks/use-form'; +import { Input } from '@tuturuuu/ui/input'; +import { zodResolver } from '@tuturuuu/ui/resolvers'; +import { Textarea } from '@tuturuuu/ui/textarea'; +import * as z from 'zod'; + +const formSchema = z.object({ + title: z.string().min(1, { + message: 'Title is required', + }), + description: z.string().optional(), +}); + +export type WhiteboardFormValues = z.infer; + +interface WhiteboardFormProps { + defaultValues?: WhiteboardFormValues; + whiteboardId?: string; + // eslint-disable-next-line no-unused-vars + onSubmit: (values: WhiteboardFormValues) => void; + isSubmitting: boolean; +} + +export default function WhiteboardForm({ + defaultValues, + whiteboardId, + onSubmit, + isSubmitting, +}: WhiteboardFormProps) { + const isEditing = !!whiteboardId; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + title: '', + description: '', + ...defaultValues, + }, + }); + + return ( +
+ + + + + {isEditing ? 'Edit Whiteboard' : 'Create Whiteboard'} + + + + ( + + Title + + + + + + )} + /> + + ( + + Description (Optional) + +