diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 9ecc7451..88807306 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -162,6 +162,8 @@ import CacheTest from "./pages/CacheTest"; // ... (imports) import { FeedCacheProvider } from "@/contexts/FeedCacheContext"; +import { StreamProvider } from "@/contexts/StreamContext"; +import { StreamInvalidator } from "@/components/StreamInvalidator"; // ... (imports) @@ -185,9 +187,12 @@ const App = () => { - - - + + + + + + diff --git a/packages/ui/src/components/PageActions.tsx b/packages/ui/src/components/PageActions.tsx index f7a63ffe..9f3459fa 100644 --- a/packages/ui/src/components/PageActions.tsx +++ b/packages/ui/src/components/PageActions.tsx @@ -2,20 +2,24 @@ import { useState } from "react"; import { supabase } from "@/integrations/supabase/client"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { Eye, EyeOff, Edit3, Trash2, GitMerge, Share2, Link as LinkIcon, FileText, Download, FilePlus, FolderTree, FileJson } from "lucide-react"; +import { Eye, EyeOff, Edit3, Trash2, GitMerge, Share2, Link as LinkIcon, FileText, Download, FilePlus, FolderTree, FileJson, LayoutTemplate } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, - DropdownMenuLabel + DropdownMenuLabel, + DropdownMenuGroup } from "@/components/ui/dropdown-menu"; import { T, translate } from "@/i18n"; import { PagePickerDialog } from "./widgets/PagePickerDialog"; import { PageCreationWizard } from "./widgets/PageCreationWizard"; import { CategoryManager } from "./widgets/CategoryManager"; import { cn } from "@/lib/utils"; +import { Database } from '@/integrations/supabase/types'; + +type Layout = Database['public']['Tables']['layouts']['Row']; interface Page { id: string; @@ -39,6 +43,8 @@ interface PageActionsProps { onMetaUpdated?: () => void; className?: string; showLabels?: boolean; + templates?: Layout[]; + onLoadTemplate?: (template: Layout) => void; } export const PageActions = ({ @@ -50,7 +56,9 @@ export const PageActions = ({ onDelete, onMetaUpdated, className, - showLabels = true + showLabels = true, + templates, + onLoadTemplate }: PageActionsProps) => { const [loading, setLoading] = useState(false); const [showPagePicker, setShowPagePicker] = useState(false); @@ -78,7 +86,8 @@ export const PageActions = ({ 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ - paths: [apiPath, htmlPath] + paths: [apiPath, htmlPath], + types: ['pages'] }) }); console.log('Cache invalidated for:', page.slug); @@ -488,6 +497,33 @@ draft: ${!page.visible} )} + {/* Layout Template Picker - Only in Edit Mode */} + {isEditMode && templates && onLoadTemplate && ( + + + + + + Load Template + + + {templates.length === 0 ? ( +
No templates found
+ ) : ( + templates.map((t) => ( + onLoadTemplate(t)}> + {t.name} + + )) + )} +
+
+
+ )} + {/* Categorization - New All-in-One Component */} + + + ) : ( +

isOwner && isEditMode && handleStartEditTitle()} + title={isOwner && isEditMode ? 'Click to edit title' : ''} + > + {page.title} +

+ )} + +
+
+ {userProfile && ( + + {userProfile.display_name || userProfile.username || `User ${userId?.slice(0, 8)}`} + + )} +
+ + {new Date(page.created_at).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + })} +
+
+ {(() => { + const displayPaths = page.category_paths || (page.categories?.map(c => [c]) || []); + if (displayPaths.length === 0) return null; + + return ( +
+ {displayPaths.map((path, pathIdx) => ( +
+ + {path.map((cat, idx) => ( + + {idx > 0 && /} + + {cat.name} + + + ))} +
+ ))} +
+ ); + })()} +
+ + + + + + {/* Tags and Type */} +
+
+ {!page.visible && isOwner && ( + + + Hidden + + )} + {!page.is_public && ( + + Private + + )} + + {/* PageActions - Only visible in View Mode (Edit Mode uses PageRibbonBar) */} + {!isEditMode && ( + { + onToggleEditMode(); + if (isEditMode) onWidgetRename(null); + }} + onPageUpdate={onPageUpdate} + onMetaUpdated={() => userId && page.slug && invalidateUserPageCache(userId, page.slug)} // Simple invalidation trigger + templates={templates} + onLoadTemplate={onLoadTemplate} + /> + )} +
+ + + + {/* Editable Tags */} + {editingTags && isOwner && isEditMode ? ( +
+
+ setTagsValue(e.target.value)} + className="text-sm" + placeholder="tag1, tag2, tag3..." + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveTags(); + if (e.key === 'Escape') setEditingTags(false); + }} + autoFocus + disabled={savingField === 'tags'} + /> +

+ Separate tags with commas +

+
+ + +
+ ) : ( +
+ {page.tags && page.tags.map((tag, index) => ( + isOwner && isEditMode && handleStartEditTags()} + > + #{tag} + + ))} + {isOwner && isEditMode && ( + + )} +
+ )} +
+ + ); +}; diff --git a/packages/ui/src/components/user-page/UserPageTopBar.tsx b/packages/ui/src/components/user-page/UserPageTopBar.tsx new file mode 100644 index 00000000..f06f3734 --- /dev/null +++ b/packages/ui/src/components/user-page/UserPageTopBar.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button } from "@/components/ui/button"; +import { ArrowLeft } from "lucide-react"; +import { T } from "@/i18n"; + +interface UserPageTopBarProps { + embedded?: boolean; + orgSlug?: string; + userId: string; + isOwner?: boolean; +} + +export const UserPageTopBar: React.FC = ({ + embedded = false, + orgSlug, + userId, + isOwner, +}) => { + const navigate = useNavigate(); + + if (embedded) return null; + + return ( +
+
+ +
+
+ ); +}; diff --git a/packages/ui/src/components/user-page/ribbons/PageRibbonBar.tsx b/packages/ui/src/components/user-page/ribbons/PageRibbonBar.tsx new file mode 100644 index 00000000..a3fd4f9d --- /dev/null +++ b/packages/ui/src/components/user-page/ribbons/PageRibbonBar.tsx @@ -0,0 +1,491 @@ +import React, { useState } from 'react'; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { supabase } from "@/integrations/supabase/client"; +import { toast } from "sonner"; +import { + LayoutTemplate, + Eye, + EyeOff, + Trash2, + GitMerge, + FolderTree, + FilePlus, + Settings, + Grid, + Type, + Image as ImageIcon, + Component, + MousePointer2, + Save, + Undo2, + Redo2, + FileJson +} from "lucide-react"; +import { T, translate } from "@/i18n"; +import { Database } from '@/integrations/supabase/types'; +import { CategoryManager } from "@/components/widgets/CategoryManager"; +import { widgetRegistry } from "@/lib/widgetRegistry"; + +type Layout = Database['public']['Tables']['layouts']['Row']; + +interface Page { + id: string; + title: string; + content: any; + visible: boolean; + is_public: boolean; + owner: string; + slug: string; + parent: string | null; + meta?: any; +} + +interface PageRibbonBarProps { + page: Page; + isOwner: boolean; + onToggleEditMode: () => void; + onPageUpdate: (updatedPage: Page) => void; + onDelete?: () => void; + onMetaUpdated?: () => void; + templates?: Layout[]; + onLoadTemplate?: (template: Layout) => void; + onAddWidget?: (widgetId: string) => void; + onAddContainer?: () => void; + className?: string; + onUndo?: () => void; + onRedo?: () => void; + canUndo?: boolean; + canRedo?: boolean; +} + +// Ribbon UI Components +const RibbonTab = ({ active, onClick, children }: { active: boolean, onClick: () => void, children: React.ReactNode }) => ( + +); + +const RibbonGroup = ({ label, children }: { label: string, children: React.ReactNode }) => ( +
+
+ {children} +
+
+ {label} +
+
+); + +const RibbonItemLarge = ({ + icon: Icon, + label, + onClick, + active, + iconColor = "text-foreground", + disabled = false +}: { + icon: any, + label: string, + onClick?: () => void, + active?: boolean, + iconColor?: string, + disabled?: boolean +}) => ( + +); + +const RibbonItemSmall = ({ + icon: Icon, + label, + onClick, + active, + iconColor = "text-foreground", + disabled = false +}: { + icon: any, + label: string, + onClick?: () => void, + active?: boolean, + iconColor?: string, + disabled?: boolean +}) => ( + +); + +export const PageRibbonBar = ({ + page, + isOwner, + onToggleEditMode, + onPageUpdate, + onDelete, + onMetaUpdated, + templates, + onLoadTemplate, + onAddWidget, + onAddContainer, + className, + onUndo, + onRedo, + canUndo = false, + canRedo = false +}: PageRibbonBarProps) => { + const [activeTab, setActiveTab] = useState<'page' | 'insert' | 'view' | 'advanced'>('page'); + const [loading, setLoading] = useState(false); + const [showCategoryManager, setShowCategoryManager] = useState(false); + const [showPagePicker, setShowPagePicker] = useState(false); + + // Logic duplicated from PageActions + const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin; + + const invalidatePageCache = async () => { + try { + const session = await supabase.auth.getSession(); + const token = session.data.session?.access_token; + if (!token) return; + + const apiPath = `/api/user-page/${page.owner}/${page.slug}`; + const htmlPath = `/user/${page.owner}/pages/${page.slug}`; + + await fetch(`${baseUrl}/api/cache/invalidate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + paths: [apiPath, htmlPath], + types: ['pages'] + }) + }); + console.log('Cache invalidated for:', page.slug); + } catch (e) { + console.error('Failed to invalidate cache:', e); + } + }; + + const handleToggleVisibility = async (e?: React.MouseEvent) => { + e?.stopPropagation(); + if (loading) return; + setLoading(true); + + try { + const { error } = await supabase + .from('pages') + .update({ visible: !page.visible }) + .eq('id', page.id); + + if (error) throw error; + + onPageUpdate({ ...page, visible: !page.visible }); + toast.success(translate(page.visible ? 'Page hidden' : 'Page made visible')); + invalidatePageCache(); + } catch (error) { + console.error('Error toggling visibility:', error); + toast.error(translate('Failed to update page visibility')); + } finally { + setLoading(false); + } + }; + + const handleTogglePublic = async (e?: React.MouseEvent) => { + e?.stopPropagation(); + if (loading) return; + setLoading(true); + + try { + const { error } = await supabase + .from('pages') + .update({ is_public: !page.is_public }) + .eq('id', page.id); + + if (error) throw error; + + onPageUpdate({ ...page, is_public: !page.is_public }); + toast.success(translate(page.is_public ? 'Page made private' : 'Page made public')); + invalidatePageCache(); + } catch (error) { + console.error('Error toggling public status:', error); + toast.error(translate('Failed to update page status')); + } finally { + setLoading(false); + } + }; + + const handleDumpJson = async () => { + try { + const pageJson = JSON.stringify(page, null, 2); + console.log('Page JSON:', pageJson); + await navigator.clipboard.writeText(pageJson); + toast.success("Page JSON dumped to console and clipboard"); + } catch (e) { + console.error("Failed to dump JSON", e); + toast.error("Failed to dump JSON"); + } + }; + + if (!isOwner) return null; + + return ( +
+ {/* Context & Tabs Row */} +
+
+ DESIGN +
+
+ setActiveTab('page')}>PAGE + setActiveTab('insert')}>INSERT + setActiveTab('view')}>VIEW + setActiveTab('advanced')}>ADVANCED +
+
+ + {/* Ribbon Toolbar Area */} +
+ + {/* === PAGE TAB === */} + {activeTab === 'page' && ( + <> + +
+ + +
+ setShowCategoryManager(true)} + iconColor="text-yellow-600 dark:text-yellow-400" + /> +
+ + +
+ + +
+
+ + + + {onDelete && ( + + )} + + + + {templates?.map(t => ( + onLoadTemplate?.(t)} + iconColor="text-indigo-500 dark:text-indigo-400" + /> + ))} + {(!templates || templates.length === 0) && ( +
No Layouts
+ )} +
+ + )} + + {/* === INSERT TAB === */} + {activeTab === 'insert' && ( + <> + + + + + {/* Dynamic Widget Groups */} + {Array.from(new Set(widgetRegistry.getAll().map(w => w.metadata.category))) + .filter(cat => cat !== 'system' && cat !== 'hidden') // Filter internal categories if needed + .sort((a, b) => { + // Custom sort order: display, chart, control, others + const order = { display: 1, chart: 2, control: 3, custom: 4 }; + return (order[a as keyof typeof order] || 99) - (order[b as keyof typeof order] || 99); + }) + .map(category => { + const widgets = widgetRegistry.getByCategory(category); + if (widgets.length === 0) return null; + + return ( + +
+ {/* If we have many items, use small items in grid/column, else large */} + {widgets.length <= 2 ? ( + widgets.map(widget => ( + onAddWidget?.(widget.metadata.id)} + iconColor="text-blue-600 dark:text-blue-400" + /> + )) + ) : ( +
+ {widgets.map(widget => ( + onAddWidget?.(widget.metadata.id)} + iconColor="text-blue-600 dark:text-blue-400" + /> + ))} +
+ )} +
+
+ ); + }) + } + + )} + + {/* === VIEW TAB === */} + {activeTab === 'view' && ( + <> + + + + + )} + + {/* === ADVANCED TAB === */} + {activeTab === 'advanced' && ( + <> + + + + + )} + + {/* Always visible 'Finish' on far right? Or just in View tab. Fusion has 'Finish Sketch' big checkmark */} +
+ +
+ +
+ + {/* Managed Dialogs */} + setShowCategoryManager(false)} + currentPageId={page.id} + currentPageMeta={page.meta} + onPageMetaUpdate={async (newMeta) => { + // Similar logic to handleMetaUpdate in PageActions + try { + const { updatePageMeta } = await import('@/lib/db'); + await updatePageMeta(page.id, newMeta); + invalidatePageCache(); + onPageUpdate({ ...page, meta: newMeta }); + if (onMetaUpdated) onMetaUpdated(); + } catch (error) { + console.error('Failed to update page meta:', error); + toast.error(translate('Failed to update categories')); + } + }} + filterByType="pages" + defaultMetaType="pages" + /> +
+ ); +}; + +export default PageRibbonBar; diff --git a/packages/ui/src/components/widgets/CategoryManager.tsx b/packages/ui/src/components/widgets/CategoryManager.tsx index 0d0b3f1f..c516fe36 100644 --- a/packages/ui/src/components/widgets/CategoryManager.tsx +++ b/packages/ui/src/components/widgets/CategoryManager.tsx @@ -1,4 +1,5 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; @@ -9,6 +10,7 @@ import { toast } from "sonner"; import { Plus, Edit2, Trash2, FolderTree, Link as LinkIcon, Check, X, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { T } from "@/i18n"; + interface CategoryManagerProps { isOpen: boolean; onClose: () => void; @@ -20,8 +22,8 @@ interface CategoryManagerProps { } export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMeta, onPageMetaUpdate, filterByType, defaultMetaType }: CategoryManagerProps) => { - const [categories, setCategories] = useState([]); - const [loading, setLoading] = useState(false); + // const [categories, setCategories] = useState([]); // Replaced by useQuery + // const [loading, setLoading] = useState(false); // Replaced by useQuery const [actionLoading, setActionLoading] = useState(false); // Selection state @@ -44,15 +46,10 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet const linkedCategoryIds = getLinkedCategoryIds(); - useEffect(() => { - if (isOpen) { - loadCategories(); - } - }, [isOpen]); - - const loadCategories = async () => { - setLoading(true); - try { + // React Query Integration + const { data: categories = [], isLoading: loading } = useQuery({ + queryKey: ['categories'], + queryFn: async () => { const data = await fetchCategories({ includeChildren: true }); // Filter by type if specified let filtered = filterByType @@ -60,17 +57,9 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet : data; // Only show root-level categories (those without a parent) - // Children will be rendered recursively via the children property - filtered = filtered.filter(cat => !cat.parent_category_id); - - setCategories(filtered); - } catch (error) { - console.error(error); - toast.error("Failed to load categories"); - } finally { - setLoading(false); + return filtered.filter(cat => !cat.parent_category_id); } - }; + }); const handleCreateStart = (parentId: string | null = null) => { setIsCreating(true); @@ -88,6 +77,8 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet setEditingCategory({ ...category }); }; + const queryClient = useQueryClient(); + const handleSave = async () => { if (!editingCategory || !editingCategory.name || !editingCategory.slug) { toast.error("Name and Slug are required"); @@ -119,7 +110,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet toast.success("Category updated"); } setEditingCategory(null); - loadCategories(); + queryClient.invalidateQueries({ queryKey: ['categories'] }); } catch (error) { console.error(error); toast.error(isCreating ? "Failed to create category" : "Failed to update category"); @@ -136,7 +127,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet try { await deleteCategory(id); toast.success("Category deleted"); - loadCategories(); + queryClient.invalidateQueries({ queryKey: ['categories'] }); if (selectedCategoryId === id) setSelectedCategoryId(null); } catch (error) { console.error(error); diff --git a/packages/ui/src/components/widgets/TailwindClassPicker.tsx b/packages/ui/src/components/widgets/TailwindClassPicker.tsx new file mode 100644 index 00000000..598ed172 --- /dev/null +++ b/packages/ui/src/components/widgets/TailwindClassPicker.tsx @@ -0,0 +1,181 @@ +import React, { useState, useMemo } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; +import { Check, Wand2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { T } from '@/i18n'; + +interface TailwindClassGroup { + label: string; + classes: string[]; +} + +const TAILWIND_GROUPS: TailwindClassGroup[] = [ + { + label: 'Layout & Sizing', + classes: [ + 'flex', 'grid', 'hidden', 'block', 'inline-block', + 'flex-col', 'flex-row', 'flex-wrap', + 'items-center', 'items-start', 'items-end', 'items-stretch', + 'justify-center', 'justify-between', 'justify-start', 'justify-end', + 'gap-1', 'gap-2', 'gap-4', 'gap-6', 'gap-8', + 'w-full', 'h-full', 'w-screen', 'h-screen', + 'max-w-sm', 'max-w-md', 'max-w-lg', 'max-w-xl', 'max-w-2xl', + 'min-h-screen', 'min-h-[200px]', + 'relative', 'absolute', 'fixed', 'sticky', 'top-0', 'left-0', 'z-10', 'z-50', + 'overflow-hidden', 'overflow-auto' + ] + }, + { + label: 'Spacing', + classes: [ + 'p-0', 'p-1', 'p-2', 'p-4', 'p-6', 'p-8', 'p-12', + 'px-2', 'px-4', 'px-6', 'px-8', + 'py-2', 'py-4', 'py-6', 'py-8', + 'm-0', 'm-2', 'm-4', 'm-8', 'm-auto', + 'mt-2', 'mt-4', 'mt-8', 'mb-2', 'mb-4', 'mb-8', + 'mx-auto' + ] + }, + { + label: 'Typography', + classes: [ + 'text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl', 'text-2xl', 'text-3xl', 'text-4xl', 'text-5xl', + 'font-thin', 'font-light', 'font-normal', 'font-medium', 'font-semibold', 'font-bold', 'font-black', + 'tracking-tighter', 'tracking-tight', 'tracking-normal', 'tracking-wide', 'tracking-wider', + 'leading-none', 'leading-tight', 'leading-snug', 'leading-normal', 'leading-relaxed', 'leading-loose', + 'text-center', 'text-left', 'text-right', 'text-justify', + 'uppercase', 'lowercase', 'capitalize', + 'underline', 'line-through', 'no-underline', + 'truncate', 'break-words', 'whitespace-nowrap' + ] + }, + { + label: 'Colors (Semantic)', + classes: [ + 'text-foreground', 'text-muted-foreground', 'text-primary', 'text-primary-foreground', + 'text-secondary', 'text-secondary-foreground', 'text-accent', 'text-accent-foreground', + 'text-destructive', 'text-white', 'text-black', 'text-transparent', + 'bg-background', 'bg-foreground', 'bg-card', 'bg-popover', + 'bg-primary', 'bg-secondary', 'bg-muted', 'bg-accent', 'bg-destructive', + 'bg-white', 'bg-black', 'bg-transparent' + ] + }, + { + label: 'Appearance', + classes: [ + 'border', 'border-2', 'border-t', 'border-b', 'border-input', 'border-primary', 'border-border', + 'rounded-none', 'rounded-sm', 'rounded-md', 'rounded-lg', 'rounded-xl', 'rounded-2xl', 'rounded-full', + 'shadow-sm', 'shadow', 'shadow-md', 'shadow-lg', 'shadow-xl', 'shadow-2xl', 'shadow-none', + 'opacity-0', 'opacity-25', 'opacity-50', 'opacity-75', 'opacity-100', + 'backdrop-blur-none', 'backdrop-blur-sm', 'backdrop-blur', 'backdrop-blur-md', 'backdrop-blur-lg', 'backdrop-blur-xl', + 'grayscale', 'grayscale-0', 'invert', 'invert-0' + ] + }, + { + label: 'Animation (Enter/Exit)', + classes: [ + 'animate-in', 'animate-out', + 'fade-in', 'fade-out', + 'zoom-in', 'zoom-out', 'zoom-in-95', 'zoom-in-50', + 'slide-in-from-top-2', 'slide-in-from-bottom-2', 'slide-in-from-left-2', 'slide-in-from-right-2', + 'duration-100', 'duration-200', 'duration-300', 'duration-500', 'duration-700', 'duration-1000', + 'ease-in', 'ease-out', 'ease-in-out', + 'delay-100', 'delay-200', 'delay-300', 'delay-500' + ] + } +]; + +interface TailwindClassPickerProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; +} + +export const TailwindClassPicker: React.FC = ({ + value = '', + onChange, + placeholder = "Select classes...", + className +}) => { + const [open, setOpen] = useState(false); + + // Split current value into array of classes + const currentClasses = useMemo(() => { + return value.trim().split(/\s+/).filter(Boolean); + }, [value]); + + const handleSelect = (cls: string) => { + const newClasses = currentClasses.includes(cls) + ? currentClasses.filter(c => c !== cls) + : [...currentClasses, cls]; + + onChange(newClasses.join(' ')); + }; + + const removeClass = (cls: string) => { + onChange(currentClasses.filter(c => c !== cls).join(' ')); + }; + + + const handleInputChange = (e: React.ChangeEvent) => { + onChange(e.target.value); + }; + + return ( +
+
+ +
+ + + + + + + + + + No class found. + {TAILWIND_GROUPS.map((group) => ( + + {group.classes.map((cls) => ( + handleSelect(cls)} + className="text-xs" + > + + {cls} + + ))} + + ))} + + + + +
+ ); +}; diff --git a/packages/ui/src/components/widgets/WidgetPropertiesForm.tsx b/packages/ui/src/components/widgets/WidgetPropertiesForm.tsx index 570e3b9a..15ffaeeb 100644 --- a/packages/ui/src/components/widgets/WidgetPropertiesForm.tsx +++ b/packages/ui/src/components/widgets/WidgetPropertiesForm.tsx @@ -11,6 +11,7 @@ import { Image as ImageIcon, Maximize2 } from 'lucide-react'; import { Textarea } from "@/components/ui/textarea"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import MarkdownEditor from '@/components/MarkdownEditorEx'; +import { TailwindClassPicker } from './TailwindClassPicker'; export interface WidgetPropertiesFormProps { widgetDefinition: WidgetDefinition; @@ -233,38 +234,79 @@ export const WidgetPropertiesForm: React.FC = ({ return (
- {/* Widget ID Helper (Editable) */} - {widgetInstanceId && onRename && ( -
- -
- { - const val = e.target.value.trim(); - if (val && val !== widgetInstanceId) { - onRename(val); - } else { - e.target.value = widgetInstanceId; // Reset if empty or same - } - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.currentTarget.blur(); - } - }} - /> + + + {/* General Properties */} +
+ + + {/* Widget ID Helper (Editable) */} + {widgetInstanceId && onRename && ( +
+ +
+ { + const val = e.target.value.trim(); + if (val && val !== widgetInstanceId) { + onRename(val); + } else { + e.target.value = widgetInstanceId; // Reset if empty or same + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.currentTarget.blur(); + } + }} + /> +
+

+ Unique identifier used for templating key reference. +

-

- Unique identifier used for templating key reference. -

+ )} + + {/* Enabled Toggle */} +
+
+ +

Turn off to hide this widget.

+
+ updateSetting('enabled', checked)} + className="scale-90" + />
- )} +
+ + {/* Custom CSS Class Editor */} +
+ +
+ updateSetting('customClassName', newValue)} + placeholder={widgetInstanceId ? `widget-${widgetInstanceId.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : 'Select classes...'} + className="w-full" + /> +
+

+ Custom CSS class for this widget instance. Defaults to widget-ID. +

+
{Object.entries(configSchema).map(([key, config]) => diff --git a/packages/ui/src/components/widgets/WidgetSettingsManager.tsx b/packages/ui/src/components/widgets/WidgetSettingsManager.tsx index 922e1232..cfbf764e 100644 --- a/packages/ui/src/components/widgets/WidgetSettingsManager.tsx +++ b/packages/ui/src/components/widgets/WidgetSettingsManager.tsx @@ -13,6 +13,7 @@ interface WidgetSettingsManagerProps { currentProps: Record; widgetInstanceId?: string; onRename?: (newId: string) => void; + onCancel?: () => void; } const WidgetSettingsManagerComponent: React.FC = ({ @@ -22,7 +23,8 @@ const WidgetSettingsManagerComponent: React.FC = ({ widgetDefinition, currentProps, widgetInstanceId, - onRename + onRename, + onCancel }) => { const [settings, setSettings] = useState>(currentProps); @@ -42,11 +44,22 @@ const WidgetSettingsManagerComponent: React.FC = ({ const handleCancel = () => { setSettings(currentProps); + if (onCancel) { + onCancel(); + } onClose(); }; return ( - + { + if (!open) { + // Treat closing via backdrop/ESC as cancel + handleCancel(); + } else { + // Should be handled by isOpen prop, but Dialog might invoke this + // This branch is rarely hit if controlled, but good for safety + } + }}> diff --git a/packages/ui/src/contexts/LayoutContext.tsx b/packages/ui/src/contexts/LayoutContext.tsx index 6b4daa95..6052d37e 100644 --- a/packages/ui/src/contexts/LayoutContext.tsx +++ b/packages/ui/src/contexts/LayoutContext.tsx @@ -1,6 +1,9 @@ -import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; import { UnifiedLayoutManager, PageLayout, WidgetInstance, LayoutContainer } from '@/lib/unifiedLayoutManager'; import { widgetRegistry } from '@/lib/widgetRegistry'; +import { HistoryManager } from '@/lib/page-commands/HistoryManager'; +import { CommandContext } from '@/lib/page-commands/types'; +import { AddWidgetCommand, RemoveWidgetCommand, UpdateWidgetSettingsCommand } from '@/lib/page-commands/commands'; interface LayoutContextType { // Generic page management @@ -26,6 +29,12 @@ interface LayoutContextType { // Manual save saveToApi: () => Promise; + // History Actions + undo: () => Promise; + redo: () => Promise; + canUndo: boolean; + canRedo: boolean; + // State isLoading: boolean; loadedPages: Map; @@ -41,6 +50,45 @@ export const LayoutProvider: React.FC = ({ children }) => { const [loadedPages, setLoadedPages] = useState>(new Map()); const [isLoading, setIsLoading] = useState(true); + // History State + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + + const updateLayoutCallback = useCallback((pageId: string, layout: PageLayout) => { + setLoadedPages(prev => new Map(prev).set(pageId, layout)); + UnifiedLayoutManager.savePageLayout(layout).catch(e => console.error("Failed to persist layout update", e)); + }, []); + + const [historyManager] = useState(() => new HistoryManager({ + pageId: '', // Placeholder, locally updated in execute + layouts: loadedPages, // Placeholder, updated in execute + updateLayout: updateLayoutCallback + })); + + // Update history UI state + const updateHistoryState = useCallback(() => { + setCanUndo(historyManager.canUndo()); + setCanRedo(historyManager.canRedo()); + }, [historyManager]); + + // Helper to persist to storage (database/localStorage) + const saveLayoutToCache = useCallback(async (pageId: string, layout?: PageLayout) => { + const currentLayout = layout || loadedPages.get(pageId); + if (!currentLayout) { + console.error(`Cannot save page ${pageId}: layout not loaded`); + return; + } + + try { + await UnifiedLayoutManager.savePageLayout(currentLayout); + // Ensure state is in sync if we modified a clone + setLoadedPages(prev => new Map(prev).set(pageId, currentLayout)); + } catch (e) { + console.error("Failed to save layout", e); + } + }, [loadedPages]); + + // Initialize layouts on mount useEffect(() => { const initializeLayouts = async () => { @@ -66,10 +114,6 @@ export const LayoutProvider: React.FC = ({ children }) => { initializeLayouts(); }, []); - useEffect(() => { - - }, [loadedPages]); - const loadPageLayout = async (pageId: string, defaultName?: string) => { // Only load if not already cached if (!loadedPages.has(pageId)) { @@ -99,103 +143,86 @@ export const LayoutProvider: React.FC = ({ children }) => { name: currentLayout.name, containers: [ { - id: UnifiedLayoutManager.generateContainerId(), - type: 'container', + id: crypto.randomUUID(), + type: 'container', // Using 'container' as per ULM default, was 'grid' in previous context but 'container' in ULM columns: 1, - gap: 16, + gap: 16, // ULM default widgets: [], children: [], order: 0 } ], + // Preserve created if exists createdAt: currentLayout.createdAt, updatedAt: Date.now() }; - // Update the in-memory cache setLoadedPages(prev => new Map(prev).set(pageId, clearedLayout)); - // Save to localStorage cache - await saveLayoutToCache(pageId); + // Also clear persistence + await UnifiedLayoutManager.savePageLayout(clearedLayout); + + // Clear history as this is a destructive reset + historyManager.clear(); + updateHistoryState(); + } catch (error) { - console.error(`Failed to clear page layout ${pageId}:`, error); + console.error('Failed to clear layout:', error); throw error; } }; - const saveLayoutToCache = async (pageId: string) => { - const currentLayout = loadedPages.get(pageId); - if (!currentLayout) { - console.error(`Cannot save page ${pageId}: layout not loaded`); - return; - } - - // Save the specific page to database - await UnifiedLayoutManager.savePageLayout(currentLayout); - - // Force re-render of the specific page with deep clone - // Create a deep clone to ensure React detects the change - const clonedLayout = JSON.parse(JSON.stringify(currentLayout)); - setLoadedPages(prev => new Map(prev).set(pageId, clonedLayout)); - }; - const addWidgetToPage = async (pageId: string, containerId: string, widgetId: string, targetColumn?: number): Promise => { - try { - const currentLayout = loadedPages.get(pageId); - if (!currentLayout) { - throw new Error(`Layout for page ${pageId} not loaded`); - } + const currentLayout = loadedPages.get(pageId); + if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`); - const widget = UnifiedLayoutManager.addWidgetToContainer(currentLayout, containerId, widgetId, targetColumn); - currentLayout.updatedAt = Date.now(); + const container = UnifiedLayoutManager.findContainer(currentLayout.containers, containerId); + if (!container) throw new Error(`Container ${containerId} not found`); - await saveLayoutToCache(pageId); + const index = UnifiedLayoutManager.calculateWidgetInsertionIndex(container, targetColumn); + const newWidget = UnifiedLayoutManager.createWidgetInstance(widgetId); - return widget; - } catch (error) { - console.error(`Failed to add widget to page ${pageId}:`, error); - throw error; - } + // Command takes the resolved index + const command = new AddWidgetCommand(pageId, containerId, newWidget, index); + + await historyManager.execute(command, { + pageId, + layouts: loadedPages, + updateLayout: updateLayoutCallback + }); + + updateHistoryState(); + + return newWidget; }; const removeWidgetFromPage = async (pageId: string, widgetInstanceId: string) => { - try { - const currentLayout = loadedPages.get(pageId); - if (!currentLayout) { - throw new Error(`Layout for page ${pageId} not loaded`); - } + const command = new RemoveWidgetCommand(pageId, widgetInstanceId); - const removed = UnifiedLayoutManager.removeWidgetFromContainer(currentLayout, widgetInstanceId); - if (!removed) { - throw new Error(`Widget ${widgetInstanceId} not found`); - } - - currentLayout.updatedAt = Date.now(); - - await saveLayoutToCache(pageId); - } catch (error) { - console.error(`Failed to remove widget from page ${pageId}:`, error); - throw error; - } + await historyManager.execute(command, { + pageId, + layouts: loadedPages, + updateLayout: updateLayoutCallback + }); + updateHistoryState(); }; + // --- Legacy / Non-Command Implementation for now (Todo: migrate) --- + const moveWidgetInPage = async (pageId: string, widgetInstanceId: string, direction: 'up' | 'down' | 'left' | 'right') => { try { const currentLayout = loadedPages.get(pageId); - if (!currentLayout) { - throw new Error(`Layout for page ${pageId} not loaded`); + if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`); + + const newLayout = JSON.parse(JSON.stringify(currentLayout)) as PageLayout; + const success = UnifiedLayoutManager.moveWidgetInContainer(newLayout, widgetInstanceId, direction); + + if (success) { + setLoadedPages(prev => new Map(prev).set(pageId, newLayout)); + await saveLayoutToCache(pageId, newLayout); } - - const moved = UnifiedLayoutManager.moveWidgetInContainer(currentLayout, widgetInstanceId, direction); - if (!moved) { - throw new Error(`Failed to move widget ${widgetInstanceId}`); - } - - currentLayout.updatedAt = Date.now(); - - await saveLayoutToCache(pageId); } catch (error) { - console.error(`Failed to move widget in page ${pageId}:`, error); + console.error('Failed to move widget:', error); throw error; } }; @@ -203,20 +230,17 @@ export const LayoutProvider: React.FC = ({ children }) => { const updatePageContainerColumns = async (pageId: string, containerId: string, columns: number) => { try { const currentLayout = loadedPages.get(pageId); - if (!currentLayout) { - throw new Error(`Layout for page ${pageId} not loaded`); + if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`); + + const newLayout = JSON.parse(JSON.stringify(currentLayout)) as PageLayout; + const success = UnifiedLayoutManager.updateContainerColumns(newLayout, containerId, columns); + + if (success) { + setLoadedPages(prev => new Map(prev).set(pageId, newLayout)); + await saveLayoutToCache(pageId, newLayout); } - - const updated = UnifiedLayoutManager.updateContainerColumns(currentLayout, containerId, columns); - if (!updated) { - throw new Error(`Container ${containerId} not found`); - } - - currentLayout.updatedAt = Date.now(); - - await saveLayoutToCache(pageId); } catch (error) { - console.error(`Failed to update container columns in page ${pageId}:`, error); + console.error('Failed to update columns:', error); throw error; } }; @@ -224,39 +248,35 @@ export const LayoutProvider: React.FC = ({ children }) => { const updatePageContainerSettings = async (pageId: string, containerId: string, settings: Partial) => { try { const currentLayout = loadedPages.get(pageId); - if (!currentLayout) { - throw new Error(`Layout for page ${pageId} not loaded`); + if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`); + + const newLayout = JSON.parse(JSON.stringify(currentLayout)) as PageLayout; + const success = UnifiedLayoutManager.updateContainerSettings(newLayout, containerId, settings); + + if (success) { + setLoadedPages(prev => new Map(prev).set(pageId, newLayout)); + await saveLayoutToCache(pageId, newLayout); } - - const updated = UnifiedLayoutManager.updateContainerSettings(currentLayout, containerId, settings); - if (!updated) { - throw new Error(`Container ${containerId} not found`); - } - - currentLayout.updatedAt = Date.now(); - - await saveLayoutToCache(pageId); } catch (error) { - console.error(`Failed to update container settings in page ${pageId}:`, error); + console.error('Failed to update container settings:', error); throw error; } }; - const addPageContainer = async (pageId: string, parentContainerId?: string): Promise => { + const addPageContainer = async (pageId: string, parentContainerId?: string) => { try { const currentLayout = loadedPages.get(pageId); - if (!currentLayout) { - throw new Error(`Layout for page ${pageId} not loaded`); - } + if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`); - const container = UnifiedLayoutManager.addContainer(currentLayout, parentContainerId); - currentLayout.updatedAt = Date.now(); + const newLayout = JSON.parse(JSON.stringify(currentLayout)) as PageLayout; + const newContainer = UnifiedLayoutManager.addContainer(newLayout, parentContainerId); // Mutates newLayout AND returns container - await saveLayoutToCache(pageId); + setLoadedPages(prev => new Map(prev).set(pageId, newLayout)); + await saveLayoutToCache(pageId, newLayout); - return container; + return newContainer; } catch (error) { - console.error(`Failed to add container to page ${pageId}:`, error); + console.error('Failed to add container:', error); throw error; } }; @@ -264,23 +284,17 @@ export const LayoutProvider: React.FC = ({ children }) => { const removePageContainer = async (pageId: string, containerId: string) => { try { const currentLayout = loadedPages.get(pageId); - if (!currentLayout) { - throw new Error(`Layout for page ${pageId} not loaded`); + if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`); + + const newLayout = JSON.parse(JSON.stringify(currentLayout)) as PageLayout; + const success = UnifiedLayoutManager.removeContainer(newLayout, containerId); + + if (success) { + setLoadedPages(prev => new Map(prev).set(pageId, newLayout)); + await saveLayoutToCache(pageId, newLayout); } - - // For extension slots, allow removing the last container (to effectively remove the canvas) - const isExtensionSlot = pageId.includes('-slot-'); - - const removed = UnifiedLayoutManager.removeContainer(currentLayout, containerId, isExtensionSlot); - if (!removed) { - throw new Error(`Container ${containerId} not found or cannot be removed`); - } - - currentLayout.updatedAt = Date.now(); - - await saveLayoutToCache(pageId); } catch (error) { - console.error(`Failed to remove container from page ${pageId}:`, error); + console.error('Failed to remove container:', error); throw error; } }; @@ -288,42 +302,34 @@ export const LayoutProvider: React.FC = ({ children }) => { const movePageContainer = async (pageId: string, containerId: string, direction: 'up' | 'down') => { try { const currentLayout = loadedPages.get(pageId); - if (!currentLayout) { - throw new Error(`Layout for page ${pageId} not loaded`); + if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`); + + const newLayout = JSON.parse(JSON.stringify(currentLayout)) as PageLayout; + const success = UnifiedLayoutManager.moveRootContainer(newLayout, containerId, direction); + + if (success) { + setLoadedPages(prev => new Map(prev).set(pageId, newLayout)); + await saveLayoutToCache(pageId, newLayout); } - - const moved = UnifiedLayoutManager.moveRootContainer(currentLayout, containerId, direction); - if (!moved) { - // This can fail gracefully if the container is at the top/bottom, so no error needed. - return; - } - - currentLayout.updatedAt = Date.now(); - - await saveLayoutToCache(pageId); } catch (error) { - console.error(`Failed to move container in page ${pageId}:`, error); + console.error('Failed to move container:', error); throw error; } }; const updateWidgetProps = async (pageId: string, widgetInstanceId: string, props: Record) => { try { - const currentLayout = loadedPages.get(pageId); - if (!currentLayout) { - throw new Error(`Layout for page ${pageId} not loaded`); - } + const command = new UpdateWidgetSettingsCommand(pageId, widgetInstanceId, props); - const updated = UnifiedLayoutManager.updateWidgetProps(currentLayout, widgetInstanceId, props); - if (!updated) { - throw new Error(`Widget ${widgetInstanceId} not found`); - } + await historyManager.execute(command, { + pageId, + layouts: loadedPages, + updateLayout: updateLayoutCallback + }); - currentLayout.updatedAt = Date.now(); - - await saveLayoutToCache(pageId); + updateHistoryState(); } catch (error) { - console.error(`Failed to update widget props in page ${pageId}:`, error); + console.error('Failed to update widget props:', error); throw error; } }; @@ -331,110 +337,99 @@ export const LayoutProvider: React.FC = ({ children }) => { const renameWidget = async (pageId: string, widgetInstanceId: string, newId: string): Promise => { try { const currentLayout = loadedPages.get(pageId); - if (!currentLayout) { - throw new Error(`Layout for page ${pageId} not loaded`); + if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`); + + const newLayout = JSON.parse(JSON.stringify(currentLayout)) as PageLayout; + const success = UnifiedLayoutManager.renameWidget(newLayout, widgetInstanceId, newId); + + if (success) { + setLoadedPages(prev => new Map(prev).set(pageId, newLayout)); + await saveLayoutToCache(pageId, newLayout); + return true; } - - const success = UnifiedLayoutManager.renameWidget(currentLayout, widgetInstanceId, newId); - if (!success) { - return false; - } - - currentLayout.updatedAt = Date.now(); - - await saveLayoutToCache(pageId); - return true; + return false; } catch (error) { - console.error(`Failed to rename widget in page ${pageId}:`, error); + console.error('Failed to rename widget:', error); throw error; } }; - const exportPageLayout = async (pageId: string): Promise => { - try { - // Export directly from memory state to ensure consistency with UI - const currentLayout = loadedPages.get(pageId); - if (currentLayout) { - return JSON.stringify(currentLayout, null, 2); - } - // Fallback to ULM if not loaded (though it should be) - return await UnifiedLayoutManager.exportPageLayout(pageId); - } catch (error) { - console.error(`Failed to export page layout ${pageId}:`, error); - throw error; - } - }; - - const importPageLayout = async (pageId: string, jsonData: string): Promise => { - try { - console.log(`[LayoutContext] Importing page layout for ${pageId}...`); - const layout = await UnifiedLayoutManager.importPageLayout(pageId, jsonData); - console.log('[LayoutContext] Layout imported successfully from ULM:', layout); - // Directly update the state with the newly imported layout - setLoadedPages(prev => { - const newPages = new Map(prev); - newPages.set(pageId, layout); - console.log('[LayoutContext] Updating loadedPages state with new layout for pageId:', pageId, newPages); - return newPages; - }); - return layout; - } catch (error) { - console.error(`[LayoutContext] Failed to import page layout ${pageId}:`, error); - throw error; - } - }; - - const hydratePageLayout = (pageId: string, layout: PageLayout) => { - // Only set if not already loaded or if we want to force update (usually we want to trust the prop) - // But check timestamps? No, if we pass explicit data, we assume it's fresh. - if (!loadedPages.has(pageId)) { - setLoadedPages(prev => new Map(prev).set(pageId, layout)); - } - }; - const saveToApi = async (): Promise => { + return true; + }; + + const exportPageLayout = async (pageId: string): Promise => { + const layout = loadedPages.get(pageId); + if (!layout) throw new Error('Layout not found'); + return JSON.stringify(layout, null, 2); + }; + + const importPageLayout = async (pageId: string, jsonData: string): Promise => { try { - // Save all loaded pages to database - let allSaved = true; - for (const [pageId, layout] of loadedPages.entries()) { - try { - await UnifiedLayoutManager.savePageLayout(layout); - } catch (error) { - console.error(`Failed to save page ${pageId}:`, error); - allSaved = false; - } - } - return allSaved; - } catch (error) { - console.error('Failed to save layouts to database:', error); - return false; + const importedLayout = JSON.parse(jsonData) as PageLayout; + importedLayout.id = pageId; + + setLoadedPages(prev => new Map(prev).set(pageId, importedLayout)); + await UnifiedLayoutManager.savePageLayout(importedLayout); + + historyManager.clear(); + updateHistoryState(); + + return importedLayout; + } catch (e) { + console.error('Failed to import layout:', e); + throw e; } }; - const value: LayoutContextType = { - loadPageLayout, - getLoadedPageLayout, - clearPageLayout, - addWidgetToPage, - removeWidgetFromPage, - moveWidgetInPage, - updatePageContainerColumns, - updatePageContainerSettings, - addPageContainer, - removePageContainer, - movePageContainer, - updateWidgetProps, - exportPageLayout, - importPageLayout, - hydratePageLayout, - saveToApi, - isLoading, - loadedPages, - renameWidget, + const hydratePageLayout = (pageId: string, layout: PageLayout) => { + setLoadedPages(prev => new Map(prev).set(pageId, layout)); + }; + + const undo = async () => { + await historyManager.undo({ + pageId: '', + layouts: loadedPages, + updateLayout: updateLayoutCallback + }); + updateHistoryState(); + }; + + const redo = async () => { + await historyManager.redo({ + pageId: '', + layouts: loadedPages, + updateLayout: updateLayoutCallback + }); + updateHistoryState(); }; return ( - + {children} ); diff --git a/packages/ui/src/contexts/StreamContext.tsx b/packages/ui/src/contexts/StreamContext.tsx new file mode 100644 index 00000000..12a39f42 --- /dev/null +++ b/packages/ui/src/contexts/StreamContext.tsx @@ -0,0 +1,122 @@ +import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react'; +import { AppEvent } from '../types-server'; +import logger from '@/Logger'; + +type StreamStatus = 'DISCONNECTED' | 'CONNECTING' | 'CONNECTED' | 'ERROR'; + +interface StreamContextType { + status: StreamStatus; + lastEvent: AppEvent | null; + isConnected: boolean; + subscribe: (callback: (event: AppEvent) => void) => () => void; +} + +const StreamContext = createContext(undefined); + +export const useStream = () => { + const context = useContext(StreamContext); + if (context === undefined) { + throw new Error('useStream must be used within a StreamProvider'); + } + return context; +}; + +interface StreamProviderProps { + children: ReactNode; + url?: string; +} + +export const StreamProvider: React.FC = ({ children, url }) => { + const [status, setStatus] = useState('DISCONNECTED'); + const [lastEvent, setLastEvent] = useState(null); + const listenersRef = React.useRef void>>(new Set()); + + const subscribe = React.useCallback((callback: (event: AppEvent) => void) => { + listenersRef.current.add(callback); + return () => listenersRef.current.delete(callback); + }, []); + + useEffect(() => { + if (!url) return; + + let eventSource: EventSource | null = null; + let reconnectTimer: NodeJS.Timeout | null = null; + + const connect = () => { + setStatus('CONNECTING'); + + // Append /api/stream if not present, handling trailing slashes + const baseUrl = url.replace(/\/+$/, ''); + const streamUrl = `${baseUrl}/api/stream`; + + try { + eventSource = new EventSource(streamUrl); + + eventSource.onopen = () => { + setStatus('CONNECTED'); + // Clear any pending reconnect + if (reconnectTimer) clearTimeout(reconnectTimer); + }; + + eventSource.onerror = (err) => { + setStatus('ERROR'); + eventSource?.close(); + // Auto-reconnect after 5s + reconnectTimer = setTimeout(() => { + connect(); + }, 10000); + }; + + // Listen for 'connected' event (handshake) + eventSource.addEventListener('connected', (e) => { + const data = JSON.parse(e.data); + }); + + // Listen for specific event types + // We listen to the generic 'app-update' if the server sends it, + // OR specific kinds like 'cache', 'system' if the server sends named events. + // The server implementation sends: event: event.kind ('cache', 'system', etc.) + + const handleEvent = (e: MessageEvent) => { + try { + const eventData: AppEvent = JSON.parse(e.data); + + // 1. Notify listeners (synchronously/immediately) + listenersRef.current.forEach(listener => listener(eventData)); + + // 2. Update state (subject to React batching, useful for debug/simple UI) + setLastEvent(eventData); + console.log('Stream event received', eventData); + // logger.debug('Stream event received', eventData); + } catch (err) { + logger.error('Failed to parse stream event', err); + } + }; + + // Add listeners for known event kinds + eventSource.addEventListener('cache', handleEvent); + eventSource.addEventListener('system', handleEvent); + eventSource.addEventListener('chat', handleEvent); + eventSource.addEventListener('other', handleEvent); + + } catch (err) { + logger.error('Failed to initialize EventSource', err); + setStatus('ERROR'); + } + }; + + connect(); + + return () => { + logger.info('Closing EventStream'); + eventSource?.close(); + if (reconnectTimer) clearTimeout(reconnectTimer); + }; + }, [url]); + + return ( + + {children} + + ); +}; diff --git a/packages/ui/src/lib/db.ts b/packages/ui/src/lib/db.ts index d0e36298..9d2105cc 100644 --- a/packages/ui/src/lib/db.ts +++ b/packages/ui/src/lib/db.ts @@ -100,6 +100,28 @@ export const invalidateCache = (key: string) => { } }; +export const invalidateServerCache = async (types: string[]) => { + try { + const { supabase } = await import("@/integrations/supabase/client"); + const session = await supabase.auth.getSession(); + const token = session.data.session?.access_token; + if (!token) return; + + const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin; + + await fetch(`${baseUrl}/api/cache/invalidate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ types }) + }); + } catch (e) { + console.error('Failed to invalidate server cache:', e); + } +}; + export const fetchPostById = async (id: string, client?: SupabaseClient) => { // Use API-mediated fetching instead of direct Supabase calls // This returns enriched FeedPost data including category_paths, author info, etc. @@ -305,6 +327,9 @@ export const updatePostDetails = async (postId: string, updates: { title: string if (requestCache.has(cacheKey)) requestCache.delete(cacheKey); const fullCacheKey = `full-post-${postId}`; if (requestCache.has(fullCacheKey)) requestCache.delete(fullCacheKey); + + // Invalidate Server Cache + await invalidateServerCache(['posts']); }; export const unlinkPictures = async (ids: string[], client?: SupabaseClient) => { diff --git a/packages/ui/src/lib/page-commands/HistoryManager.ts b/packages/ui/src/lib/page-commands/HistoryManager.ts new file mode 100644 index 00000000..d586c5f9 --- /dev/null +++ b/packages/ui/src/lib/page-commands/HistoryManager.ts @@ -0,0 +1,55 @@ +import { Command, CommandContext } from './types'; + +export class HistoryManager { + private past: Command[] = []; + private future: Command[] = []; + private context: CommandContext; + + constructor(context: CommandContext) { + this.context = context; + } + + public async execute(command: Command, context?: CommandContext): Promise { + if (context) this.context = context; + await command.execute(this.context); + this.past.push(command); + this.future = []; // Clear redo stack on new action + } + + public async undo(context?: CommandContext): Promise { + if (this.past.length === 0) return; + + if (context) this.context = context; + + const command = this.past.pop(); + if (command) { + await command.undo(this.context); + this.future.push(command); + } + } + + public async redo(context?: CommandContext): Promise { + if (this.future.length === 0) return; + + if (context) this.context = context; + + const command = this.future.pop(); + if (command) { + await command.execute(this.context); + this.past.push(command); + } + } + + public canUndo(): boolean { + return this.past.length > 0; + } + + public canRedo(): boolean { + return this.future.length > 0; + } + + public clear(): void { + this.past = []; + this.future = []; + } +} diff --git a/packages/ui/src/lib/page-commands/commands.ts b/packages/ui/src/lib/page-commands/commands.ts new file mode 100644 index 00000000..93f33b11 --- /dev/null +++ b/packages/ui/src/lib/page-commands/commands.ts @@ -0,0 +1,246 @@ +import { Command, CommandContext } from './types'; +import { WidgetInstance, PageLayout, LayoutContainer } from '@/lib/unifiedLayoutManager'; + +// Helper to find a container by ID in the layout tree +const findContainer = (containers: LayoutContainer[], id: string): LayoutContainer | null => { + for (const container of containers) { + if (container.id === id) return container; + if (container.children) { + const found = findContainer(container.children, id); + if (found) return found; + } + } + return null; +}; + +// Helper to finding parent container +const findParentContainer = (containers: LayoutContainer[], childId: string): LayoutContainer | null => { + for (const container of containers) { + if (container.children.some(c => c.id === childId)) return container; + const found = findParentContainer(container.children, childId); + if (found) return found; + } + return null; +} + +// Helper to find widget location +const findWidgetLocation = (containers: LayoutContainer[], widgetId: string): { container: LayoutContainer, index: number, widget: WidgetInstance } | null => { + for (const container of containers) { + const idx = container.widgets.findIndex(w => w.id === widgetId); + if (idx !== -1) { + return { container, index: idx, widget: container.widgets[idx] }; + } + if (container.children) { + const found = findWidgetLocation(container.children, widgetId); + if (found) return found; + } + } + return null; +}; + + +// --- Add Widget Command --- +export class AddWidgetCommand implements Command { + id: string; + type = 'ADD_WIDGET'; + timestamp: number; + + private pageId: string; + private containerId: string; + private widget: WidgetInstance; + private index: number; + + constructor(pageId: string, containerId: string, widget: WidgetInstance, index: number = -1) { + this.id = crypto.randomUUID(); + this.timestamp = Date.now(); + this.pageId = pageId; + this.containerId = containerId; + this.widget = widget; + this.index = index; + } + + async execute(context: CommandContext): Promise { + const layout = context.layouts.get(this.pageId); + if (!layout) throw new Error(`Layout not found: ${this.pageId}`); + + // Clone layout to avoid mutation + const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout; + const container = findContainer(newLayout.containers, this.containerId); + + if (!container) throw new Error(`Container not found: ${this.containerId}`); + + if (this.index === -1) { + container.widgets.push(this.widget); + } else { + container.widgets.splice(this.index, 0, this.widget); + } + + context.updateLayout(this.pageId, newLayout); + } + + async undo(context: CommandContext): Promise { + const layout = context.layouts.get(this.pageId); + if (!layout) throw new Error(`Layout not found: ${this.pageId}`); + + const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout; + + // Find widget anywhere (robust against moves) + const location = findWidgetLocation(newLayout.containers, this.widget.id); + + if (location) { + location.container.widgets.splice(location.index, 1); + context.updateLayout(this.pageId, newLayout); + } else { + console.warn(`Widget ${this.widget.id} not found for undo add`); + } + } +} + +// --- Remove Widget Command --- +export class RemoveWidgetCommand implements Command { + id: string; + type = 'REMOVE_WIDGET'; + timestamp: number; + + private pageId: string; + private widgetId: string; + + // State capture for undo + private containerId: string | null = null; + private index: number = -1; + private widget: WidgetInstance | null = null; + + constructor(pageId: string, widgetId: string) { + this.id = crypto.randomUUID(); + this.timestamp = Date.now(); + this.pageId = pageId; + this.widgetId = widgetId; + } + + async execute(context: CommandContext): Promise { + const layout = context.layouts.get(this.pageId); + if (!layout) throw new Error(`Layout not found: ${this.pageId}`); + + // 1. Capture state BEFORE modification + const location = findWidgetLocation(layout.containers, this.widgetId); + + if (!location) { + console.warn(`Widget ${this.widgetId} not found for removal`); + return; + } + + this.containerId = location.container.id; + this.index = location.index; + this.widget = location.widget; + + // 2. Perform Removal + const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout; + const newLocation = findWidgetLocation(newLayout.containers, this.widgetId); + + if (newLocation) { + newLocation.container.widgets.splice(newLocation.index, 1); + context.updateLayout(this.pageId, newLayout); + } + } + + async undo(context: CommandContext): Promise { + if (!this.containerId || !this.widget || this.index === -1) { + throw new Error("Cannot undo remove: State was not captured correctly"); + } + + const layout = context.layouts.get(this.pageId); + if (!layout) throw new Error(`Layout not found: ${this.pageId}`); + + const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout; + const container = findContainer(newLayout.containers, this.containerId); + + if (!container) throw new Error(`Original container ${this.containerId} not found`); + + // Restore widget at original index + // Ensure index is valid + if (this.index > container.widgets.length) { + container.widgets.push(this.widget); + } else { + container.widgets.splice(this.index, 0, this.widget); + } + + context.updateLayout(this.pageId, newLayout); + } +} + +// --- Update Widget Settings Command --- +export class UpdateWidgetSettingsCommand implements Command { + id: string; + type = 'UPDATE_WIDGET_SETTINGS'; + timestamp: number; + + private pageId: string; + private widgetId: string; + private newSettings: Record; // Store full new settings or partial? Partial is what comes in. + + // State capture for undo + private oldSettings: Record | null = null; + + constructor(pageId: string, widgetId: string, newSettings: Record) { + this.id = crypto.randomUUID(); + this.timestamp = Date.now(); + this.pageId = pageId; + this.widgetId = widgetId; + this.newSettings = newSettings; + } + + async execute(context: CommandContext): Promise { + const layout = context.layouts.get(this.pageId); + if (!layout) throw new Error(`Layout not found: ${this.pageId}`); + + // 1. Capture state (snapshot of current props) BEFORE modification + // We need to find the widget in the CURRENT layout to get old props + const location = findWidgetLocation(layout.containers, this.widgetId); + + if (!location) { + console.warn(`Widget ${this.widgetId} not found for update`); + return; + } + + // Only capture oldSettings the FIRST time execute is called (or if we want to support re-execution) + // Since we create a NEW command instance for every user action, this is fine. + // But for Redo, we don't want to recapture 'oldSettings' from the *already updated* state if we were in a weird state. + // Actually, Redo implies we are going from Undo -> Redo. + // Undo restored oldSettings. So Redo will see oldSettings. + // So capturing it again is fine, OR we check if it's null. + if (!this.oldSettings) { + this.oldSettings = JSON.parse(JSON.stringify(location.widget.props || {})); + } + + // 2. Perform Update + const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout; + const newLocation = findWidgetLocation(newLayout.containers, this.widgetId); + + if (newLocation) { + // merge + newLocation.widget.props = { ...newLocation.widget.props, ...this.newSettings }; + context.updateLayout(this.pageId, newLayout); + } + } + + async undo(context: CommandContext): Promise { + if (!this.oldSettings) { + console.warn("Cannot undo update: State was not captured"); + return; + } + + const layout = context.layouts.get(this.pageId); + if (!layout) throw new Error(`Layout not found: ${this.pageId}`); + + const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout; + const location = findWidgetLocation(newLayout.containers, this.widgetId); + + if (location) { + // Restore exact old props + location.widget.props = this.oldSettings; + context.updateLayout(this.pageId, newLayout); + } else { + console.warn(`Widget ${this.widgetId} not found for undo update`); + } + } +} diff --git a/packages/ui/src/lib/page-commands/types.ts b/packages/ui/src/lib/page-commands/types.ts new file mode 100644 index 00000000..b655ffb9 --- /dev/null +++ b/packages/ui/src/lib/page-commands/types.ts @@ -0,0 +1,21 @@ +import { PageLayout, WidgetInstance, LayoutContainer } from '@/lib/unifiedLayoutManager'; + +export interface CommandContext { + pageId: string; + layouts: Map; + updateLayout: (pageId: string, layout: PageLayout) => void; +} + +export interface Command { + id: string; + type: string; + timestamp: number; + execute(context: CommandContext): Promise; + undo(context: CommandContext): Promise; +} + +export interface CommandFactory { + createAddWidgetCommand(pageId: string, containerId: string, widget: WidgetInstance, index?: number): Command; + createRemoveWidgetCommand(pageId: string, containerId: string, widget: WidgetInstance, index: number): Command; + createMoveWidgetCommand(pageId: string, sourceContainerId: string, targetContainerId: string, widgetId: string, oldIndex: number, newIndex: number): Command; +} diff --git a/packages/ui/src/lib/unifiedLayoutManager.ts b/packages/ui/src/lib/unifiedLayoutManager.ts index 2c25faa0..3d4deab1 100644 --- a/packages/ui/src/lib/unifiedLayoutManager.ts +++ b/packages/ui/src/lib/unifiedLayoutManager.ts @@ -56,6 +56,35 @@ export class UnifiedLayoutManager { return `widget-${this.generateId()}`; } + // Create a standalone widget instance (for Command pattern) + static createWidgetInstance(widgetId: string): WidgetInstance { + let defaultProps = {}; + const widgetDef = widgetRegistry.get(widgetId); + if (widgetDef && widgetDef.metadata.defaultProps) { + defaultProps = { ...widgetDef.metadata.defaultProps }; + } + + return { + id: this.generateWidgetId(), + widgetId, + props: defaultProps, + order: 0 + }; + } + + // Get all widgets in a layout (recursive) + static getAllWidgets(layout: PageLayout): WidgetInstance[] { + const widgets: WidgetInstance[] = []; + const collect = (containers: LayoutContainer[]) => { + containers.forEach(c => { + widgets.push(...c.widgets); + collect(c.children); + }); + }; + collect(layout.containers); + return widgets; + } + // Load root data from storage (database-only, no localStorage) static async loadRootData(pageId?: string): Promise { try { @@ -160,6 +189,29 @@ export class UnifiedLayoutManager { return null; } + // Calculate insertion index based on column target + static calculateWidgetInsertionIndex(container: LayoutContainer, targetColumn?: number): number { + let order = container.widgets.length; + + if (targetColumn !== undefined && targetColumn >= 0 && targetColumn < container.columns) { + const occupiedPositionsInColumn = container.widgets + .map((_, index) => index) + .filter(index => index % container.columns === targetColumn); + + let targetRow = 0; + while (occupiedPositionsInColumn.includes(targetRow * container.columns + targetColumn)) { + targetRow++; + } + + order = targetRow * container.columns + targetColumn; + + if (order > container.widgets.length) { + order = container.widgets.length; + } + } + return order; + } + // Add widget to specific container static addWidgetToContainer( layout: PageLayout, diff --git a/packages/ui/src/pages/Post/usePostActions.ts b/packages/ui/src/pages/Post/usePostActions.ts index 2fdc10e1..5515fa67 100644 --- a/packages/ui/src/pages/Post/usePostActions.ts +++ b/packages/ui/src/pages/Post/usePostActions.ts @@ -1,5 +1,6 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { invalidateServerCache } from '@/lib/db'; import { toast } from "sonner"; import { supabase } from "@/integrations/supabase/client"; import { MediaItem, PostItem, User } from "@/types"; @@ -47,6 +48,7 @@ export const usePostActions = ({ if (error) throw error; toast.success(translate('Post deleted')); + await invalidateServerCache(['posts']); navigate('/'); } catch (error) { console.error('Error deleting post:', error); @@ -163,6 +165,7 @@ export const usePostActions = ({ toast.success(translate('Categories updated')); // Trigger parent refresh + await invalidateServerCache(['posts']); fetchMedia(); } catch (error) { console.error('Failed to update post meta:', error); diff --git a/packages/ui/src/pages/UserPage.tsx b/packages/ui/src/pages/UserPage.tsx index 91698278..c36f6abe 100644 --- a/packages/ui/src/pages/UserPage.tsx +++ b/packages/ui/src/pages/UserPage.tsx @@ -1,17 +1,17 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, Suspense, lazy } from "react"; import { useParams, useNavigate, Link } from "react-router-dom"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { ArrowLeft, FileText, Calendar, Eye, EyeOff, Edit, Edit3, Check, X, Plus, PanelLeftClose, PanelLeftOpen, FolderTree } from "lucide-react"; +import { ArrowLeft, PanelLeftClose, PanelLeftOpen } from "lucide-react"; import { T, translate } from "@/i18n"; -import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { GenericCanvas } from "@/components/hmi/GenericCanvas"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { PageActions } from "@/components/PageActions"; +import { WidgetPropertyPanel } from "@/components/widgets/WidgetPropertyPanel"; import MarkdownRenderer from "@/components/MarkdownRenderer"; import { Sidebar } from "@/components/sidebar/Sidebar"; import { TableOfContents } from "@/components/sidebar/TableOfContents"; @@ -19,6 +19,14 @@ import { MobileTOC } from "@/components/sidebar/MobileTOC"; import { extractHeadings, extractHeadingsFromLayout, MarkdownHeading } from "@/lib/toc"; import { useLayout } from "@/contexts/LayoutContext"; import { fetchUserPage, invalidateUserPageCache } from "@/lib/db"; +import { UserPageTopBar } from "@/components/user-page/UserPageTopBar"; +import { UserPageDetails } from "@/components/user-page/UserPageDetails"; +import { useLayouts } from "@/hooks/useLayouts"; +import { Database } from "@/integrations/supabase/types"; + +const PageRibbonBar = lazy(() => import("@/components/user-page/ribbons/PageRibbonBar")); + +type Layout = Database['public']['Tables']['layouts']['Row']; interface Page { id: string; @@ -82,18 +90,12 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia const [loading, setLoading] = useState(true); const [isEditMode, setIsEditMode] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); + const [selectedWidgetId, setSelectedWidgetId] = useState(null); + + - // Inline editing states - const [editingTitle, setEditingTitle] = useState(false); - const [editingSlug, setEditingSlug] = useState(false); - const [editingTags, setEditingTags] = useState(false); - const [titleValue, setTitleValue] = useState(""); - const [slugValue, setSlugValue] = useState(""); - const [tagsValue, setTagsValue] = useState(""); - const [slugError, setSlugError] = useState(null); - const [savingField, setSavingField] = useState(null); // TOC State const [headings, setHeadings] = useState([]); @@ -107,6 +109,111 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia const isOwner = currentUser?.id === userId; + // Template State + const [templates, setTemplates] = useState([]); + const { getLayouts } = useLayouts(); + const { importPageLayout, addWidgetToPage, addPageContainer, undo, redo, canUndo, canRedo } = useLayout(); + const [selectedContainerId, setSelectedContainerId] = useState(null); + const [editingWidgetId, setEditingWidgetId] = useState(null); + const [newlyAddedWidgetId, setNewlyAddedWidgetId] = useState(null); + + useEffect(() => { + if (isOwner && isEditMode) { + loadTemplates(); + } + }, [isOwner, isEditMode]); + + const loadTemplates = async () => { + const { data, error } = await getLayouts({ type: 'canvas' }); + if (data) { + setTemplates(data); + } + }; + + const handleAddWidget = async (widgetId: string) => { + if (!page) return; + const pageId = `page-${page.id}`; + + // Determine target container + let targetContainerId = selectedContainerId; + + if (!targetContainerId) { + // Find first container in current page (not ideal but fallback) + const layout = getLoadedPageLayout(pageId); + if (layout && layout.containers.length > 0) { + targetContainerId = layout.containers[0].id; + toast("Added to first container", { + description: "Select a container to add to a specific location", + action: { + label: "Undo", + onClick: undo + } + }); + } else { + // Create new container if none exists + try { + const newContainer = await addPageContainer(pageId); + targetContainerId = newContainer.id; + setSelectedContainerId(newContainer.id); + } catch (e) { + console.error("Failed to create container for widget", e); + return; + } + } + } + + try { + const newWidget = await addWidgetToPage(pageId, targetContainerId, widgetId); + toast.success(translate("Widget added")); + // Automatically open the settings modal for the new widget + setEditingWidgetId(newWidget.id); + setNewlyAddedWidgetId(newWidget.id); + // Clear selection so side panel doesn't open simultaneously (optional preference) + setSelectedWidgetId(null); + } catch (e) { + console.error("Failed to add widget", e); + toast.error(translate("Failed to add widget")); + } + }; + + const handleEditWidget = (widgetId: string | null) => { + // If closing, clear the newlyAddedWidgetId flag regardless of cause + // Logic for removal on cancel is handled in WidgetItem + if (widgetId === null) { + setNewlyAddedWidgetId(null); + } + setEditingWidgetId(widgetId); + }; + + const handleAddContainer = async () => { + if (!page) return; + const pageId = `page-${page.id}`; + try { + const newContainer = await addPageContainer(pageId); + setSelectedContainerId(newContainer.id); + toast.success(translate("Container added")); + } catch (e) { + console.error("Failed to add container", e); + toast.error(translate("Failed to add container")); + } + }; + + const handleLoadTemplate = async (template: Layout) => { + if (!page) return; + try { + const layoutJsonString = JSON.stringify(template.layout_json); + const pageId = `page-${page.id}`; + await importPageLayout(pageId, layoutJsonString); + toast.success(`Loaded layout: ${template.name}`); + // Refresh page content locally to reflect changes immediately if needed, + // effectively handled by context but we might want to ensure page state updates + // The GenericCanvas listens to the layout context, so it should auto-update. + } catch (e) { + console.error("Failed to load layout", e); + toast.error("Failed to load layout"); + } + }; + useEffect(() => { if (initialPage) { setLoading(false); @@ -152,6 +259,33 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia + // Keyboard Shortcuts for Undo/Redo + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore if input/textarea is focused + if (['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName)) { + return; + } + + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') { + e.preventDefault(); + if (e.shiftKey) { + if (canRedo) redo(); + } else { + if (canUndo) undo(); + } + } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') { + e.preventDefault(); + if (canRedo) redo(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [undo, redo, canUndo, canRedo]); + + // Reactive Heading Extraction // This ensures we extract headings whenever the page loads OR when specific layouts are loaded into context const { loadedPages } = useLayout(); @@ -218,152 +352,7 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia } }; - const checkSlugCollision = async (newSlug: string): Promise => { - if (newSlug === page?.slug) return false; // Same slug, no collision - try { - const { data, error } = await supabase - .from('pages') - .select('id') - .eq('slug', newSlug) - .eq('owner', userId) - .maybeSingle(); - - if (error) throw error; - return !!data; // Returns true if slug already exists - } catch (error) { - console.error('Error checking slug collision:', error); - return false; - } - }; - - const handleSaveTitle = async () => { - if (!page || !titleValue.trim()) { - toast.error(translate('Title cannot be empty')); - return; - } - - setSavingField('title'); - try { - const { error } = await supabase - .from('pages') - .update({ title: titleValue.trim(), updated_at: new Date().toISOString() }) - .eq('id', page.id) - .eq('owner', currentUser?.id); - - if (error) throw error; - - setPage({ ...page, title: titleValue.trim() }); - setEditingTitle(false); - // Invalidate cache for this page - if (userId && page.slug) invalidateUserPageCache(userId, page.slug); - toast.success(translate('Title updated')); - } catch (error) { - console.error('Error updating title:', error); - toast.error(translate('Failed to update title')); - } finally { - setSavingField(null); - } - }; - - const handleSaveSlug = async () => { - if (!page || !slugValue.trim()) { - toast.error(translate('Slug cannot be empty')); - return; - } - - // Validate slug format (lowercase, hyphens, alphanumeric) - const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; - if (!slugRegex.test(slugValue)) { - setSlugError('Slug must be lowercase, alphanumeric, and use hyphens only'); - return; - } - - setSavingField('slug'); - setSlugError(null); - - // Check for collisions - const hasCollision = await checkSlugCollision(slugValue); - if (hasCollision) { - setSlugError('This slug is already used by another page'); - setSavingField(null); - return; - } - - try { - const { error } = await supabase - .from('pages') - .update({ slug: slugValue.trim(), updated_at: new Date().toISOString() }) - .eq('id', page.id) - .eq('owner', currentUser?.id); - - if (error) throw error; - - setPage({ ...page, slug: slugValue.trim() }); - setEditingSlug(false); - toast.success(translate('Slug updated')); - - // Update URL to reflect new slug - const newPath = orgSlug - ? `/org/${orgSlug}/user/${userId}/pages/${slugValue}` - : `/user/${userId}/pages/${slugValue}`; - navigate(newPath, { replace: true }); - } catch (error) { - console.error('Error updating slug:', error); - toast.error(translate('Failed to update slug')); - } finally { - if (userId && page?.slug) invalidateUserPageCache(userId, page.slug); // Invalidate old slug - if (userId) invalidateUserPageCache(userId, slugValue.trim()); // Invalidate new slug to be safe - setSavingField(null); - } - }; - - const handleSaveTags = async () => { - if (!page) return; - - setSavingField('tags'); - try { - // Parse tags from comma-separated string - const newTags = tagsValue - .split(',') - .map(tag => tag.trim()) - .filter(tag => tag.length > 0); - - const { error } = await supabase - .from('pages') - .update({ tags: newTags.length > 0 ? newTags : null, updated_at: new Date().toISOString() }) - .eq('id', page.id) - .eq('owner', currentUser?.id); - - if (error) throw error; - - setPage({ ...page, tags: newTags.length > 0 ? newTags : null }); - setEditingTags(false); - toast.success(translate('Tags updated')); - } catch (error) { - console.error('Error updating tags:', error); - toast.error(translate('Failed to update tags')); - } finally { - if (userId && page?.slug) invalidateUserPageCache(userId, page.slug); - setSavingField(null); - } - }; - - const handleStartEditTitle = () => { - setTitleValue(page?.title || ''); - setEditingTitle(true); - }; - - const handleStartEditSlug = () => { - setSlugValue(page?.slug || ''); - setSlugError(null); - setEditingSlug(true); - }; - - const handleStartEditTags = () => { - setTagsValue(page?.tags?.join(', ') || ''); - setEditingTags(true); - }; if (loading) { return ( @@ -389,20 +378,44 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia return (
{/* Top Header (Back button) - Fixed if not embedded */} + {/* Top Header (Back button) - Fixed if not embedded */} + {/* Top Header (Back button) or Ribbon Bar - Fixed if not embedded */} {!embedded && ( -
-
- -
-
+ isEditMode && isOwner ? ( + }> + { + setIsEditMode(false); + setSelectedWidgetId(null); + setSelectedContainerId(null); + }} + onPageUpdate={handlePageUpdate} + onDelete={() => { + // Reuse delete logic if available or hoist it. + }} + onMetaUpdated={() => { + if (userId && page.slug) invalidateUserPageCache(userId, page.slug); + }} + templates={templates} + onLoadTemplate={handleLoadTemplate} + onAddWidget={handleAddWidget} + onAddContainer={handleAddContainer} + onUndo={undo} + onRedo={redo} + canUndo={canUndo} + canRedo={canRedo} + /> + + ) : ( + + ) )} {/* Main Split Layout */} @@ -411,6 +424,7 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia {/* Sidebar Left - Fixed width, independent scroll */} {(headings.length > 0 || childPages.length > 0) && (
-
- - - {/* Tags and Type */} -
-
- {!page.visible && isOwner && ( - - - Hidden - - )} - {!page.is_public && ( - - Private - - )} - - setIsEditMode(!isEditMode)} + userId={userId || ''} // Fallback if undefined, though it should be defined if loaded + orgSlug={orgSlug} onPageUpdate={handlePageUpdate} - onMetaUpdated={() => userId && page.slug && fetchUserPageData(userId, page.slug)} + onToggleEditMode={() => setIsEditMode(!isEditMode)} + onWidgetRename={setSelectedWidgetId} + templates={templates} + onLoadTemplate={handleLoadTemplate} /> -
- - - {/* Editable Tags */} - {editingTags && isOwner && isEditMode ? ( -
-
- setTagsValue(e.target.value)} - className="text-sm" - placeholder="tag1, tag2, tag3..." - onKeyDown={(e) => { - if (e.key === 'Enter') handleSaveTags(); - if (e.key === 'Escape') setEditingTags(false); - }} - autoFocus - disabled={savingField === 'tags'} + {/* Content Body */} +
+ {page.content && typeof page.content === 'string' ? ( +
+ +
+ ) : ( + -

- Separate tags with commas -

-
- - -
- ) : ( -
- {page.tags && page.tags.map((tag, index) => ( - isOwner && isEditMode && handleStartEditTags()} - > - #{tag} - - ))} - {isOwner && isEditMode && ( - )}
- )} -
- {/* Content Body */} -
- {page.content && typeof page.content === 'string' ? ( -
- + {/* Footer */} +
+
+
+ Last updated: {new Date(page.updated_at).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} +
+ {page.parent && ( + + View parent page + + )} +
- ) : ( - - )} -
- {/* Footer */} -
-
-
- Last updated: {new Date(page.updated_at).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - })} -
- {page.parent && ( - - View parent page - - )}
+ -
-
+ {/* Right Sidebar - Property Panel */} + {isEditMode && isOwner && selectedWidgetId && ( + <> + + +
+ +
+
+ + )} +
); }; diff --git a/packages/ui/src/types-server.ts b/packages/ui/src/types-server.ts index 1c4c0ff1..e6b0c6f8 100644 --- a/packages/ui/src/types-server.ts +++ b/packages/ui/src/types-server.ts @@ -168,5 +168,13 @@ export interface Author { user_id: string username: string display_name: string - avatar_url?: string + +export type EventType = 'category' | 'post' | 'page' | 'system' | string; + +export interface AppEvent { + type: EventType; + kind: 'cache' | 'system' | 'chat' | 'other'; + action: 'create' | 'update' | 'delete'; + data: any; + timestamp: number; }