From 56425e759310c65dd91cef831825163347220834 Mon Sep 17 00:00:00 2001 From: Nguyen Khang Date: Thu, 19 Jun 2025 23:16:41 +0700 Subject: [PATCH 01/10] feat: add new whiteboard page and maintain localstorage snapsnot management by its boardId --- .../{ => [boardId]}/custom-tldraw.tsx | 19 ++++++++++++++++--- .../[wsId]/whiteboards/[boardId]/loading.tsx | 8 ++++++++ .../[wsId]/whiteboards/[boardId]/page.tsx | 15 +++++++++++++++ .../(dashboard)/[wsId]/whiteboards/page.tsx | 16 ++-------------- 4 files changed, 41 insertions(+), 17 deletions(-) rename apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/{ => [boardId]}/custom-tldraw.tsx (70%) create mode 100644 apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/loading.tsx create mode 100644 apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/page.tsx 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 70% 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..1c799db5b5 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 @@ -3,15 +3,28 @@ 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 }) { +export function CustomTldraw({ + initialData, + persistenceKey, +}: { + initialData?: TLStoreSnapshot; + persistenceKey: string; +}) { const { resolvedTheme } = useTheme(); const [editor, setEditor] = useState(null); + const handleMount = (editor: Editor) => { + setEditor(editor); + if (initialData) { + editor.loadSnapshot(initialData); + } + }; + useEffect(() => { if (editor) editor.user?.updateUserPreferences({ @@ -29,7 +42,7 @@ export function CustomTldraw({ persistenceKey }: { persistenceKey: string }) { ); 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..1f0b965257 --- /dev/null +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/page.tsx @@ -0,0 +1,15 @@ +import { CustomTldraw } from './custom-tldraw'; + +export default async function TLDrawPage({ + params, +}: { + params: Promise<{ boardId: string }>; +}) { + const { boardId } = await params; + + return ( +
+ +
+ ); +} 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..17d48142ca 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,3 @@ -import { CustomTldraw } from './custom-tldraw'; - -export default async function TLDrawPage({ - params, -}: { - params: Promise<{ wsId: string }>; -}) { - const { wsId } = await params; - - return ( -
- -
- ); +export default async function TLDrawPage() { + return
Page
; } From 5a148994deaf1590aee79497c60f767b884a21e0 Mon Sep 17 00:00:00 2001 From: Nguyen Khang Date: Fri, 20 Jun 2025 00:48:43 +0700 Subject: [PATCH 02/10] feat: implement new whiteboard prototype page with search and sorting functionality --- apps/web/messages/en.json | 5 +- apps/web/messages/vi.json | 5 +- .../(dashboard)/[wsId]/whiteboards/page.tsx | 379 +++++++++++++++++- 3 files changed, 385 insertions(+), 4 deletions(-) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 4cf7da3a06..d9e609afed 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 c67e4a586a..f2bbfdb49e 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/page.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/page.tsx index 17d48142ca..bc8680d205 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/page.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/page.tsx @@ -1,3 +1,378 @@ -export default async function TLDrawPage() { - return
Page
; +'use client'; + +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, + Eye, + Grid3X3, + IconChevronUpDown, + IconEdit, + IconPlus, + IconTrash, + IconUsers, + LetterText, + List, + MoreHorizontal, + Search, +} from '@tuturuuu/ui/icons'; +import { Input } from '@tuturuuu/ui/input'; +import { Separator } from '@tuturuuu/ui/separator'; +import { Toggle } from '@tuturuuu/ui/toggle'; +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; + +type SortOption = 'alphabetical' | 'dateCreated' | 'lastViewed'; +type ViewMode = 'grid' | 'list'; + +interface Whiteboard { + id: string; + title: string; + description?: string; + dateCreated: Date; + lastViewed: Date; + collaborators: number; + thumbnail?: string; +} + +// Mock data for demonstration +const mockWhiteboards: Whiteboard[] = [ + { + id: '1', + title: 'Product Roadmap 2024', + description: 'Strategic planning for the upcoming year', + dateCreated: new Date('2024-01-15'), + lastViewed: new Date('2024-01-20'), + collaborators: 5, + }, + { + id: '2', + title: 'User Journey Mapping', + description: 'Understanding customer touchpoints', + dateCreated: new Date('2024-01-10'), + lastViewed: new Date('2024-01-19'), + collaborators: 3, + }, + { + id: '3', + title: 'Architecture Overview', + description: 'System design and infrastructure planning', + dateCreated: new Date('2024-01-12'), + lastViewed: new Date('2024-01-21'), + collaborators: 8, + }, + { + id: '4', + title: 'Marketing Campaign Ideas', + dateCreated: new Date('2024-01-18'), + lastViewed: new Date('2024-01-18'), + collaborators: 2, + }, +]; + +export default function WhiteboardsPage() { + const [searchQuery, setSearchQuery] = useState(''); + const [sortBy, setSortBy] = useState('lastViewed'); + const [viewMode, setViewMode] = useState('grid'); + const t = useTranslations('common'); + + const getSortLabel = (option: SortOption) => { + switch (option) { + case 'alphabetical': + return 'Alphabetical'; + case 'dateCreated': + return 'Date Created'; + case 'lastViewed': + return 'Last Viewed'; + } + }; + + const getSortIcon = (option: SortOption) => { + switch (option) { + case 'alphabetical': + return ; + case 'dateCreated': + return ; + case 'lastViewed': + return ; + } + }; + + const sortedWhiteboards = [...mockWhiteboards] + .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 'lastViewed': + return b.lastViewed.getTime() - a.lastViewed.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 ( +
+ {/* Header */} +
+
+

+ {t('whiteboards')} +

+

+ {t('whiteboards_description')} +

+
+ +
+ + + + {/* 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('lastViewed')} + className="gap-2" + > + + Last Viewed + + + +
+ + {/* 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'} +

+ {!searchQuery && ( + + )} +
+ ) : viewMode === 'grid' ? ( +
+ {sortedWhiteboards.map((whiteboard) => ( + + +
+ + {whiteboard.title} + + + + + + + + + Edit + + + + Share + + + + Delete + + + +
+
+ + {/* Thumbnail placeholder */} +
+
Preview
+
+ + {whiteboard.description && ( +

+ {whiteboard.description} +

+ )} + +
+
+ + {whiteboard.collaborators} +
+
+ + {formatDate(whiteboard.lastViewed)} +
+
+
+
+ ))} +
+ ) : ( +
+ {sortedWhiteboards.map((whiteboard) => ( + + + {/* Thumbnail */} +
+
Prev
+
+ + {/* Content */} +
+

{whiteboard.title}

+ {whiteboard.description && ( +

+ {whiteboard.description} +

+ )} +
+ + {/* Metadata */} +
+
+ + {whiteboard.collaborators} +
+
+ + {formatDate(whiteboard.lastViewed)} +
+
+ + {formatDate(whiteboard.dateCreated)} +
+
+ + {/* Actions */} + + + + + + + + Rename + + + + Share + + + +
+
+ ))} +
+ )} +
+ ); } From 7335216b14fc16e857a4a15ed19e914ecb6d0e41 Mon Sep 17 00:00:00 2001 From: Nguyen Khang Date: Fri, 20 Jun 2025 12:00:15 +0700 Subject: [PATCH 03/10] feat: add whiteboards table and related RLS policies, implement snapshot saving and thumbnail generation in the whiteboard component --- .../20250620034755_create_whiteboards.sql | 69 ++++++ .../20250620034855_add_whiteboards_rls.sql | 20 ++ .../whiteboards/[boardId]/custom-tldraw.tsx | 108 +++++++-- .../[wsId]/whiteboards/[boardId]/page.tsx | 22 +- packages/types/src/supabase.ts | 221 +++++++++++------- 5 files changed, 348 insertions(+), 92 deletions(-) create mode 100644 apps/db/supabase/migrations/20250620034755_create_whiteboards.sql create mode 100644 apps/db/supabase/migrations/20250620034855_add_whiteboards_rls.sql 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..7611813824 --- /dev/null +++ b/apps/db/supabase/migrations/20250620034755_create_whiteboards.sql @@ -0,0 +1,69 @@ +create table "public"."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"."whiteboards" enable row level security; + +CREATE UNIQUE INDEX whiteboards_pkey ON public.whiteboards USING btree (id); + +alter table "public"."whiteboards" add constraint "whiteboards_pkey" PRIMARY KEY using index "whiteboards_pkey"; + +alter table "public"."whiteboards" add constraint "whiteboards_creator_id_fkey" FOREIGN KEY (creator_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."whiteboards" validate constraint "whiteboards_creator_id_fkey"; + +alter table "public"."whiteboards" add constraint "whiteboards_ws_id_fkey" FOREIGN KEY (ws_id) REFERENCES workspaces(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."whiteboards" validate constraint "whiteboards_ws_id_fkey"; + +grant delete on table "public"."whiteboards" to "anon"; + +grant insert on table "public"."whiteboards" to "anon"; + +grant references on table "public"."whiteboards" to "anon"; + +grant select on table "public"."whiteboards" to "anon"; + +grant trigger on table "public"."whiteboards" to "anon"; + +grant truncate on table "public"."whiteboards" to "anon"; + +grant update on table "public"."whiteboards" to "anon"; + +grant delete on table "public"."whiteboards" to "authenticated"; + +grant insert on table "public"."whiteboards" to "authenticated"; + +grant references on table "public"."whiteboards" to "authenticated"; + +grant select on table "public"."whiteboards" to "authenticated"; + +grant trigger on table "public"."whiteboards" to "authenticated"; + +grant truncate on table "public"."whiteboards" to "authenticated"; + +grant update on table "public"."whiteboards" to "authenticated"; + +grant delete on table "public"."whiteboards" to "service_role"; + +grant insert on table "public"."whiteboards" to "service_role"; + +grant references on table "public"."whiteboards" to "service_role"; + +grant select on table "public"."whiteboards" to "service_role"; + +grant trigger on table "public"."whiteboards" to "service_role"; + +grant truncate on table "public"."whiteboards" to "service_role"; + +grant update on table "public"."whiteboards" to "service_role"; + + diff --git a/apps/db/supabase/migrations/20250620034855_add_whiteboards_rls.sql b/apps/db/supabase/migrations/20250620034855_add_whiteboards_rls.sql new file mode 100644 index 0000000000..97c43b53fb --- /dev/null +++ b/apps/db/supabase/migrations/20250620034855_add_whiteboards_rls.sql @@ -0,0 +1,20 @@ +-- Add an index to improve join performance +CREATE INDEX IF NOT EXISTS idx_whiteboards_ws_id ON whiteboards(ws_id); +CREATE INDEX IF NOT EXISTS idx_whiteboards_creator_id ON whiteboards(creator_id); + +CREATE INDEX idx_whiteboards_snapshot_gin ON public.whiteboards USING GIN (snapshot); + +CREATE POLICY "Workspace members can read and write whiteboards" ON public.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/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/custom-tldraw.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/custom-tldraw.tsx index 1c799db5b5..2fd5ed4ac0 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/custom-tldraw.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/custom-tldraw.tsx @@ -1,30 +1,28 @@ 'use client'; +import { createClient } from '@tuturuuu/supabase/next/client'; +import { Button } from '@tuturuuu/ui/button'; import { LoadingIndicator } from '@tuturuuu/ui/custom/loading-indicator'; +import { useToast } from '@tuturuuu/ui/hooks/use-toast'; +import { ArrowLeftIcon } from '@tuturuuu/ui/icons'; import { useTheme } from 'next-themes'; -import { useEffect, useState } from 'react'; -import { type Editor, type TLStoreSnapshot, Tldraw } from 'tldraw'; +import Link from 'next/link'; +import { useCallback, useEffect, useState } from 'react'; +import { type Editor, type TLStoreSnapshot, Tldraw, useEditor } from 'tldraw'; import 'tldraw/tldraw.css'; type Theme = 'system' | 'dark' | 'light'; export function CustomTldraw({ initialData, - persistenceKey, + boardId, }: { initialData?: TLStoreSnapshot; - persistenceKey: string; + boardId: string; }) { const { resolvedTheme } = useTheme(); const [editor, setEditor] = useState(null); - const handleMount = (editor: Editor) => { - setEditor(editor); - if (initialData) { - editor.loadSnapshot(initialData); - } - }; - useEffect(() => { if (editor) editor.user?.updateUserPreferences({ @@ -41,9 +39,93 @@ export function CustomTldraw({ )} , + }} + onMount={setEditor} />
); } + +function SnapshotToolbar({ boardId }: { boardId: string }) { + const editor = useEditor(); + const { toast } = useToast(); + const supabase = createClient(); + + const generateThumbnail = useCallback(async (): Promise => { + 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; + }, [editor]); + + const save = useCallback(async () => { + try { + const snapshot = editor.getSnapshot(); + + const thumbnailBlob = await generateThumbnail(); + + const thumbnailFile = new File([thumbnailBlob], `${boardId}.png`, { + type: 'image/png', + }); + + const { data: thumbnailData } = await supabase.storage + .from('whiteboards-thumbnails') + .upload(thumbnailFile.name, thumbnailFile); + + await supabase + .from('whiteboards') + .update({ + snapshot: JSON.stringify(snapshot), + thumbnail_url: thumbnailData?.path, + updated_at: new Date().toISOString(), + }) + .eq('id', boardId); + + toast({ + title: 'Saved successfully!', + description: 'Your whiteboard and thumbnail have been saved.', + variant: 'default', + }); + } catch (error: any) { + console.error(error); + toast({ + title: 'Error saving', + description: 'Failed to save whiteboard', + variant: 'destructive', + }); + } + }, [editor, boardId, generateThumbnail]); + + useEffect(() => { + const snapshot = localStorage.getItem(boardId); + if (!snapshot) return; + + editor.loadSnapshot(JSON.parse(snapshot)); + }, [editor]); + + 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 index 1f0b965257..ca82c38e2c 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/page.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/page.tsx @@ -1,4 +1,7 @@ import { CustomTldraw } from './custom-tldraw'; +import { createClient } from '@tuturuuu/supabase/next/server'; +import { notFound } from 'next/navigation'; +import { TLStoreSnapshot } from 'tldraw'; export default async function TLDrawPage({ params, @@ -7,9 +10,26 @@ export default async function TLDrawPage({ }) { const { boardId } = await params; + const supabase = await createClient(); + + const { data: whiteboard } = await supabase + .from('whiteboards') + .select('*') + .eq('id', boardId) + .single(); + + if (!whiteboard) return notFound(); + return (
- +
); } diff --git a/packages/types/src/supabase.ts b/packages/types/src/supabase.ts index 092084dfe6..d678207888 100644 --- a/packages/types/src/supabase.ts +++ b/packages/types/src/supabase.ts @@ -4672,6 +4672,71 @@ export type Database = { }; Relationships: []; }; + whiteboards: { + Row: { + created_at: string; + creator_id: string; + description: string | null; + id: string; + snapshot: Json | null; + thumbnail_url: string | null; + title: string; + updated_at: string; + ws_id: string; + }; + Insert: { + created_at?: string; + creator_id: string; + description?: string | null; + id?: string; + snapshot?: Json | null; + thumbnail_url?: string | null; + title: string; + updated_at?: string; + ws_id: string; + }; + Update: { + created_at?: string; + creator_id?: string; + description?: string | null; + id?: string; + snapshot?: Json | null; + thumbnail_url?: string | null; + title?: string; + updated_at?: string; + ws_id?: string; + }; + Relationships: [ + { + foreignKeyName: 'whiteboards_creator_id_fkey'; + columns: ['creator_id']; + isOneToOne: false; + referencedRelation: 'nova_user_challenge_leaderboard'; + referencedColumns: ['user_id']; + }, + { + foreignKeyName: 'whiteboards_creator_id_fkey'; + columns: ['creator_id']; + isOneToOne: false; + referencedRelation: 'nova_user_leaderboard'; + referencedColumns: ['user_id']; + }, + { + foreignKeyName: 'whiteboards_creator_id_fkey'; + columns: ['creator_id']; + isOneToOne: false; + referencedRelation: 'users'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'whiteboards_ws_id_fkey'; + columns: ['ws_id']; + isOneToOne: false; + referencedRelation: 'workspaces'; + referencedColumns: ['id']; + }, + ]; + }; workspace_ai_models: { Row: { created_at: string; @@ -7550,7 +7615,7 @@ export type Database = { }; Functions: { calculate_productivity_score: { - Args: { category_color: string; duration_seconds: number }; + Args: { duration_seconds: number; category_color: string }; Returns: number; }; check_ws_creator: { @@ -7567,12 +7632,12 @@ export type Database = { }; count_search_users: { Args: + | { search_query: string } | { - enabled_filter?: boolean; - role_filter?: string; search_query: string; - } - | { search_query: string }; + role_filter?: string; + enabled_filter?: boolean; + }; Returns: number; }; create_ai_chat: { @@ -7582,41 +7647,41 @@ export type Database = { generate_cross_app_token: { Args: | { - p_session_data?: Json; - p_expiry_seconds?: number; - p_target_app: string; - p_origin_app: string; p_user_id: string; - } - | { + p_origin_app: string; p_target_app: string; p_expiry_seconds?: number; + } + | { p_user_id: string; p_origin_app: string; + p_target_app: string; + p_expiry_seconds?: number; + p_session_data?: Json; }; Returns: string; }; get_challenge_stats: { - Args: { user_id_param: string; challenge_id_param: string }; + Args: { challenge_id_param: string; user_id_param: string }; Returns: { - problems_attempted: number; total_score: number; + problems_attempted: number; }[]; }; get_daily_income_expense: { Args: { _ws_id: string; past_days?: number }; Returns: { - total_expense: number; day: string; total_income: number; + total_expense: number; }[]; }; get_daily_prompt_completion_tokens: { Args: { past_days?: number }; Returns: { - total_completion_tokens: number; - total_prompt_tokens: number; day: string; + total_prompt_tokens: number; + total_completion_tokens: number; }[]; }; get_finance_invoices_count: { @@ -7643,8 +7708,8 @@ export type Database = { Args: { past_hours?: number }; Returns: { hour: string; - total_completion_tokens: number; total_prompt_tokens: number; + total_completion_tokens: number; }[]; }; get_inventory_batches_count: { @@ -7657,10 +7722,10 @@ export type Database = { }; get_inventory_products: { Args: { - _has_unit?: boolean; _category_ids?: string[]; - _warehouse_ids?: string[]; _ws_id?: string; + _warehouse_ids?: string[]; + _has_unit?: boolean; }; Returns: { id: string; @@ -7694,16 +7759,16 @@ export type Database = { get_monthly_income_expense: { Args: { _ws_id: string; past_months?: number }; Returns: { - total_expense: number; month: string; total_income: number; + total_expense: number; }[]; }; get_monthly_prompt_completion_tokens: { Args: { past_months?: number }; Returns: { - total_prompt_tokens: number; month: string; + total_prompt_tokens: number; total_completion_tokens: number; }[]; }; @@ -7724,19 +7789,19 @@ export type Database = { Args: { _ws_id: string; included_tags: string[] }; Returns: { id: string; - amount: number; - ws_id: string; name: string; + ws_id: string; + amount: number; }[]; }; get_session_statistics: { Args: Record; Returns: { - latest_session_date: string; - completed_count: number; - active_count: number; - unique_users_count: number; total_count: number; + unique_users_count: number; + active_count: number; + completed_count: number; + latest_session_date: string; }[]; }; get_session_templates: { @@ -7746,40 +7811,40 @@ export type Database = { limit_count?: number; }; Returns: { + title: string; + description: string; + category_id: string; + task_id: string; + tags: string[]; category_name: string; category_color: string; task_name: string; usage_count: number; avg_duration: number; last_used: string; - category_id: string; - tags: string[]; - task_id: string; - title: string; - description: string; }[]; }; get_submission_statistics: { Args: Record; Returns: { - latest_submission_date: string; total_count: number; + latest_submission_date: string; unique_users_count: number; }[]; }; get_transaction_categories_with_amount: { Args: Record; Returns: { - amount: number; id: string; name: string; is_expense: boolean; ws_id: string; created_at: string; + amount: number; }[]; }; get_user_role: { - Args: { ws_id: string; user_id: string }; + Args: { user_id: string; ws_id: string }; Returns: string; }; get_user_session_stats: { @@ -7793,8 +7858,8 @@ export type Database = { get_user_sessions: { Args: { user_id: string }; Returns: { - created_at: string; session_id: string; + created_at: string; updated_at: string; user_agent: string; ip: string; @@ -7804,15 +7869,15 @@ export type Database = { get_user_tasks: { Args: { _board_id: string }; Returns: { + id: string; + name: string; + description: string; + priority: number; completed: boolean; start_date: string; end_date: string; list_id: string; board_id: string; - description: string; - priority: number; - id: string; - name: string; }[]; }; get_workspace_drive_size: { @@ -7828,24 +7893,24 @@ export type Database = { Returns: number; }; get_workspace_transactions_count: { - Args: { start_date?: string; ws_id: string; end_date?: string }; + Args: { ws_id: string; start_date?: string; end_date?: string }; Returns: number; }; get_workspace_user_groups: { Args: { - search_query: string; - excluded_tags: string[]; - included_tags: string[]; _ws_id: string; + included_tags: string[]; + excluded_tags: string[]; + search_query: string; }; Returns: { - tags: string[]; - ws_id: string; - notes: string; - name: string; id: string; - created_at: string; + name: string; + notes: string; + ws_id: string; + tags: string[]; tag_count: number; + created_at: string; }[]; }; get_workspace_user_groups_count: { @@ -7854,10 +7919,10 @@ export type Database = { }; get_workspace_users: { Args: { - search_query: string; - excluded_groups: string[]; - included_groups: string[]; _ws_id: string; + included_groups: string[]; + excluded_groups: string[]; + search_query: string; }; Returns: { id: string; @@ -7871,6 +7936,7 @@ export type Database = { ethnicity: string; guardian: string; address: string; + national_id: string; note: string; balance: number; ws_id: string; @@ -7879,7 +7945,6 @@ export type Database = { linked_users: Json; created_at: string; updated_at: string; - national_id: string; }[]; }; get_workspace_users_count: { @@ -7891,7 +7956,7 @@ export type Database = { Returns: number; }; get_workspace_wallets_expense: { - Args: { start_date?: string; ws_id: string; end_date?: string }; + Args: { ws_id: string; start_date?: string; end_date?: string }; Returns: number; }; get_workspace_wallets_income: { @@ -7903,7 +7968,7 @@ export type Database = { Returns: boolean; }; insert_ai_chat_message: { - Args: { chat_id: string; source: string; message: string }; + Args: { message: string; chat_id: string; source: string }; Returns: undefined; }; is_list_accessible: { @@ -7911,7 +7976,7 @@ export type Database = { Returns: boolean; }; is_member_invited: { - Args: { _org_id: string; _user_id: string }; + Args: { _user_id: string; _org_id: string }; Returns: boolean; }; is_nova_challenge_manager: { @@ -7923,15 +7988,15 @@ export type Database = { Returns: boolean; }; is_nova_user_email_in_team: { - Args: { _team_id: string; _user_email: string }; + Args: { _user_email: string; _team_id: string }; Returns: boolean; }; is_nova_user_id_in_team: { - Args: { _team_id: string; _user_id: string }; + Args: { _user_id: string; _team_id: string }; Returns: boolean; }; is_org_member: { - Args: { _org_id: string; _user_id: string }; + Args: { _user_id: string; _org_id: string }; Returns: boolean; }; is_project_member: { @@ -7955,7 +8020,7 @@ export type Database = { Returns: Json; }; nova_get_challenge_with_user_stats: { - Args: { user_id: string; challenge_id: string }; + Args: { challenge_id: string; user_id: string }; Returns: Json; }; nova_get_user_daily_sessions: { @@ -7963,7 +8028,7 @@ export type Database = { Returns: number; }; nova_get_user_total_sessions: { - Args: { user_id: string; challenge_id: string }; + Args: { challenge_id: string; user_id: string }; Returns: number; }; revoke_all_cross_app_tokens: { @@ -7975,12 +8040,12 @@ export type Database = { Returns: number; }; revoke_user_session: { - Args: { session_id: string; target_user_id: string }; + Args: { target_user_id: string; session_id: string }; Returns: boolean; }; search_users: { Args: - | { page_size: number; search_query: string; page_number: number } + | { search_query: string; page_number: number; page_size: number } | { search_query: string; page_number: number; @@ -7989,6 +8054,15 @@ export type Database = { enabled_filter?: boolean; }; Returns: { + id: string; + display_name: string; + deleted: boolean; + avatar_url: string; + handle: string; + bio: string; + created_at: string; + user_id: string; + enabled: boolean; allow_challenge_management: boolean; allow_manage_all_challenges: boolean; allow_role_management: boolean; @@ -7996,29 +8070,20 @@ export type Database = { new_email: string; birthday: string; team_name: string[]; - avatar_url: string; - handle: string; - bio: string; - created_at: string; - user_id: string; - enabled: boolean; - id: string; - display_name: string; - deleted: boolean; }[]; }; search_users_by_name: { Args: { - min_similarity?: number; - result_limit?: number; search_query: string; + result_limit?: number; + min_similarity?: number; }; Returns: { + id: string; handle: string; - relevance: number; - avatar_url: string; display_name: string; - id: string; + avatar_url: string; + relevance: number; }[]; }; sum_quiz_scores: { @@ -8028,7 +8093,7 @@ export type Database = { }[]; }; transactions_have_same_abs_amount: { - Args: { transaction_id_2: string; transaction_id_1: string }; + Args: { transaction_id_1: string; transaction_id_2: string }; Returns: boolean; }; transactions_have_same_amount: { From 6e51aef595203eb3fef2b6a77d4cc19522b94112 Mon Sep 17 00:00:00 2001 From: Nguyen Khang Date: Fri, 20 Jun 2025 12:35:46 +0700 Subject: [PATCH 04/10] feat: add functionality to load whiteboard snapshot and thumbnail from database --- .../whiteboards/[boardId]/custom-tldraw.tsx | 34 +- .../(dashboard)/[wsId]/whiteboards/client.tsx | 377 ++++++++++++++++ .../(dashboard)/[wsId]/whiteboards/page.tsx | 414 ++---------------- 3 files changed, 447 insertions(+), 378 deletions(-) create mode 100644 apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/client.tsx diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/custom-tldraw.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/custom-tldraw.tsx index 2fd5ed4ac0..ecb4ccf7e5 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/custom-tldraw.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/custom-tldraw.tsx @@ -54,7 +54,7 @@ function SnapshotToolbar({ boardId }: { boardId: string }) { const { toast } = useToast(); const supabase = createClient(); - const generateThumbnail = useCallback(async (): Promise => { + const generateThumbnail = async () => { const shapeIds = editor.getCurrentPageShapeIds(); if (shapeIds.size === 0) { throw new Error('No shapes on canvas'); @@ -68,7 +68,19 @@ function SnapshotToolbar({ boardId }: { boardId: string }) { }); return blob; - }, [editor]); + }; + + 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 { @@ -76,19 +88,27 @@ function SnapshotToolbar({ boardId }: { boardId: string }) { const thumbnailBlob = await generateThumbnail(); - const thumbnailFile = new File([thumbnailBlob], `${boardId}.png`, { + const thumbnailFileName = generateFileName(`${boardId}.png`); + + const thumbnailFile = new File([thumbnailBlob], thumbnailFileName, { type: 'image/png', }); - const { data: thumbnailData } = await supabase.storage + const { error: thumbnailError } = await supabase.storage .from('whiteboards-thumbnails') - .upload(thumbnailFile.name, thumbnailFile); + .upload(thumbnailFileName, thumbnailFile); + + if (thumbnailError) throw thumbnailError; + + const { data: thumbnailUrl } = supabase.storage + .from('whiteboards-thumbnails') + .getPublicUrl(thumbnailFileName); await supabase .from('whiteboards') .update({ snapshot: JSON.stringify(snapshot), - thumbnail_url: thumbnailData?.path, + thumbnail_url: thumbnailUrl.publicUrl, updated_at: new Date().toISOString(), }) .eq('id', boardId); @@ -106,7 +126,7 @@ function SnapshotToolbar({ boardId }: { boardId: string }) { variant: 'destructive', }); } - }, [editor, boardId, generateThumbnail]); + }, [editor]); useEffect(() => { const snapshot = localStorage.getItem(boardId); 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..10571b90b4 --- /dev/null +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/client.tsx @@ -0,0 +1,377 @@ +'use client'; + +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, + IconPlus, + IconTrash, + IconUsers, + ImageIcon, + LetterText, + List, + MoreHorizontal, + Pen, + Search, +} from '@tuturuuu/ui/icons'; +import { Input } from '@tuturuuu/ui/input'; +import { Separator } from '@tuturuuu/ui/separator'; +import { Toggle } from '@tuturuuu/ui/toggle'; +import { useTranslations } from 'next-intl'; +import Image from 'next/image'; +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; +} + +export default function WhiteboardsList({ + whiteboards, +}: { + whiteboards: Whiteboard[]; +}) { + const [searchQuery, setSearchQuery] = useState(''); + const [sortBy, setSortBy] = useState('lastModified'); + const [viewMode, setViewMode] = useState('grid'); + const t = useTranslations('common'); + + 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 ( +
+ {/* Header */} +
+
+

+ {t('whiteboards')} +

+

+ {t('whiteboards_description')} +

+
+ +
+ + + + {/* 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'} +

+ {!searchQuery && ( + + )} +
+ ) : viewMode === 'grid' ? ( +
+ {sortedWhiteboards.map((whiteboard) => ( + + +
+ + {whiteboard.title} + + + + + + + + + Edit + + + + Share + + + + Delete + + + +
+
+ + {/* 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)} +
+
+ + {/* Actions */} + + + + + + + + Rename + + + + Share + + + +
+
+ ))} +
+ )} +
+ ); +} 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 bc8680d205..9578ec9408 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/page.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/page.tsx @@ -1,378 +1,50 @@ -'use client'; +import WhiteboardsList, { type Whiteboard } from './client'; +import { createClient } from '@tuturuuu/supabase/next/server'; +import { notFound } from 'next/navigation'; -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, - Eye, - Grid3X3, - IconChevronUpDown, - IconEdit, - IconPlus, - IconTrash, - IconUsers, - LetterText, - List, - MoreHorizontal, - Search, -} from '@tuturuuu/ui/icons'; -import { Input } from '@tuturuuu/ui/input'; -import { Separator } from '@tuturuuu/ui/separator'; -import { Toggle } from '@tuturuuu/ui/toggle'; -import { useTranslations } from 'next-intl'; -import { useState } from 'react'; - -type SortOption = 'alphabetical' | 'dateCreated' | 'lastViewed'; -type ViewMode = 'grid' | 'list'; - -interface Whiteboard { - id: string; - title: string; - description?: string; - dateCreated: Date; - lastViewed: Date; - collaborators: number; - thumbnail?: string; +interface WhiteboardsPageProps { + params: Promise<{ wsId: string }>; } -// Mock data for demonstration -const mockWhiteboards: Whiteboard[] = [ - { - id: '1', - title: 'Product Roadmap 2024', - description: 'Strategic planning for the upcoming year', - dateCreated: new Date('2024-01-15'), - lastViewed: new Date('2024-01-20'), - collaborators: 5, - }, - { - id: '2', - title: 'User Journey Mapping', - description: 'Understanding customer touchpoints', - dateCreated: new Date('2024-01-10'), - lastViewed: new Date('2024-01-19'), - collaborators: 3, - }, - { - id: '3', - title: 'Architecture Overview', - description: 'System design and infrastructure planning', - dateCreated: new Date('2024-01-12'), - lastViewed: new Date('2024-01-21'), - collaborators: 8, - }, - { - id: '4', - title: 'Marketing Campaign Ideas', - dateCreated: new Date('2024-01-18'), - lastViewed: new Date('2024-01-18'), - collaborators: 2, - }, -]; - -export default function WhiteboardsPage() { - const [searchQuery, setSearchQuery] = useState(''); - const [sortBy, setSortBy] = useState('lastViewed'); - const [viewMode, setViewMode] = useState('grid'); - const t = useTranslations('common'); - - const getSortLabel = (option: SortOption) => { - switch (option) { - case 'alphabetical': - return 'Alphabetical'; - case 'dateCreated': - return 'Date Created'; - case 'lastViewed': - return 'Last Viewed'; - } - }; - - const getSortIcon = (option: SortOption) => { - switch (option) { - case 'alphabetical': - return ; - case 'dateCreated': - return ; - case 'lastViewed': - return ; - } - }; +async function getWhiteboards(wsId: string): Promise { + const supabase = await createClient(); - const sortedWhiteboards = [...mockWhiteboards] - .filter((whiteboard) => - whiteboard.title.toLowerCase().includes(searchQuery.toLowerCase()) + const { data: whiteboards, error } = await supabase + .from('whiteboards') + .select( + `*, + creator:users(display_name) + ` ) - .sort((a, b) => { - switch (sortBy) { - case 'alphabetical': - return a.title.localeCompare(b.title); - case 'dateCreated': - return b.dateCreated.getTime() - a.dateCreated.getTime(); - case 'lastViewed': - return b.lastViewed.getTime() - a.lastViewed.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 ( -
- {/* Header */} -
-
-

- {t('whiteboards')} -

-

- {t('whiteboards_description')} -

-
- -
- - - - {/* 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('lastViewed')} - className="gap-2" - > - - Last Viewed - - - -
- - {/* 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'} -

- {!searchQuery && ( - - )} -
- ) : viewMode === 'grid' ? ( -
- {sortedWhiteboards.map((whiteboard) => ( - - -
- - {whiteboard.title} - - - - - - - - - Edit - - - - Share - - - - Delete - - - -
-
- - {/* Thumbnail placeholder */} -
-
Preview
-
- - {whiteboard.description && ( -

- {whiteboard.description} -

- )} - -
-
- - {whiteboard.collaborators} -
-
- - {formatDate(whiteboard.lastViewed)} -
-
-
-
- ))} -
- ) : ( -
- {sortedWhiteboards.map((whiteboard) => ( - - - {/* Thumbnail */} -
-
Prev
-
- - {/* Content */} -
-

{whiteboard.title}

- {whiteboard.description && ( -

- {whiteboard.description} -

- )} -
- - {/* Metadata */} -
-
- - {whiteboard.collaborators} -
-
- - {formatDate(whiteboard.lastViewed)} -
-
- - {formatDate(whiteboard.dateCreated)} -
-
+ .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', + })); +} - {/* Actions */} - - - - - - - - Rename - - - - Share - - - -
-
- ))} -
- )} -
- ); +export default async function WhiteboardsPage({ + params, +}: WhiteboardsPageProps) { + const { wsId } = await params; + + try { + const whiteboards = await getWhiteboards(wsId); + return ; + } catch (error) { + console.error('Failed to load whiteboards:', error); + return notFound(); + } } From 6fe2e172a778648f8963583617430de6625af892 Mon Sep 17 00:00:00 2001 From: Nguyen Khang Date: Fri, 20 Jun 2025 12:56:21 +0700 Subject: [PATCH 05/10] refactor(whiteboards): Move save toolbar to a separate component --- .../whiteboards/[boardId]/custom-tldraw.tsx | 113 +----------------- .../[wsId]/whiteboards/[boardId]/toolbar.tsx | 100 ++++++++++++++++ .../(dashboard)/[wsId]/whiteboards/client.tsx | 41 +++---- .../(dashboard)/[wsId]/whiteboards/page.tsx | 30 ++++- 4 files changed, 148 insertions(+), 136 deletions(-) create mode 100644 apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/toolbar.tsx diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/custom-tldraw.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/custom-tldraw.tsx index ecb4ccf7e5..72bf7a816e 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/custom-tldraw.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/custom-tldraw.tsx @@ -1,14 +1,10 @@ 'use client'; -import { createClient } from '@tuturuuu/supabase/next/client'; -import { Button } from '@tuturuuu/ui/button'; +import Toolbar from './toolbar'; import { LoadingIndicator } from '@tuturuuu/ui/custom/loading-indicator'; -import { useToast } from '@tuturuuu/ui/hooks/use-toast'; -import { ArrowLeftIcon } from '@tuturuuu/ui/icons'; import { useTheme } from 'next-themes'; -import Link from 'next/link'; -import { useCallback, useEffect, useState } from 'react'; -import { type Editor, type TLStoreSnapshot, Tldraw, useEditor } from 'tldraw'; +import { useEffect, useState } from 'react'; +import { type Editor, type TLStoreSnapshot, Tldraw } from 'tldraw'; import 'tldraw/tldraw.css'; type Theme = 'system' | 'dark' | 'light'; @@ -41,111 +37,10 @@ export function CustomTldraw({ , + SharePanel: () => , }} onMount={setEditor} />
); } - -function SnapshotToolbar({ boardId }: { boardId: string }) { - const editor = useEditor(); - const { toast } = useToast(); - const supabase = createClient(); - - 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 snapshot = editor.getSnapshot(); - - const thumbnailBlob = await generateThumbnail(); - - const thumbnailFileName = generateFileName(`${boardId}.png`); - - const thumbnailFile = new File([thumbnailBlob], thumbnailFileName, { - type: 'image/png', - }); - - const { error: thumbnailError } = await supabase.storage - .from('whiteboards-thumbnails') - .upload(thumbnailFileName, thumbnailFile); - - if (thumbnailError) throw thumbnailError; - - const { data: thumbnailUrl } = supabase.storage - .from('whiteboards-thumbnails') - .getPublicUrl(thumbnailFileName); - - await supabase - .from('whiteboards') - .update({ - snapshot: JSON.stringify(snapshot), - thumbnail_url: thumbnailUrl.publicUrl, - updated_at: new Date().toISOString(), - }) - .eq('id', boardId); - - toast({ - title: 'Saved successfully!', - description: 'Your whiteboard and thumbnail have been saved.', - variant: 'default', - }); - } catch (error: any) { - console.error(error); - toast({ - title: 'Error saving', - description: 'Failed to save whiteboard', - variant: 'destructive', - }); - } - }, [editor]); - - useEffect(() => { - const snapshot = localStorage.getItem(boardId); - if (!snapshot) return; - - editor.loadSnapshot(JSON.parse(snapshot)); - }, [editor]); - - 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..d7244253b2 --- /dev/null +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/[boardId]/toolbar.tsx @@ -0,0 +1,100 @@ +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 } from 'react'; +import { useEditor } from 'tldraw'; + +export default function Toolbar({ boardId }: { boardId: string }) { + const supabase = createClient(); + + const editor = useEditor(); + const { toast } = useToast(); + + 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(); + const thumbnailFileName = generateFileName(`${boardId}.png`); + const thumbnailFile = new File([thumbnailBlob], thumbnailFileName, { + type: 'image/png', + }); + + const { error: thumbnailError } = await supabase.storage + .from('whiteboards-thumbnails') + .upload(thumbnailFileName, thumbnailFile); + + if (thumbnailError) throw thumbnailError; + + const { data: thumbnailUrl } = supabase.storage + .from('whiteboards-thumbnails') + .getPublicUrl(thumbnailFileName); + + const snapshot = editor.getSnapshot(); + + await supabase + .from('whiteboards') + .update({ + snapshot: JSON.stringify(snapshot), + thumbnail_url: thumbnailUrl.publicUrl, + updated_at: new Date().toISOString(), + }) + .eq('id', boardId); + + toast({ + title: 'Saved successfully!', + description: 'Your whiteboard and thumbnail have been saved.', + variant: 'default', + }); + } catch (error: any) { + console.error(error); + toast({ + title: 'Error saving', + description: 'Failed to save whiteboard', + variant: 'destructive', + }); + } + }, [editor]); + + 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 index 10571b90b4..5c0cb51ae1 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/client.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/client.tsx @@ -24,9 +24,7 @@ import { Search, } from '@tuturuuu/ui/icons'; import { Input } from '@tuturuuu/ui/input'; -import { Separator } from '@tuturuuu/ui/separator'; import { Toggle } from '@tuturuuu/ui/toggle'; -import { useTranslations } from 'next-intl'; import Image from 'next/image'; import { useState } from 'react'; @@ -43,15 +41,18 @@ export interface Whiteboard { creatorName: string; } +interface WhiteboardsListProps { + wsId: string; + whiteboards: Whiteboard[]; +} + export default function WhiteboardsList({ + wsId, whiteboards, -}: { - whiteboards: Whiteboard[]; -}) { +}: WhiteboardsListProps) { const [searchQuery, setSearchQuery] = useState(''); const [sortBy, setSortBy] = useState('lastModified'); const [viewMode, setViewMode] = useState('grid'); - const t = useTranslations('common'); const getSortLabel = (option: SortOption) => { switch (option) { @@ -104,26 +105,12 @@ export default function WhiteboardsList({ return date.toLocaleDateString(); }; - return ( -
- {/* Header */} -
-
-

- {t('whiteboards')} -

-

- {t('whiteboards_description')} -

-
- -
- - + const handleClick = (whiteboard: Whiteboard) => { + window.open(`/${wsId}/whiteboards/${whiteboard.id}`, '_blank'); + }; + return ( + <> {/* Controls */}
@@ -219,6 +206,7 @@ export default function WhiteboardsList({ handleClick(whiteboard)} >
@@ -298,6 +286,7 @@ export default function WhiteboardsList({ handleClick(whiteboard)} > {/* Thumbnail */} @@ -372,6 +361,6 @@ export default function WhiteboardsList({ ))}
)} -
+ ); } 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 9578ec9408..7895f78199 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/page.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/page.tsx @@ -1,5 +1,9 @@ import WhiteboardsList, { type Whiteboard } from './client'; 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'; interface WhiteboardsPageProps { @@ -39,10 +43,34 @@ export default async function WhiteboardsPage({ params, }: WhiteboardsPageProps) { const { wsId } = await params; + const t = await getTranslations('common'); try { const whiteboards = await getWhiteboards(wsId); - return ; + + return ( +
+ {/* Header */} +
+
+

+ {t('whiteboards')} +

+

+ {t('whiteboards_description')} +

+
+ +
+ + + + +
+ ); } catch (error) { console.error('Failed to load whiteboards:', error); return notFound(); From 9295caf6e5d239731fe6abe4111858491d1a3181 Mon Sep 17 00:00:00 2001 From: Nguyen Khang Date: Fri, 20 Jun 2025 14:34:13 +0700 Subject: [PATCH 06/10] feat(whiteboards): add create and edit whiteboard dialogs with form handling --- .../(dashboard)/[wsId]/whiteboards/client.tsx | 364 +++++++++++------- .../whiteboards/createWhiteboardDialog.tsx | 82 ++++ .../whiteboards/editWhiteboardDialog.tsx | 95 +++++ .../(dashboard)/[wsId]/whiteboards/page.tsx | 14 +- .../[wsId]/whiteboards/whiteboardForm.tsx | 110 ++++++ 5 files changed, 513 insertions(+), 152 deletions(-) create mode 100644 apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/createWhiteboardDialog.tsx create mode 100644 apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/editWhiteboardDialog.tsx create mode 100644 apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/whiteboardForm.tsx diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/client.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/client.tsx index 5c0cb51ae1..6851b238df 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/client.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/client.tsx @@ -1,5 +1,17 @@ '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 { @@ -13,7 +25,6 @@ import { Grid3X3, IconChevronUpDown, IconEdit, - IconPlus, IconTrash, IconUsers, ImageIcon, @@ -24,8 +35,11 @@ import { 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'; @@ -105,10 +119,6 @@ export default function WhiteboardsList({ return date.toLocaleDateString(); }; - const handleClick = (whiteboard: Whiteboard) => { - window.open(`/${wsId}/whiteboards/${whiteboard.id}`, '_blank'); - }; - return ( <> {/* Controls */} @@ -193,174 +203,232 @@ export default function WhiteboardsList({ ? `No whiteboards match "${searchQuery}"` : 'Get started by creating your first whiteboard'}

- {!searchQuery && ( - - )}
) : viewMode === 'grid' ? (
{sortedWhiteboards.map((whiteboard) => ( - handleClick(whiteboard)} + href={`/${wsId}/whiteboards/${whiteboard.id}`} + target="_blank" + className="block" > - -
- - {whiteboard.title} - - - - - - - - - Edit - - - - Share - - - - Delete - - - -
-
- - {/* Thumbnail */} -
- {whiteboard.thumbnail_url ? ( - {whiteboard.title} - ) : ( -
-
- No preview + + +
+ + {whiteboard.title} + + + +
+
+ + {/* Thumbnail */} +
+ {whiteboard.thumbnail_url ? ( + {whiteboard.title} + ) : ( +
+
+ No preview +
-
- )} -
+ )} +
- {whiteboard.description && ( -

- {whiteboard.description} -

- )} + {whiteboard.description && ( +

+ {whiteboard.description} +

+ )} -
-
- - {whiteboard.creatorName} -
-
- - {formatDate(whiteboard.lastModified)} +
+
+ + {whiteboard.creatorName} +
+
+ + {formatDate(whiteboard.lastModified)} +
-
- - + + + ))}
) : (
{sortedWhiteboards.map((whiteboard) => ( - handleClick(whiteboard)} + href={`/${wsId}/whiteboards/${whiteboard.id}`} + target="_blank" + className="block" > - - {/* Thumbnail */} -
- {whiteboard.thumbnail_url ? ( - {whiteboard.title} - ) : ( -
-
- + + + {/* Thumbnail */} +
+ {whiteboard.thumbnail_url ? ( + {whiteboard.title} + ) : ( +
+
+ +
-
- )} -
- - {/* Content */} -
-

{whiteboard.title}

- {whiteboard.description && ( -

- {whiteboard.description} -

- )} -
- - {/* Metadata */} -
-
- - {whiteboard.creatorName} + )}
-
- - {formatDate(whiteboard.lastModified)} + + {/* Content */} +
+

+ {whiteboard.title} +

+ {whiteboard.description && ( +

+ {whiteboard.description} +

+ )}
-
- - {formatDate(whiteboard.dateCreated)} + + {/* Metadata */} +
+
+ + {whiteboard.creatorName} +
+
+ + {formatDate(whiteboard.lastModified)} +
+
+ + {formatDate(whiteboard.dateCreated)} +
-
- {/* Actions */} - - - - - - - - Rename - - - - Share - - - - - + + + + ))}
)} ); } + +function CardAction({ whiteboard }: { whiteboard: Whiteboard }) { + const supabase = createClient(); + const router = useRouter(); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + const handleDelete = async (whiteboard: Whiteboard) => { + if (!confirm(`Are you sure you want to delete "${whiteboard.title}"?`)) { + return; + } + + try { + const { error } = await supabase + .from('whiteboards') + .delete() + .eq('id', whiteboard.id); + + if (error) { + console.error('Error deleting whiteboard:', error); + toast.error('Failed to delete whiteboard. Please try again.'); + return; + } + + toast.success('Whiteboard deleted successfully!'); + router.refresh(); + } catch (error) { + console.error('Unexpected error:', error); + toast.error('An unexpected error occurred. 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..78270c18a3 --- /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('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..c696c84dea --- /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('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 7895f78199..c423f38b77 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/page.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/page.tsx @@ -1,4 +1,5 @@ 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'; @@ -60,10 +61,15 @@ export default async function WhiteboardsPage({ {t('whiteboards_description')}

- + + + {t('new_whiteboard')} + + } + />
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..68c168f425 --- /dev/null +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/whiteboards/whiteboardForm.tsx @@ -0,0 +1,110 @@ +'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(3, { + message: 'Title must be at least 3 characters.', + }), + description: z.string().optional(), +}); + +export type WhiteboardFormValues = z.infer; + +interface WhiteboardFormProps { + defaultValues?: WhiteboardFormValues; + whiteboardId?: string; + 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) + +