From 633df15f65627ca8095128ccaa24740666cea683 Mon Sep 17 00:00:00 2001 From: Babayaga Date: Wed, 11 Feb 2026 18:38:54 +0100 Subject: [PATCH] ui:commands|fields|cats|ribbons --- packages/ui/src/actions/ActionProvider.tsx | 22 + packages/ui/src/actions/default-actions.ts | 52 ++ packages/ui/src/actions/store.ts | 58 ++ packages/ui/src/actions/types.ts | 64 ++ packages/ui/src/actions/useActions.ts | 41 + packages/ui/src/components/PageActions.tsx | 116 +-- .../ui/src/components/StreamInvalidator.tsx | 3 +- .../containers/ContainerSettingsManager.tsx | 6 +- .../ui/src/components/hmi/GenericCanvas.tsx | 20 - .../ui/src/components/hmi/LayoutContainer.tsx | 5 +- .../src/components/sidebar/HierarchyTree.tsx | 175 ++++ .../user-page/SaveTemplateDialog.tsx | 61 ++ .../components/user-page/UserPageDetails.tsx | 56 +- .../components/user-page/UserPageTopBar.tsx | 14 +- .../user-page/UserPageTypeFields.tsx | 148 ++++ .../user-page/ribbons/PageRibbonBar.tsx | 812 ++++++++++++------ .../components/widgets/CategoryManager.tsx | 48 +- .../src/components/widgets/GalleryWidget.tsx | 17 +- .../ui/src/components/widgets/IconPicker.tsx | 90 ++ .../src/components/widgets/PageCardWidget.tsx | 3 +- .../components/widgets/PagePickerDialog.tsx | 11 +- .../components/widgets/TabsPropertyEditor.tsx | 184 ++++ .../ui/src/components/widgets/TabsWidget.tsx | 152 ++++ .../widgets/WidgetPropertiesForm.tsx | 101 ++- packages/ui/src/contexts/LayoutContext.tsx | 643 +++++++------- packages/ui/src/lib/db.ts | 313 ++++--- packages/ui/src/lib/layoutStorage.ts | 82 +- packages/ui/src/lib/openai.ts | 2 +- packages/ui/src/lib/page-commands/commands.ts | 494 ++++++++++- packages/ui/src/lib/page-commands/types.ts | 2 + packages/ui/src/lib/registerWidgets.ts | 89 ++ packages/ui/src/lib/schema-utils.ts | 110 +++ packages/ui/src/lib/unifiedLayoutManager.ts | 42 +- .../renderers/components/CompactFilmStrip.tsx | 55 +- .../components/CompactMediaViewer.tsx | 17 +- .../Post/renderers/components/Gallery.tsx | 53 +- .../renderers/components/SpyGlassImage.tsx | 129 +++ packages/ui/src/pages/UserPage.tsx | 267 ++---- packages/ui/src/pages/UserPageEdit.tsx | 646 ++++++++++++++ 39 files changed, 4051 insertions(+), 1152 deletions(-) create mode 100644 packages/ui/src/actions/ActionProvider.tsx create mode 100644 packages/ui/src/actions/default-actions.ts create mode 100644 packages/ui/src/actions/store.ts create mode 100644 packages/ui/src/actions/types.ts create mode 100644 packages/ui/src/actions/useActions.ts create mode 100644 packages/ui/src/components/sidebar/HierarchyTree.tsx create mode 100644 packages/ui/src/components/user-page/SaveTemplateDialog.tsx create mode 100644 packages/ui/src/components/user-page/UserPageTypeFields.tsx create mode 100644 packages/ui/src/components/widgets/IconPicker.tsx create mode 100644 packages/ui/src/components/widgets/TabsPropertyEditor.tsx create mode 100644 packages/ui/src/components/widgets/TabsWidget.tsx create mode 100644 packages/ui/src/lib/schema-utils.ts create mode 100644 packages/ui/src/pages/Post/renderers/components/SpyGlassImage.tsx create mode 100644 packages/ui/src/pages/UserPageEdit.tsx diff --git a/packages/ui/src/actions/ActionProvider.tsx b/packages/ui/src/actions/ActionProvider.tsx new file mode 100644 index 00000000..7a2f48d5 --- /dev/null +++ b/packages/ui/src/actions/ActionProvider.tsx @@ -0,0 +1,22 @@ +import React, { useEffect } from 'react'; +import { useActions } from './useActions'; +import { createDefaultActions } from './default-actions'; + +interface ActionProviderProps { + children: React.ReactNode; +} + +export const ActionProvider: React.FC = ({ children }) => { + const { registerAction } = useActions(); + + useEffect(() => { + const defaults = createDefaultActions(); + defaults.forEach(action => { + registerAction(action); + }); + }, [registerAction]); + + // TODO: Add keyboard shortcut listener here + + return <>{children}; +}; diff --git a/packages/ui/src/actions/default-actions.ts b/packages/ui/src/actions/default-actions.ts new file mode 100644 index 00000000..16393424 --- /dev/null +++ b/packages/ui/src/actions/default-actions.ts @@ -0,0 +1,52 @@ +import { Action } from './types'; +import { Undo2, Redo2, MousePointer2, X } from 'lucide-react'; + +export const UNDO_ACTION_ID = 'Edit/Undo'; +export const REDO_ACTION_ID = 'Edit/Redo'; +export const FINISH_ACTION_ID = 'File/Finish'; +export const CANCEL_ACTION_ID = 'File/Cancel'; + +export const createDefaultActions = (): Action[] => [ + { + id: UNDO_ACTION_ID, + label: 'Undo', + icon: Undo2, + group: 'History', + shortcut: 'ctrl+z', + visibilities: { + Ribbon: true + }, + handler: () => { console.warn('Undo action not implemented') } + }, + { + id: REDO_ACTION_ID, + label: 'Redo', + icon: Redo2, + group: 'History', + shortcut: 'ctrl+y', + visibilities: { + Ribbon: true + }, + handler: () => { console.warn('Redo action not implemented') } + }, + { + id: FINISH_ACTION_ID, + label: 'Finish', + icon: MousePointer2, + group: 'Exit', + visibilities: { + Ribbon: true + }, + handler: () => { console.warn('Finish action not implemented') } + }, + { + id: CANCEL_ACTION_ID, + label: 'Cancel', + icon: X, + group: 'Exit', + visibilities: { + Ribbon: true + }, + handler: () => { console.warn('Cancel action not implemented') } + } +]; diff --git a/packages/ui/src/actions/store.ts b/packages/ui/src/actions/store.ts new file mode 100644 index 00000000..09fd4ed3 --- /dev/null +++ b/packages/ui/src/actions/store.ts @@ -0,0 +1,58 @@ +import { create } from 'zustand'; +import { Action, ActionState } from './types'; + +interface ActionStore extends ActionState { + // Internal state +} + +export const useActionStore = create((set, get) => ({ + actions: {}, + + registerAction: (action: Action) => set((state) => { + // Prevent duplicate registration if not needed, or overwrite + // For now, we overwrite based on ID + if (state.actions[action.id]) { + console.warn(`Action with id ${action.id} already exists. Overwriting.`); + } + return { + actions: { + ...state.actions, + [action.id]: action + } + }; + }), + + unregisterAction: (actionId: string) => set((state) => { + const newActions = { ...state.actions }; + delete newActions[actionId]; + return { actions: newActions }; + }), + + updateAction: (actionId: string, updates: Partial) => set((state) => { + const action = state.actions[actionId]; + if (!action) { + console.warn(`Action with id ${actionId} not found.`); + return {}; + } + return { + actions: { + ...state.actions, + [actionId]: { ...action, ...updates } + } + }; + }), + + getAction: (actionId: string) => { + return get().actions[actionId]; + }, + + getActionsByGroup: (group: string) => { + const actions = get().actions; + return Object.values(actions).filter(a => a.group === group); + }, + + getActionsByPath: (path: string) => { + const actions = get().actions; + return Object.values(actions).filter(a => a.id.startsWith(path)); + } +})); diff --git a/packages/ui/src/actions/types.ts b/packages/ui/src/actions/types.ts new file mode 100644 index 00000000..3a8450cd --- /dev/null +++ b/packages/ui/src/actions/types.ts @@ -0,0 +1,64 @@ +import { ReactNode } from 'react'; + +export type ActionVisibility = 'Ribbon' | 'Command' | 'ContextMenu' | 'Toolbar'; + +export interface Action { + /** + * Unique identifier for the action, typically the command path (e.g., "File/Edit/Undo") + */ + id: string; + /** + * Readable label for the action + */ + label: string; + /** + * Icon for the action (Lucide icon component or string name) + */ + icon?: any; + /** + * Group for organizing actions in UI (e.g., "History", "File") + */ + group?: string; + /** + * The handler function to execute when the action is triggered + */ + handler: (context?: any) => void | Promise; + /** + * Keyboard shortcut (e.g., "ctrl+z") + */ + shortcut?: string; + /** + * Whether the action is currently disabled + */ + disabled?: boolean; + /** + * Whether the action is currently visible + */ + visible?: boolean; + /** + * Specific visibility settings for different UI contexts + */ + visibilities?: Partial>; + /** + * Tooltip text + */ + tooltip?: string; + /** + * Any additional metadata + */ + metadata?: Record; + /** + * Parent action ID if this is a sub-action + */ + parentId?: string; +} + +export type ActionState = { + actions: Record; + registerAction: (action: Action) => void; + unregisterAction: (actionId: string) => void; + updateAction: (actionId: string, updates: Partial) => void; + getAction: (actionId: string) => Action | undefined; + getActionsByGroup: (group: string) => Action[]; + getActionsByPath: (path: string) => Action[]; +} diff --git a/packages/ui/src/actions/useActions.ts b/packages/ui/src/actions/useActions.ts new file mode 100644 index 00000000..6e87f266 --- /dev/null +++ b/packages/ui/src/actions/useActions.ts @@ -0,0 +1,41 @@ +import { useActionStore } from './store'; +import { Action } from './types'; +import { useCallback } from 'react'; + +/** + * Hook to interact with the Action System + */ +export const useActions = () => { + const registerAction = useActionStore(state => state.registerAction); + const unregisterAction = useActionStore(state => state.unregisterAction); + const updateAction = useActionStore(state => state.updateAction); + const getAction = useActionStore(state => state.getAction); + const actions = useActionStore(state => state.actions); + + const getActionsByGroup = useCallback((group: string) => { + return Object.values(actions).filter(a => a.group === group); + }, [actions]); + + const executeAction = useCallback(async (actionId: string, context?: any) => { + const action = actions[actionId]; + if (action && !action.disabled && action.handler) { + try { + await action.handler(context); + } catch (error) { + console.error(`Error executing action ${actionId}:`, error); + } + } else { + console.warn(`Action ${actionId} not found or disabled.`); + } + }, [actions]); + + return { + actions, + registerAction, + unregisterAction, + updateAction, + getAction, + getActionsByGroup, + executeAction + }; +}; diff --git a/packages/ui/src/components/PageActions.tsx b/packages/ui/src/components/PageActions.tsx index 9f3459fa..eee819c2 100644 --- a/packages/ui/src/components/PageActions.tsx +++ b/packages/ui/src/components/PageActions.tsx @@ -1,8 +1,7 @@ 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, LayoutTemplate } from "lucide-react"; +import { Eye, EyeOff, Edit3, Trash2, Share2, Link as LinkIcon, FileText, Download, FolderTree, FileJson, LayoutTemplate } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, @@ -13,8 +12,6 @@ import { 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'; @@ -61,74 +58,19 @@ export const PageActions = ({ onLoadTemplate }: PageActionsProps) => { const [loading, setLoading] = useState(false); - const [showPagePicker, setShowPagePicker] = useState(false); - const [showCreationWizard, setShowCreationWizard] = useState(false); const [showCategoryManager, setShowCategoryManager] = useState(false); 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; - - // Invalidate API and HTML routes - // API: /api/user-page/USER_ID/SLUG - // HTML: /user/USER_ID/pages/SLUG - 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 handleParentUpdate = async (parentId: string | null) => { - if (loading) return; - setLoading(true); - - try { - const { error } = await supabase - .from('pages') - .update({ parent: parentId }) - .eq('id', page.id); - - if (error) throw error; - - onPageUpdate({ ...page, parent: parentId }); - toast.success(translate('Page parent updated')); - invalidatePageCache(); - } catch (error) { - console.error('Error updating page parent:', error); - toast.error(translate('Failed to update page parent')); - } finally { - setLoading(false); - } - }; - const handleMetaUpdate = async (newMeta: any) => { // Update local state immediately for responsive UI onPageUpdate({ ...page, meta: newMeta }); - // Persist to database try { const { updatePageMeta } = await import('@/lib/db'); await updatePageMeta(page.id, newMeta); - invalidatePageCache(); - // Trigger parent refresh to get updated category_paths if (onMetaUpdated) { onMetaUpdated(); @@ -145,12 +87,8 @@ export const PageActions = ({ setLoading(true); try { - const { error } = await supabase - .from('pages') - .update({ visible: !page.visible }) - .eq('id', page.id); - - if (error) throw error; + const { updatePage } = await import('@/lib/db'); + await updatePage(page.id, { visible: !page.visible }); onPageUpdate({ ...page, visible: !page.visible }); toast.success(translate(page.visible ? 'Page hidden' : 'Page made visible')); @@ -169,16 +107,10 @@ export const PageActions = ({ setLoading(true); try { - const { error } = await supabase - .from('pages') - .update({ is_public: !page.is_public }) - .eq('id', page.id); - - if (error) throw error; - + const { updatePage } = await import('@/lib/db'); + await updatePage(page.id, { is_public: !page.is_public }); 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')); @@ -214,8 +146,6 @@ export const PageActions = ({ if (typeof content === 'string') return content; let markdown = ''; - const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin; - try { // Determine content root // Some versions might have content directly, others wrapped in { pages: { [id]: { containers: ... } } } @@ -482,6 +412,7 @@ draft: ${!page.visible} size="sm" onClick={(e) => { e.stopPropagation(); onToggleEditMode(); }} className={isEditMode ? "bg-primary text-white" : ""} + data-testid="edit-mode-toggle" > {isEditMode ? ( <> @@ -546,42 +477,7 @@ draft: ${!page.visible} defaultMetaType="pages" /> - {/* Legacy/Standard Parent Picker - Keeping relevant as "Page Hierarchy" vs "Category Taxonomy" */} - - setShowPagePicker(false)} - onSelect={handleParentUpdate} - currentValue={page.parent} - forbiddenIds={[page.id]} - /> - - - - setShowCreationWizard(false)} - parentId={page.id} - /> {/* Dev Mode: Dump JSON */} {import.meta.env.DEV && ( diff --git a/packages/ui/src/components/StreamInvalidator.tsx b/packages/ui/src/components/StreamInvalidator.tsx index 30721208..c859a0d1 100644 --- a/packages/ui/src/components/StreamInvalidator.tsx +++ b/packages/ui/src/components/StreamInvalidator.tsx @@ -24,11 +24,10 @@ export const StreamInvalidator = () => { const queryKey = EVENT_TO_QUERY_KEY[event.type]; if (queryKey) { - logger.info(`[StreamInvalidator] Invalidating query: [${queryKey}] due to event: ${event.type}`); queryClient.invalidateQueries({ queryKey }); } else { // Optional: Log unhandled types if you want to verify what's missing - logger.debug(`[StreamInvalidator] Unknown event type for invalidation: ${event.type}`); + console.log(`[StreamInvalidator] Unknown event type for invalidation: ${event.type}`); } } }); diff --git a/packages/ui/src/components/containers/ContainerSettingsManager.tsx b/packages/ui/src/components/containers/ContainerSettingsManager.tsx index d5f03bb0..05707967 100644 --- a/packages/ui/src/components/containers/ContainerSettingsManager.tsx +++ b/packages/ui/src/components/containers/ContainerSettingsManager.tsx @@ -78,7 +78,7 @@ export const ContainerSettingsManager: React.FC = Configure display and behavior settings for container {containerInfo.id.split('-').pop()} ({containerInfo.columns} columns) - +
{/* Title Settings */}
@@ -92,7 +92,7 @@ export const ContainerSettingsManager: React.FC = onCheckedChange={(checked) => updateSetting('showTitle', checked)} />
- + {settings.showTitle && (
- + {settings.collapsible && (
diff --git a/packages/ui/src/components/hmi/GenericCanvas.tsx b/packages/ui/src/components/hmi/GenericCanvas.tsx index 08960952..a90c43ae 100644 --- a/packages/ui/src/components/hmi/GenericCanvas.tsx +++ b/packages/ui/src/components/hmi/GenericCanvas.tsx @@ -278,26 +278,6 @@ const GenericCanvasComponent: React.FC = ({ )} - - - -
)}
diff --git a/packages/ui/src/components/hmi/LayoutContainer.tsx b/packages/ui/src/components/hmi/LayoutContainer.tsx index 27623223..9a7da833 100644 --- a/packages/ui/src/components/hmi/LayoutContainer.tsx +++ b/packages/ui/src/components/hmi/LayoutContainer.tsx @@ -123,6 +123,7 @@ const LayoutContainerComponent: React.FC = ({ onMove={onMoveWidget} isEditing={editingWidgetId === widget.id} onEditWidget={onEditWidget} + isNew={newlyAddedWidgetId === widget.id} /> ))} @@ -522,9 +523,9 @@ const WidgetItem: React.FC = ({ const handleSettingsCancel = () => { if (isNew) { // If it's a new widget and the user cancels settings, remove it + console.log('Removing cancelled new widget:', widget.id); onRemove?.(widget.id); } - onEditWidget?.(null); // Close the settings modal }; // Handle Enabled State @@ -648,8 +649,10 @@ const WidgetItem: React.FC = ({ isOpen={!!isEditing} // coerce to boolean although it should be boolean | undefined from comparison onClose={() => onEditWidget?.(null)} widgetDefinition={widgetDefinition} + currentProps={widget.props || {}} onSave={handleSettingsSave} + onCancel={handleSettingsCancel} /> ) } diff --git a/packages/ui/src/components/sidebar/HierarchyTree.tsx b/packages/ui/src/components/sidebar/HierarchyTree.tsx new file mode 100644 index 00000000..05ac41be --- /dev/null +++ b/packages/ui/src/components/sidebar/HierarchyTree.tsx @@ -0,0 +1,175 @@ + +import React, { useState } from 'react'; +import { ChevronRight, ChevronDown, Box, LayoutGrid } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { LayoutContainer, WidgetInstance } from '@/lib/unifiedLayoutManager'; +import { widgetRegistry } from '@/lib/widgetRegistry'; + +interface HierarchyNodeProps { + label: string; + icon: any; + isSelected: boolean; + onClick: (e: React.MouseEvent) => void; + children?: React.ReactNode; + defaultExpanded?: boolean; + depth?: number; + hasChildren?: boolean; + onToggleExpand?: () => void; + isExpanded?: boolean; +} + +const TreeNode = ({ + label, + icon: Icon, + isSelected, + onClick, + children, + depth = 0, + hasChildren = false, + onToggleExpand, + isExpanded = false +}: HierarchyNodeProps) => { + + return ( +
+
+
{ + e.stopPropagation(); + onToggleExpand?.(); + }} + > + {hasChildren && ( + isExpanded ? : + )} +
+ + + {label} +
+ {hasChildren && isExpanded && ( +
{children}
+ )} +
+ ); +}; + +interface HierarchyTreeProps { + containers: LayoutContainer[]; + selectedWidgetId?: string | null; + selectedContainerId?: string | null; + onSelectWidget: (id: string) => void; + onSelectContainer: (id: string) => void; +} + +export const HierarchyTree = ({ + containers, + selectedWidgetId, + selectedContainerId, + onSelectWidget, + onSelectContainer +}: HierarchyTreeProps) => { + + // Manage expansion state locally + const [expandedNodes, setExpandedNodes] = useState>(new Set()); + + // Auto-expand all containers on load/change + React.useEffect(() => { + const allIds = new Set(); + const traverse = (items: LayoutContainer[]) => { + items.forEach(c => { + allIds.add(c.id); + if (c.children) traverse(c.children); + }); + }; + if (containers) traverse(containers); + setExpandedNodes(allIds); + }, [containers]); + + const toggleNode = (id: string) => { + const next = new Set(expandedNodes); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + setExpandedNodes(next); + }; + + // Auto-expand path to selection would be nice, but simple toggle for now + + const renderWidget = (widget: WidgetInstance, depth: number) => { + const def = widgetRegistry.get(widget.widgetId); + const name = def?.metadata.name || widget.widgetId; + const Icon = def?.metadata.icon || Box; + + return ( + { + e.stopPropagation(); + onSelectWidget(widget.id); + }} + depth={depth} + /> + ); + }; + + const renderContainer = (container: LayoutContainer, depth: number) => { + const title = container.settings?.title || `Container (${container.columns} col)`; + const hasChildren = container.widgets.length > 0 || container.children.length > 0; + const isExpanded = expandedNodes.has(container.id); + + return ( + + onSelectContainer(container.id)} + depth={depth} + hasChildren={hasChildren} + isExpanded={isExpanded} + onToggleExpand={() => toggleNode(container.id)} + > + {/* Render Content */} + {isExpanded && ( + <> + {container.widgets.map(w => renderWidget(w, depth + 1))} + {container.children.map(c => renderContainer(c, depth + 1))} + + )} + + + ); + }; + + if (!containers || containers.length === 0) { + return ( +
+ +

No layout elements

+
+ ); + } + + return ( +
+ {containers.map(c => renderContainer(c, 0))} +
+ ); +}; diff --git a/packages/ui/src/components/user-page/SaveTemplateDialog.tsx b/packages/ui/src/components/user-page/SaveTemplateDialog.tsx new file mode 100644 index 00000000..20487ee6 --- /dev/null +++ b/packages/ui/src/components/user-page/SaveTemplateDialog.tsx @@ -0,0 +1,61 @@ +import React, { useState } from 'react'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { T, translate } from '@/i18n'; + +interface SaveTemplateDialogProps { + isOpen: boolean; + onClose: () => void; + onSave: (name: string) => void; +} + +export const SaveTemplateDialog: React.FC = ({ + isOpen, + onClose, + onSave +}) => { + const [name, setName] = useState(''); + + const handleSave = () => { + if (name.trim()) { + onSave(name.trim()); + setName(''); + onClose(); + } + }; + + return ( + !open && onClose()}> + + + Save as New Template + + Enter a unique name for your new layout template. + + +
+
+ setName(e.target.value)} + placeholder={translate("Template Name")} + className="col-span-4" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSave(); + } + }} + /> +
+
+ + + + +
+
+ ); +}; diff --git a/packages/ui/src/components/user-page/UserPageDetails.tsx b/packages/ui/src/components/user-page/UserPageDetails.tsx index fd47685d..514ec4b5 100644 --- a/packages/ui/src/components/user-page/UserPageDetails.tsx +++ b/packages/ui/src/components/user-page/UserPageDetails.tsx @@ -12,6 +12,8 @@ import { FileText, Check, X, Calendar, FolderTree, EyeOff, Plus } from "lucide-react"; import { Separator } from "@/components/ui/separator"; +import { useLayout } from "@/contexts/LayoutContext"; +import { UpdatePageMetaCommand } from "@/lib/page-commands/commands"; // Interfaces mostly matching UserPage.tsx interface Page { @@ -94,6 +96,8 @@ export const UserPageDetails: React.FC = ({ const [slugError, setSlugError] = useState(null); const [savingField, setSavingField] = useState(null); + const { executeCommand } = useLayout(); + const checkSlugCollision = async (newSlug: string): Promise => { if (newSlug === page?.slug) return false; try { @@ -120,18 +124,25 @@ export const UserPageDetails: React.FC = ({ setSavingField('title'); try { - const { error } = await supabase - .from('pages') - .update({ title: titleValue.trim(), updated_at: new Date().toISOString() }) - .eq('id', page.id) - .eq('owner', userId); + const pageId = `page-${page.id}`; + const command = new UpdatePageMetaCommand( + pageId, + { title: page.title }, // Old meta + { title: titleValue.trim() }, // New meta + (meta) => { + // This callback runs on execute and undo to update local UI state if needed + // For local state 'page', we might need to merge. + // However, 'onPageUpdate' prop is what updates the parent state. + // The command calls context.updatePageMetadata which updates the context. + // But UserPageDetails relies on 'page' prop. + // We should call onPageUpdate here. + if (meta.title) onPageUpdate({ ...page, title: meta.title }); + } + ); - if (error) throw error; - - onPageUpdate({ ...page, title: titleValue.trim() }); + await executeCommand(command); setEditingTitle(false); - if (userId && page.slug) invalidateUserPageCache(userId, page.slug); - toast.success(translate('Title updated')); + toast.success(translate('Title updated (unsaved)')); } catch (error) { console.error('Error updating title:', error); toast.error(translate('Failed to update title')); @@ -163,13 +174,8 @@ export const UserPageDetails: React.FC = ({ } try { - const { error } = await supabase - .from('pages') - .update({ slug: slugValue.trim(), updated_at: new Date().toISOString() }) - .eq('id', page.id) - .eq('owner', userId); - - if (error) throw error; + const { updatePage } = await import('@/lib/db'); + await updatePage(page.id, { slug: slugValue.trim() }); onPageUpdate({ ...page, slug: slugValue.trim() }); setEditingSlug(false); @@ -199,13 +205,8 @@ export const UserPageDetails: React.FC = ({ .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', userId); - - if (error) throw error; + const { updatePage } = await import('@/lib/db'); + await updatePage(page.id, { tags: newTags.length > 0 ? newTags : null }); onPageUpdate({ ...page, tags: newTags.length > 0 ? newTags : null }); setEditingTags(false); @@ -260,12 +261,14 @@ export const UserPageDetails: React.FC = ({ }} autoFocus disabled={savingField === 'title'} + data-testid="page-title-input" /> @@ -283,6 +286,7 @@ export const UserPageDetails: React.FC = ({ className={`text-3xl font-bold mb-2 ${isOwner && isEditMode ? 'cursor-pointer hover:text-primary transition-colors' : ''}`} onClick={() => isOwner && isEditMode && handleStartEditTitle()} title={isOwner && isEditMode ? 'Click to edit title' : ''} + data-testid="page-title-display" > {page.title} @@ -433,6 +437,8 @@ export const UserPageDetails: React.FC = ({
)} - + + ); }; + diff --git a/packages/ui/src/components/user-page/UserPageTopBar.tsx b/packages/ui/src/components/user-page/UserPageTopBar.tsx index f06f3734..d55329e5 100644 --- a/packages/ui/src/components/user-page/UserPageTopBar.tsx +++ b/packages/ui/src/components/user-page/UserPageTopBar.tsx @@ -22,18 +22,8 @@ export const UserPageTopBar: React.FC = ({ if (embedded) return null; return ( -
-
- -
+
+
); }; diff --git a/packages/ui/src/components/user-page/UserPageTypeFields.tsx b/packages/ui/src/components/user-page/UserPageTypeFields.tsx new file mode 100644 index 00000000..39cff79b --- /dev/null +++ b/packages/ui/src/components/user-page/UserPageTypeFields.tsx @@ -0,0 +1,148 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import Form from '@rjsf/core'; +import validator from '@rjsf/validator-ajv8'; +import { TypeDefinition, fetchTypes } from '../types/db'; +import { generateSchemaForType, generateUiSchemaForType } from '@/lib/schema-utils'; +import { customWidgets, customTemplates } from '../types/RJSFTemplates'; +import { useQuery } from '@tanstack/react-query'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { useLayout } from '@/contexts/LayoutContext'; +import { UpdatePageMetaCommand } from '@/lib/page-commands/commands'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; + +interface UserPageTypeFieldsProps { + pageId: string; + pageMeta: any; + assignedTypes: TypeDefinition[]; // These are resolved types from the server + isEditMode: boolean; + onMetaUpdate?: (newMeta: any) => void; +} + +export const UserPageTypeFields: React.FC = ({ + pageId, + pageMeta, + assignedTypes, + isEditMode, + onMetaUpdate +}) => { + // We need all types to resolve schemas properly + const { data: allTypes = [], isLoading: typesLoading } = useQuery({ + queryKey: ['types', 'all'], + queryFn: async () => fetchTypes(), + staleTime: 1000 * 60 * 5 // 5 minutes + }); + + const { executeCommand } = useLayout(); + + // Local state for form data to allow changes before saving + // We store type values in meta.typeValues = { [typeId]: { ...data } } + const [formData, setFormData] = useState>(pageMeta?.typeValues || {}); + + // Sync from props + useEffect(() => { + if (pageMeta?.typeValues) { + setFormData(pageMeta.typeValues); + } + }, [pageMeta]); + + const handleFormChange = (typeId: string, data: any) => { + setFormData(prev => ({ + ...prev, + [typeId]: data + })); + }; + + const handleFormSubmit = async (typeId: string, data: any) => { + const newTypeValues = { + ...(pageMeta?.typeValues || {}), + [typeId]: data + }; + + const newMeta = { + ...(pageMeta || {}), + typeValues: newTypeValues + }; + + const command = new UpdatePageMetaCommand( + `page-${pageId}`, + { meta: pageMeta || {} }, + { meta: newMeta }, + (updatedData) => { + if (onMetaUpdate) onMetaUpdate(updatedData.meta); + toast.success("Fields updated"); + } + ); + + await executeCommand(command); + }; + + if (assignedTypes.length === 0) return null; + + if (typesLoading) { + return
; + } + + return ( +
+

Type Properties

+ + + {assignedTypes.map(type => { + const schema = generateSchemaForType(type.id, allTypes); + // Ensure schema is object type for form rendering + if (schema.type !== 'object') { + return null; + } + + const uiSchema = generateUiSchemaForType(type.id, allTypes); + + // Merge with type's own UI schema + const finalUiSchema = { + ...uiSchema, + ...(type.meta?.uiSchema || {}) + }; + + const typeData = formData[type.id] || {}; + + return ( + + +
+ {type.name} + {type.description && ( + + {type.description} + + )} +
+
+ +
isEditMode && handleFormChange(type.id, e.formData)} + onSubmit={(e) => handleFormSubmit(type.id, e.formData)} + readonly={!isEditMode} + className={isEditMode ? "" : "pointer-events-none opacity-80"} + > + {isEditMode ? ( +
+ +
+ ) : <>} +
+
+
+ ); + })} +
+
+ ); +}; diff --git a/packages/ui/src/components/user-page/ribbons/PageRibbonBar.tsx b/packages/ui/src/components/user-page/ribbons/PageRibbonBar.tsx index a3fd4f9d..89b3c419 100644 --- a/packages/ui/src/components/user-page/ribbons/PageRibbonBar.tsx +++ b/packages/ui/src/components/user-page/ribbons/PageRibbonBar.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useRef } from 'react'; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { supabase } from "@/integrations/supabase/client"; @@ -20,14 +20,38 @@ import { Save, Undo2, Redo2, - FileJson + FileJson, + Download, + Upload, + X, + ListTree, + Database } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { T, translate } from "@/i18n"; -import { Database } from '@/integrations/supabase/types'; +import { Database as DatabaseType } from '@/integrations/supabase/types'; import { CategoryManager } from "@/components/widgets/CategoryManager"; import { widgetRegistry } from "@/lib/widgetRegistry"; +import { PagePickerDialog } from "@/components/widgets/PagePickerDialog"; +import { PageCreationWizard } from "@/components/widgets/PageCreationWizard"; +import { useLayout } from "@/contexts/LayoutContext"; +import * as PageCommands from "@/lib/page-commands/commands"; +import { ActionProvider } from "@/actions/ActionProvider"; +import { useActions } from "@/actions/useActions"; +import { UNDO_ACTION_ID, REDO_ACTION_ID, FINISH_ACTION_ID, CANCEL_ACTION_ID } from "@/actions/default-actions"; + +const { UpdatePageParentCommand, UpdatePageMetaCommand } = PageCommands; +type Layout = DatabaseType['public']['Tables']['layouts']['Row']; -type Layout = Database['public']['Tables']['layouts']['Row']; interface Page { id: string; @@ -38,6 +62,10 @@ interface Page { owner: string; slug: string; parent: string | null; + parent_page?: { + title: string; + slug: string; + } | null; meta?: any; } @@ -50,13 +78,25 @@ interface PageRibbonBarProps { onMetaUpdated?: () => void; templates?: Layout[]; onLoadTemplate?: (template: Layout) => void; - onAddWidget?: (widgetId: string) => void; + onToggleWidget?: (widgetId: string) => void; onAddContainer?: () => void; className?: string; onUndo?: () => void; onRedo?: () => void; canUndo?: boolean; canRedo?: boolean; + onImportLayout?: () => void; + onExportLayout?: () => void; + activeWidgets?: Set; + activeTemplateId?: string; + onSaveToTemplate?: () => void; + onSaveAsNewTemplate?: () => void; + onNewLayout?: () => void; + showHierarchy?: boolean; + onToggleHierarchy?: () => void; + onToggleTypeFields?: () => void; + showTypeFields?: boolean; + hasTypeFields?: boolean; } // Ribbon UI Components @@ -124,18 +164,21 @@ const RibbonItemSmall = ({ onClick, active, iconColor = "text-foreground", - disabled = false + disabled = false, + ...props }: { icon: any, label: string, onClick?: () => void, active?: boolean, iconColor?: string, - disabled?: boolean + disabled?: boolean, + [key: string]: any }) => ( + {/* === PAGE TAB === */} + {activeTab === 'page' && ( + <> + +
+ + +
+ setShowCategoryManager(true)} + iconColor="text-yellow-600 dark:text-yellow-400" + /> + setShowPagePicker(true)} + iconColor="text-orange-500 dark:text-orange-400" + /> + setShowCreationWizard(true)} + iconColor="text-green-600 dark:text-green-500" + /> +
+ + + { + setLoading(true); + try { + await saveToApi(); + toast.success(translate('Page saved')); + } catch (e) { + console.error(e); + toast.error(translate('Failed to save')); + } finally { + setLoading(false); + } + }} + iconColor="text-blue-600 dark:text-blue-400" + /> + {onDelete && ( + + )} + + + + + + + + + + + )} + + {/* === WIDGETS TAB === */} + {activeTab === 'widgets' && (() => { + const allWidgets = widgetRegistry.getAll() + .filter(w => w.metadata.category !== 'hidden') + .sort((a, b) => a.metadata.name.localeCompare(b.metadata.name)); + + const structureWidgets = allWidgets.filter(w => + w.metadata.tags?.some(t => ['layout', 'container', 'structure'].includes(t)) + ); + + const mediaWidgets = allWidgets.filter(w => + !structureWidgets.includes(w) && + w.metadata.tags?.some(t => ['photo', 'gallery', 'image', 'video'].includes(t)) + ); + + const contentWidgets = allWidgets.filter(w => + !structureWidgets.includes(w) && + !mediaWidgets.includes(w) && + (w.metadata.category === 'display' || w.metadata.tags?.some(t => ['text', 'page', 'card', 'content'].includes(t))) + ); + + const advancedWidgets = allWidgets.filter(w => + !structureWidgets.includes(w) && + !mediaWidgets.includes(w) && + !contentWidgets.includes(w) + ); + + return ( + <> + + {/* Add Container Action */} + + {structureWidgets.map(widget => ( + onToggleWidget?.(widget.metadata.id)} + active={activeWidgets.has(widget.metadata.id)} + iconColor={activeWidgets.has(widget.metadata.id) ? "text-green-600 dark:text-green-400" : "text-blue-600 dark:text-blue-400"} + /> + ))} + + + {mediaWidgets.length > 0 && ( + + {mediaWidgets.map(widget => ( + onToggleWidget?.(widget.metadata.id)} + active={activeWidgets.has(widget.metadata.id)} + iconColor={activeWidgets.has(widget.metadata.id) ? "text-green-600 dark:text-green-400" : "text-blue-600 dark:text-blue-400"} + /> + ))} + + )} + + {contentWidgets.length > 0 && ( + + {contentWidgets.map(widget => ( + onToggleWidget?.(widget.metadata.id)} + active={activeWidgets.has(widget.metadata.id)} + iconColor={activeWidgets.has(widget.metadata.id) ? "text-green-600 dark:text-green-400" : "text-blue-600 dark:text-blue-400"} + /> + ))} + + )} + + {advancedWidgets.length > 0 && ( + + {advancedWidgets.map(widget => ( + onToggleWidget?.(widget.metadata.id)} + active={activeWidgets.has(widget.metadata.id)} + iconColor={activeWidgets.has(widget.metadata.id) ? "text-green-600 dark:text-green-400" : "text-blue-600 dark:text-blue-400"} + /> + ))} + + )} + + ); + })()} + + {/* === LAYOUTS TAB === */} + {activeTab === 'layouts' && ( + <> + + + {onSaveAsNewTemplate && ( + + )} + {activeTemplateId && onSaveToTemplate && ( + + )} + + + + {templates?.map(t => ( + onLoadTemplate?.(t)} + active={t.id === activeTemplateId} + iconColor="text-indigo-500 dark:text-indigo-400" + /> + ))} + {(!templates || templates.length === 0) && ( +
No Templates
+ )} +
+ + )} + + {/* === VIEW TAB === */} + {activeTab === 'view' && ( + <> + + + + + )} + + {/* === ADVANCED TAB === */} + {activeTab === 'advanced' && ( + <> + + + + + )} +
+ - + {/* Managed Dialogs */} + setShowCategoryManager(false)} + currentPageId={page.id} + currentPageMeta={page.meta} + onPageMetaUpdate={async (newMeta) => { + // Use UpdatePageMetaCommand for undo/redo support + const pageId = `page-${page.id}`; - {/* 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" - /> - + // Construct old meta from current page.meta + // We only need keys present in newMeta + const oldMeta: Record = {}; + Object.keys(newMeta).forEach(key => { + oldMeta[key] = page.meta?.[key] ?? null; + }); + + try { + await executeCommand(new UpdatePageMetaCommand( + pageId, + oldMeta, + newMeta, + (meta) => { + // Update local state for immediate feedback + // Note: LayoutContext also updates its pendingMetadata + onPageUpdate({ ...page, meta: { ...page.meta, ...meta } }); + if (onMetaUpdated) onMetaUpdated(); + } + )); + } catch (error) { + console.error('Failed to update page meta:', error); + toast.error(translate('Failed to update categories')); + } + }} + filterByType="pages" + defaultMetaType="pages" + /> + + setShowPagePicker(false)} + onSelect={handleParentUpdate} + currentValue={page.parent} + forbiddenIds={[page.id]} + /> + + setShowCreationWizard(false)} + parentId={page.id} + /> + + + + + {translate('Discard changes?')} + + {translate('You have unsaved changes. Are you sure you want to discard them and exit?')} + + + + {translate('Keep Editing')} + + {translate('Discard & Exit')} + + + + + + ); }; diff --git a/packages/ui/src/components/widgets/CategoryManager.tsx b/packages/ui/src/components/widgets/CategoryManager.tsx index c516fe36..a35a2bd9 100644 --- a/packages/ui/src/components/widgets/CategoryManager.tsx +++ b/packages/ui/src/components/widgets/CategoryManager.tsx @@ -5,10 +5,11 @@ import { Input } from "@/components/ui/input"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { fetchCategories, createCategory, updateCategory, deleteCategory, updatePageMeta, Category } from "@/lib/db"; +import { fetchCategories, fetchTypes, createCategory, updateCategory, deleteCategory, updatePageMeta, Category } from "@/lib/db"; import { toast } from "sonner"; import { Plus, Edit2, Trash2, FolderTree, Link as LinkIcon, Check, X, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; +import { Checkbox } from "@/components/ui/checkbox"; import { T } from "@/i18n"; interface CategoryManagerProps { @@ -61,6 +62,15 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet } }); + const { data: types = [] } = useQuery({ + queryKey: ['types', 'assignable'], + queryFn: async () => { + const allTypes = await fetchTypes(); + // Filter for structures and aliases as requested + return allTypes.filter(t => t.kind === 'structure' || t.kind === 'alias'); + } + }); + const handleCreateStart = (parentId: string | null = null) => { setIsCreating(true); setCreationParentId(parentId); @@ -310,12 +320,46 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
- setEditingCategory({ ...editingCategory, description: e.target.value })} />
+ +
+ +
+ {types.length === 0 &&
No assignable types found.
} + {types.map(type => ( +
+ { + const current = editingCategory.meta?.assignedTypes || []; + const newTypes = checked + ? [...current, type.id] + : current.filter((id: string) => id !== type.id); + + setEditingCategory({ + ...editingCategory, + meta: { + ...(editingCategory.meta || {}), + assignedTypes: newTypes + } + }); + }} + /> + +
+ ))} +
+
+ Assign types to allow using them in this category. +
+
+ + +
+ setSearch(e.target.value)} + className="h-8 text-xs" + /> + +
+ {filteredIcons.map(iconName => { + const Icon = (Icons as any)[iconName]; + if (!Icon) return null; + return ( + + ); + })} +
+ {filteredIcons.length === 0 && ( +
+ No icons found +
+ )} +
+
+
+ + ); +}; diff --git a/packages/ui/src/components/widgets/PageCardWidget.tsx b/packages/ui/src/components/widgets/PageCardWidget.tsx index 8348ff5e..26954531 100644 --- a/packages/ui/src/components/widgets/PageCardWidget.tsx +++ b/packages/ui/src/components/widgets/PageCardWidget.tsx @@ -117,7 +117,8 @@ const PageCardWidget: React.FC = ({ console.log('Like functionality not yet implemented'); }; - const handlePageSelect = (selectedPageId: string | null) => { + const handlePageSelect = (selectedPage: { id: string } | null) => { + const selectedPageId = selectedPage ? selectedPage.id : null; setPageId(selectedPageId); setShowPagePicker(false); diff --git a/packages/ui/src/components/widgets/PagePickerDialog.tsx b/packages/ui/src/components/widgets/PagePickerDialog.tsx index d394c1a9..64dcbf8b 100644 --- a/packages/ui/src/components/widgets/PagePickerDialog.tsx +++ b/packages/ui/src/components/widgets/PagePickerDialog.tsx @@ -11,7 +11,7 @@ import { fetchUserPages } from '@/lib/db'; interface PagePickerDialogProps { isOpen: boolean; onClose: () => void; - onSelect: (pageId: string | null) => void; + onSelect: (page: Page | null) => void; currentValue?: string | null; forbiddenIds?: string[]; // IDs that cannot be selected (e.g. self) } @@ -72,7 +72,12 @@ export const PagePickerDialog: React.FC = ({ }, [pages, searchQuery, forbiddenIds]); const handleConfirm = () => { - onSelect(selectedId); + if (selectedId) { + const selectedPage = pages.find(p => p.id === selectedId) || null; + onSelect(selectedPage); + } else { + onSelect(null); + } onClose(); }; @@ -140,7 +145,7 @@ export const PagePickerDialog: React.FC = ({ onClick={() => setSelectedId(page.id)} onDoubleClick={() => { setSelectedId(page.id); - onSelect(page.id); + onSelect(page); onClose(); }} className={cn( diff --git a/packages/ui/src/components/widgets/TabsPropertyEditor.tsx b/packages/ui/src/components/widgets/TabsPropertyEditor.tsx new file mode 100644 index 00000000..3594416a --- /dev/null +++ b/packages/ui/src/components/widgets/TabsPropertyEditor.tsx @@ -0,0 +1,184 @@ +import React, { useState } from 'react'; +import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core'; +import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { T } from '@/i18n'; +import { Plus, Trash2, GripVertical, Settings2 } from 'lucide-react'; +import { TabDefinition } from './TabsWidget'; + +// Local helpers since we might not have uuid/slugify +const generateId = () => Math.random().toString(36).substring(2, 9); + +const slugify = (text: string) => text.toString().toLowerCase() + .replace(/\s+/g, '-') // Replace spaces with - + .replace(/[^\w\-]+/g, '') // Remove all non-word chars + .replace(/\-\-+/g, '-') // Replace multiple - with single - + .replace(/^-+/, '') // Trim - from start of text + .replace(/-+$/, ''); // Trim - from end of text + +import { IconPicker } from './IconPicker'; + +interface TabsPropertyEditorProps { + value: TabDefinition[]; + onChange: (tabs: TabDefinition[]) => void; + widgetInstanceId: string; +} + +const SortableTabItem = ({ + tab, + onRemove, + onUpdate +}: { + tab: TabDefinition; + onRemove: (id: string) => void; + onUpdate: (id: string, updates: Partial) => void; +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ id: tab.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+
+ + + onUpdate(tab.id, { icon: newIcon })} + /> + + onUpdate(tab.id, { label: e.target.value })} + className="h-8 text-sm flex-1" + placeholder="Tab Label" + /> + +
+ {/* Optional: Icon picker or other details here */} +
+ ID: {tab.layoutId} +
+
+ ); +}; + +export const TabsPropertyEditor: React.FC = ({ + value = [], + onChange, + widgetInstanceId +}) => { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + const oldIndex = value.findIndex((item) => item.id === active.id); + const newIndex = value.findIndex((item) => item.id === over.id); + onChange(arrayMove(value, oldIndex, newIndex)); + } + }; + + const handleAddTab = () => { + const newId = generateId(); + const label = `New Tab ${value.length + 1}`; + // Pattern: tabs-- + // Note: We use a timestamp or random component in ID to ensure uniqueness + // if user renames tab to same name repeatedly. + // Actually user requested: tabs-- + // But if they rename, should layoutId change? Usually NO. Layout ID should be stable. + // So we generate it once upon creation. + // To avoid conflicts if they delete and recreate with same name, let's append a short random string or index if needed. + // But for cleaner URLs/IDs, let's try to stick to the requested format if possible, + // appending a suffix only if really needed (but here we are creating a NEW one). + + const slug = slugify(label); + const layoutId = `tabs-${widgetInstanceId}-${slug}-${newId.slice(0, 4)}`; + + const newTab: TabDefinition = { + id: newId, + label: label, + layoutId: layoutId, + icon: 'Square' // Default icon + }; + + onChange([...value, newTab]); + }; + + const handleRemoveTab = (id: string) => { + if (confirm("Are you sure? The layout for this tab will be detached (but not deleted from DB immediately).")) { + onChange(value.filter(t => t.id !== id)); + } + }; + + const handleUpdateTab = (id: string, updates: Partial) => { + onChange(value.map(t => t.id === id ? { ...t, ...updates } : t)); + }; + + return ( +
+
+ Manage your tabs below. Drag to reorder. +
+ + + t.id)} + strategy={verticalListSortingStrategy} + > +
+ {value.map((tab) => ( + + ))} +
+
+
+ + +
+ ); +}; diff --git a/packages/ui/src/components/widgets/TabsWidget.tsx b/packages/ui/src/components/widgets/TabsWidget.tsx new file mode 100644 index 00000000..3bc836f6 --- /dev/null +++ b/packages/ui/src/components/widgets/TabsWidget.tsx @@ -0,0 +1,152 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { GenericCanvas } from '@/components/hmi/GenericCanvas'; +import { cn } from '@/lib/utils'; +import { T } from '@/i18n'; +import * as LucideIcons from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +export interface TabDefinition { + id: string; + label: string; + layoutId: string; + icon?: string; +} + +interface TabsWidgetProps { + widgetInstanceId: string; + tabs?: TabDefinition[]; + activeTabId?: string; + orientation?: 'horizontal' | 'vertical'; + tabBarPosition?: 'top' | 'bottom' | 'left' | 'right'; + className?: string; // Container classes + tabBarClassName?: string; // Tab bar specific classes + contentClassName?: string; // Content area classes + isEditMode?: boolean; + onPropsChange: (props: Record) => void; +} + +const TabsWidget: React.FC = ({ + widgetInstanceId, + tabs = [], + activeTabId, + orientation = 'horizontal', + tabBarPosition = 'top', + className = '', + tabBarClassName = '', + contentClassName = '', + isEditMode = false, + onPropsChange +}) => { + const [currentTabId, setCurrentTabId] = useState(activeTabId); + + // Effect to ensure we have a valid currentTabId + useEffect(() => { + if (tabs.length > 0) { + if (!currentTabId || !tabs.find(t => t.id === currentTabId)) { + setCurrentTabId(tabs[0].id); + } + } else { + setCurrentTabId(undefined); + } + }, [tabs, currentTabId]); + + // Effect to sync prop activeTabId if it changes externally + useEffect(() => { + if (activeTabId && tabs.find(t => t.id === activeTabId)) { + setCurrentTabId(activeTabId); + } + }, [activeTabId, tabs]); + + + const handleTabClick = (tabId: string) => { + setCurrentTabId(tabId); + // Optionally persist selection? + // onPropsChange({ activeTabId: tabId }); + // Usually tabs reset on reload unless specifically desired. + // Let's keep it local state for now unless user demands persistence. + }; + + const currentTab = tabs.find(t => t.id === currentTabId); + + const renderIcon = (iconName?: string) => { + if (!iconName) return null; + const Icon = (LucideIcons as any)[iconName]; + return Icon ? : null; + }; + + const isVertical = tabBarPosition === 'left' || tabBarPosition === 'right'; + + const flexDirection = (() => { + switch (tabBarPosition) { + case 'left': return 'flex-row'; + case 'right': return 'flex-row-reverse'; + case 'bottom': return 'flex-col-reverse'; + case 'top': + default: return 'flex-col'; + } + })(); + + const tabBarClasses = cn( + "flex gap-1 overflow-auto scrollbar-hide bg-slate-100 dark:bg-slate-800/50 p-1 rounded-t-md", + isVertical ? "flex-col w-48 min-w-[12rem]" : "flex-row w-full", + tabBarClassName + ); + + const tabButtonClasses = (isActive: boolean) => cn( + "flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors whitespace-nowrap", + isActive + ? "bg-white dark:bg-slate-700 text-primary shadow-sm" + : "text-slate-600 dark:text-slate-400 hover:bg-slate-200/50 dark:hover:bg-slate-700/50", + isVertical ? "w-full justify-start" : "flex-1 justify-center" + ); + + if (tabs.length === 0) { + return ( +
+
+ +

No tabs configured.

+ {isEditMode &&

Add tabs in widget settings.

} +
+
+ ); + } + + return ( +
+ {/* Tab Bar */} +
+ {tabs.map(tab => ( + + ))} +
+ + {/* Content Area */} +
+ {currentTab ? ( + + ) : ( +
+ Select a tab +
+ )} +
+
+ ); +}; + +export default TabsWidget; diff --git a/packages/ui/src/components/widgets/WidgetPropertiesForm.tsx b/packages/ui/src/components/widgets/WidgetPropertiesForm.tsx index 15ffaeeb..acb698f7 100644 --- a/packages/ui/src/components/widgets/WidgetPropertiesForm.tsx +++ b/packages/ui/src/components/widgets/WidgetPropertiesForm.tsx @@ -7,11 +7,13 @@ import { Switch } from '@/components/ui/switch'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { WidgetDefinition } from '@/lib/widgetRegistry'; import { ImagePickerDialog } from './ImagePickerDialog'; -import { Image as ImageIcon, Maximize2 } from 'lucide-react'; +import { PagePickerDialog } from './PagePickerDialog'; +import { Image as ImageIcon, Maximize2, FileText } 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'; +import { TabsPropertyEditor } from './TabsPropertyEditor'; export interface WidgetPropertiesFormProps { widgetDefinition: WidgetDefinition; @@ -38,6 +40,8 @@ export const WidgetPropertiesForm: React.FC = ({ const [settings, setSettings] = useState>(currentProps); const [imagePickerOpen, setImagePickerOpen] = useState(false); const [imagePickerField, setImagePickerField] = useState(null); + const [pagePickerOpen, setPagePickerOpen] = useState(false); + const [pagePickerField, setPagePickerField] = useState(null); const [markdownEditorOpen, setMarkdownEditorOpen] = useState(false); const [activeMarkdownField, setActiveMarkdownField] = useState(null); @@ -189,6 +193,44 @@ export const WidgetPropertiesForm: React.FC = ({ ); + case 'pagePicker': + return ( +
+ +
+ updateSetting(key, e.target.value)} + placeholder={config.default || 'No page selected'} + className="flex-1 font-mono text-[10px] h-8" + readOnly + /> + +
+ {config.description && ( +

+ {config.description} +

+ )} +
+ ); + case 'markdown': return (
@@ -225,6 +267,45 @@ export const WidgetPropertiesForm: React.FC = ({
); + case 'classname': + return ( +
+ + updateSetting(key, newValue)} + placeholder={config.default || 'Select classes...'} + className="w-full" + /> + {config.description && ( +

+ {config.description} +

+ )} +
+ ); + + case 'tabs-editor': + return ( +
+ + updateSetting(key, newValue)} + widgetInstanceId={widgetInstanceId || 'new-widget'} + /> + {config.description && ( +

+ {config.description} +

+ )} +
+ ); + default: return null; } @@ -346,6 +427,23 @@ export const WidgetPropertiesForm: React.FC = ({ /> )} + {/* Page Picker Dialog */} + {pagePickerField && ( + { + setPagePickerOpen(false); + setPagePickerField(null); + }} + onSelect={(page) => { + updateSetting(pagePickerField, page?.id || null); + setPagePickerOpen(false); + setPagePickerField(null); + }} + currentValue={settings[pagePickerField]} + /> + )} + {/* Markdown Editor Modal */} @@ -376,3 +474,4 @@ export const WidgetPropertiesForm: React.FC = ({ ); }; + diff --git a/packages/ui/src/contexts/LayoutContext.tsx b/packages/ui/src/contexts/LayoutContext.tsx index 6052d37e..e479d4f8 100644 --- a/packages/ui/src/contexts/LayoutContext.tsx +++ b/packages/ui/src/contexts/LayoutContext.tsx @@ -1,9 +1,20 @@ 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'; +import { Command } from '@/lib/page-commands/types'; +import { + AddWidgetCommand, + RemoveWidgetCommand, + UpdateWidgetSettingsCommand, + AddContainerCommand, + RemoveContainerCommand, + MoveContainerCommand, + MoveWidgetCommand, + UpdateContainerColumnsCommand, + UpdateContainerSettingsCommand, + RenameWidgetCommand, + ReplaceLayoutCommand +} from '@/lib/page-commands/commands'; interface LayoutContextType { // Generic page management @@ -34,6 +45,8 @@ interface LayoutContextType { redo: () => Promise; canUndo: boolean; canRedo: boolean; + clearHistory: () => void; + executeCommand: (command: Command) => Promise; // State isLoading: boolean; @@ -54,343 +67,334 @@ export const LayoutProvider: React.FC = ({ children }) => { const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); + // Pending Metadata State + const [pendingMetadata, setPendingMetadata] = useState>>(new Map()); + 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)); + // UnifiedLayoutManager.savePageLayout(layout).catch(e => console.error("Failed to persist layout update", e)); + }, []); + + const updatePageMetadataCallback = useCallback((pageId: string, metadata: Record) => { + setPendingMetadata(prev => { + const newMap = new Map(prev); + const existing = newMap.get(pageId) || {}; + newMap.set(pageId, { ...existing, ...metadata }); + return newMap; + }); }, []); const [historyManager] = useState(() => new HistoryManager({ - pageId: '', // Placeholder, locally updated in execute - layouts: loadedPages, // Placeholder, updated in execute - updateLayout: updateLayoutCallback + pageId: '', + layouts: loadedPages, + updateLayout: updateLayoutCallback, + pageMetadata: pendingMetadata, + updatePageMetadata: updatePageMetadataCallback })); - // Update history UI state + // Update history manager context when state changes + useEffect(() => { + // We need to update the context inside historyManager so it has access to latest state + // but HistoryManager doesn't preserve context reference, it uses it per execute/undo/redo. + // However, for 'executeCommand' prop below, we construct a fresh context. + }, [loadedPages, pendingMetadata, updateLayoutCallback, updatePageMetadataCallback]); + + 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 () => { - try { - // Get valid widget IDs from registry - const validWidgetIds = new Set(widgetRegistry.getAll().map(w => w.metadata.id)); + updateHistoryState(); + }, [historyManager, loadedPages, pendingMetadata]); - // Only cleanup if we have widgets registered - if (validWidgetIds.size > 0) { - // Clean up all known pages - const knownPages = ['playground-layout', 'dashboard-layout', 'profile-layout']; - await Promise.all(knownPages.map(pageId => - UnifiedLayoutManager.cleanupInvalidWidgets(pageId, validWidgetIds) - )); - } - } catch (error) { - console.error('Failed to initialize layouts:', error); - } finally { - setIsLoading(false); - } - }; - - initializeLayouts(); + // Load layout + const loadPageLayout = useCallback(async (pageId: string, defaultName?: string) => { + try { + const layout = await UnifiedLayoutManager.getPageLayout(pageId, defaultName); + setLoadedPages(prev => new Map(prev).set(pageId, layout)); + } catch (e) { + console.error("Failed to load page layout", e); + } finally { + setIsLoading(false); + } }, []); - const loadPageLayout = async (pageId: string, defaultName?: string) => { - // Only load if not already cached - if (!loadedPages.has(pageId)) { - try { - const layout = await UnifiedLayoutManager.getPageLayout(pageId, defaultName); - setLoadedPages(prev => new Map(prev).set(pageId, layout)); - } catch (error) { - console.error(`Failed to load page layout ${pageId}:`, error); - } - } - }; - - const getLoadedPageLayout = (pageId: string): PageLayout | null => { + const getLoadedPageLayout = useCallback((pageId: string) => { return loadedPages.get(pageId) || null; - }; + }, [loadedPages]); - const clearPageLayout = async (pageId: string) => { - try { - const currentLayout = loadedPages.get(pageId); - if (!currentLayout) { - throw new Error(`Layout for page ${pageId} not loaded`); - } - - // Create a fresh empty layout with one empty container - const clearedLayout: PageLayout = { - id: pageId, - name: currentLayout.name, - containers: [ - { - id: crypto.randomUUID(), - type: 'container', // Using 'container' as per ULM default, was 'grid' in previous context but 'container' in ULM - columns: 1, - gap: 16, // ULM default - widgets: [], - children: [], - order: 0 - } - ], - // Preserve created if exists - createdAt: currentLayout.createdAt, - updatedAt: Date.now() - }; - - setLoadedPages(prev => new Map(prev).set(pageId, clearedLayout)); - - // 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 layout:', error); - throw error; - } - }; - - const addWidgetToPage = async (pageId: string, containerId: string, widgetId: string, targetColumn?: number): Promise => { - const currentLayout = loadedPages.get(pageId); - if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`); - - const container = UnifiedLayoutManager.findContainer(currentLayout.containers, containerId); - if (!container) throw new Error(`Container ${containerId} not found`); - - const index = UnifiedLayoutManager.calculateWidgetInsertionIndex(container, targetColumn); - const newWidget = UnifiedLayoutManager.createWidgetInstance(widgetId); - - // Command takes the resolved index - const command = new AddWidgetCommand(pageId, containerId, newWidget, index); - - await historyManager.execute(command, { - pageId, - layouts: loadedPages, - updateLayout: updateLayoutCallback + const clearPageLayout = useCallback(async (pageId: string) => { + setLoadedPages(prev => { + const next = new Map(prev); + next.delete(pageId); + return next; }); + }, []); - updateHistoryState(); - - return newWidget; - }; - - const removeWidgetFromPage = async (pageId: string, widgetInstanceId: string) => { - const command = new RemoveWidgetCommand(pageId, widgetInstanceId); - - 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`); - - 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); - } - } catch (error) { - console.error('Failed to move widget:', error); - throw error; - } - }; - - 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`); - - 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); - } - } catch (error) { - console.error('Failed to update columns:', error); - throw error; - } - }; - - 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`); - - 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); - } - } catch (error) { - console.error('Failed to update container settings:', error); - throw error; - } - }; - - const addPageContainer = async (pageId: string, parentContainerId?: string) => { - try { - const currentLayout = loadedPages.get(pageId); - if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`); - - const newLayout = JSON.parse(JSON.stringify(currentLayout)) as PageLayout; - const newContainer = UnifiedLayoutManager.addContainer(newLayout, parentContainerId); // Mutates newLayout AND returns container - - setLoadedPages(prev => new Map(prev).set(pageId, newLayout)); - await saveLayoutToCache(pageId, newLayout); - - return newContainer; - } catch (error) { - console.error('Failed to add container:', error); - throw error; - } - }; - - const removePageContainer = async (pageId: string, containerId: string) => { - try { - const currentLayout = loadedPages.get(pageId); - 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); - } - } catch (error) { - console.error('Failed to remove container:', error); - throw error; - } - }; - - 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`); - - 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); - } - } catch (error) { - console.error('Failed to move container:', error); - throw error; - } - }; - - const updateWidgetProps = async (pageId: string, widgetInstanceId: string, props: Record) => { - try { - const command = new UpdateWidgetSettingsCommand(pageId, widgetInstanceId, props); - - await historyManager.execute(command, { - pageId, - layouts: loadedPages, - updateLayout: updateLayoutCallback - }); - - updateHistoryState(); - } catch (error) { - console.error('Failed to update widget props:', error); - throw error; - } - }; - - 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`); - - 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; - } - return false; - } catch (error) { - console.error('Failed to rename widget:', error); - throw error; - } - }; - - const saveToApi = async (): Promise => { - return true; - }; - - const exportPageLayout = async (pageId: string): Promise => { + // Widget Actions + const addWidgetToPage = useCallback(async (pageId: string, containerId: string, widgetId: string, targetColumn?: number) => { const layout = loadedPages.get(pageId); - if (!layout) throw new Error('Layout not found'); - return JSON.stringify(layout, null, 2); - }; + if (!layout) throw new Error("Layout not loaded"); - const importPageLayout = async (pageId: string, jsonData: string): Promise => { - try { - const importedLayout = JSON.parse(jsonData) as PageLayout; - importedLayout.id = pageId; + const widgetInstance = UnifiedLayoutManager.createWidgetInstance(widgetId); - setLoadedPages(prev => new Map(prev).set(pageId, importedLayout)); - await UnifiedLayoutManager.savePageLayout(importedLayout); + // Use Command for Undo/Redo + const command = new AddWidgetCommand(pageId, containerId, widgetInstance, -1); // Index handling might need improvement if strict positioning required + await historyManager.execute(command, { + pageId, + layouts: loadedPages, + updateLayout: updateLayoutCallback, + pageMetadata: pendingMetadata, + updatePageMetadata: updatePageMetadataCallback + }); + updateHistoryState(); + return widgetInstance; + }, [loadedPages, historyManager, updateLayoutCallback, pendingMetadata, updatePageMetadataCallback, updateHistoryState]); - historyManager.clear(); - updateHistoryState(); + const removeWidgetFromPage = useCallback(async (pageId: string, widgetInstanceId: string) => { + const command = new RemoveWidgetCommand(pageId, widgetInstanceId); + await historyManager.execute(command, { + pageId, + layouts: loadedPages, + updateLayout: updateLayoutCallback, + pageMetadata: pendingMetadata, + updatePageMetadata: updatePageMetadataCallback + }); + updateHistoryState(); + }, [loadedPages, historyManager, updateLayoutCallback, pendingMetadata, updatePageMetadataCallback, updateHistoryState]); - return importedLayout; - } catch (e) { - console.error('Failed to import layout:', e); - throw e; - } - }; + const updateWidgetProps = useCallback(async (pageId: string, widgetInstanceId: string, props: Record) => { + const command = new UpdateWidgetSettingsCommand(pageId, widgetInstanceId, props); + await historyManager.execute(command, { + pageId, + layouts: loadedPages, + updateLayout: updateLayoutCallback, + pageMetadata: pendingMetadata, + updatePageMetadata: updatePageMetadataCallback + }); + updateHistoryState(); + }, [loadedPages, historyManager, updateLayoutCallback, pendingMetadata, updatePageMetadataCallback, updateHistoryState]); - const hydratePageLayout = (pageId: string, layout: PageLayout) => { + // Other Actions (Direct Update for now) + const moveWidgetInPage = useCallback(async (pageId: string, widgetInstanceId: string, direction: 'up' | 'down' | 'left' | 'right') => { + const layout = loadedPages.get(pageId); + if (!layout) return; + + const command = new MoveWidgetCommand(pageId, widgetInstanceId, direction); + await historyManager.execute(command, { + pageId, + layouts: loadedPages, + updateLayout: updateLayoutCallback, + pageMetadata: pendingMetadata, + updatePageMetadata: updatePageMetadataCallback + }); + updateHistoryState(); + }, [loadedPages, historyManager, updateLayoutCallback, pendingMetadata, updatePageMetadataCallback, updateHistoryState]); + + const updatePageContainerColumns = useCallback(async (pageId: string, containerId: string, columns: number) => { + const layout = loadedPages.get(pageId); + if (!layout) return; + const command = new UpdateContainerColumnsCommand(pageId, containerId, columns); + await historyManager.execute(command, { + pageId, + layouts: loadedPages, + updateLayout: updateLayoutCallback, + pageMetadata: pendingMetadata, + updatePageMetadata: updatePageMetadataCallback + }); + updateHistoryState(); + }, [loadedPages, historyManager, updateLayoutCallback, pendingMetadata, updatePageMetadataCallback, updateHistoryState]); + + const updatePageContainerSettings = useCallback(async (pageId: string, containerId: string, settings: Partial) => { + const layout = loadedPages.get(pageId); + if (!layout) return; + const command = new UpdateContainerSettingsCommand(pageId, containerId, settings); + await historyManager.execute(command, { + pageId, + layouts: loadedPages, + updateLayout: updateLayoutCallback, + pageMetadata: pendingMetadata, + updatePageMetadata: updatePageMetadataCallback + }); + updateHistoryState(); + }, [loadedPages, historyManager, updateLayoutCallback, pendingMetadata, updatePageMetadataCallback, updateHistoryState]); + + const addPageContainer = useCallback(async (pageId: string, parentContainerId?: string) => { + const layout = loadedPages.get(pageId); + if (!layout) throw new Error("Layout not loaded"); + // Create container instance first (pure) + // UnifiedLayoutManager.addContainer mutates, checking if we can generate detached container? + // We can't easily generate detached container with UnifiedLayoutManager.addContainer without a layout. + // So we manually create it here or use a helper that doesn't attach. + // UnifiedLayoutManager.generateContainerId is static. + + // Manual creation to follow command pattern: + const newContainer: LayoutContainer = { + id: UnifiedLayoutManager.generateContainerId(), + type: 'container', + columns: 1, + gap: 16, + widgets: [], + children: [], + order: 0 // Command execution will set order + }; + + const command = new AddContainerCommand(pageId, newContainer, parentContainerId); + await historyManager.execute(command, { + pageId, + layouts: loadedPages, + updateLayout: updateLayoutCallback, + pageMetadata: pendingMetadata, + updatePageMetadata: updatePageMetadataCallback + }); + updateHistoryState(); + return newContainer; + }, [loadedPages, historyManager, updateLayoutCallback, pendingMetadata, updatePageMetadataCallback, updateHistoryState]); + + const removePageContainer = useCallback(async (pageId: string, containerId: string) => { + const layout = loadedPages.get(pageId); + if (!layout) return; + const command = new RemoveContainerCommand(pageId, containerId); + await historyManager.execute(command, { + pageId, + layouts: loadedPages, + updateLayout: updateLayoutCallback, + pageMetadata: pendingMetadata, + updatePageMetadata: updatePageMetadataCallback + }); + updateHistoryState(); + }, [loadedPages, historyManager, updateLayoutCallback, pendingMetadata, updatePageMetadataCallback, updateHistoryState]); + + const movePageContainer = useCallback(async (pageId: string, containerId: string, direction: 'up' | 'down') => { + const layout = loadedPages.get(pageId); + if (!layout) return; + const command = new MoveContainerCommand(pageId, containerId, direction); + await historyManager.execute(command, { + pageId, + layouts: loadedPages, + updateLayout: updateLayoutCallback, + pageMetadata: pendingMetadata, + updatePageMetadata: updatePageMetadataCallback + }); + updateHistoryState(); + }, [loadedPages, historyManager, updateLayoutCallback, pendingMetadata, updatePageMetadataCallback, updateHistoryState]); + + const renameWidget = useCallback(async (pageId: string, widgetInstanceId: string, newId: string) => { + const layout = loadedPages.get(pageId); + if (!layout) return false; + const command = new RenameWidgetCommand(pageId, widgetInstanceId, newId); + await historyManager.execute(command, { + pageId, + layouts: loadedPages, + updateLayout: updateLayoutCallback, + pageMetadata: pendingMetadata, + updatePageMetadata: updatePageMetadataCallback + }); + updateHistoryState(); + return true; // Command execution is async void, we assume success if no throw + }, [loadedPages, historyManager, updateLayoutCallback, pendingMetadata, updatePageMetadataCallback, updateHistoryState]); + + const exportPageLayout = useCallback(async (pageId: string) => { + return UnifiedLayoutManager.exportPageLayout(pageId); + }, []); + + const importPageLayout = useCallback(async (pageId: string, jsonData: string) => { + // Use parseLayoutJSON (pure) then execute ReplaceLayoutCommand + const parsedLayout = UnifiedLayoutManager.parseLayoutJSON(jsonData, pageId); + + const command = new ReplaceLayoutCommand(pageId, parsedLayout); + await historyManager.execute(command, { + pageId, + layouts: loadedPages, + updateLayout: updateLayoutCallback, + pageMetadata: pendingMetadata, + updatePageMetadata: updatePageMetadataCallback + }); + updateHistoryState(); + return parsedLayout; + }, [loadedPages, historyManager, updateLayoutCallback, pendingMetadata, updatePageMetadataCallback, updateHistoryState]); + + const hydratePageLayout = useCallback((pageId: string, layout: PageLayout) => { setLoadedPages(prev => new Map(prev).set(pageId, layout)); - }; + }, []); + + const saveToApi = useCallback(async (): Promise => { + try { + setIsLoading(true); + let success = true; + + // 1. Save all loaded page layouts + // We iterate through all loaded pages and save them. + // We also pass any pending metadata for this page to be saved atomically. + for (const [pageId, layout] of loadedPages.entries()) { + try { + const metadata = pendingMetadata.get(pageId); + await UnifiedLayoutManager.savePageLayout(layout, metadata); + + // If we successfully saved, we can remove this page from pendingMetadata + if (metadata) { + // We need to update the state to remove this page's pending metadata + // Since we are inside a loop and async, we should be careful. + // But purely functional update is fine. + // We can just track what we saved and clear them all at once or iteratively. + // Let's do nothing here and clear properly at the end or re-filter. + } + } catch (e) { + console.error(`Failed to save layout for ${pageId}`, e); + success = false; + } + } + + // 2. Clear pending metadata for pages that were loaded and saved. + // We also need to handle metadata for pages that might NOT be in loadedPages (unlikely but possible if we unloaded a page but kept its pending meta?) + // If a page is not in loadedPages, savePageLayout wasn't called. We must manually save metadata for those. + + const remainingMetadata = new Map(pendingMetadata); + for (const pageId of loadedPages.keys()) { + remainingMetadata.delete(pageId); + } + + if (remainingMetadata.size > 0) { + const updates = Array.from(remainingMetadata.entries()); + for (const [pageId, metadata] of updates) { + const dbId = pageId.startsWith('page-') ? pageId.replace('page-', '') : pageId; + try { + const { updatePage } = await import('@/lib/db'); + await updatePage(dbId, metadata); + } catch (error) { + console.error(`Failed to save remaining metadata for ${pageId}`, error); + success = false; + } + } + } + + // Clear all pending metadata. + // NOTE: This assumes that if savePageLayout succeeded/failed, we still clear the pending state or we risking double saving? + // If success == false, maybe we shouldn't clear? + // But partial failure is hard to track mapped to specific pages in this simple boolean. + // Let's assume clear on attempt for now or we get stuck/loops. + // Ideally we only clear what we processed. + + setPendingMetadata(new Map()); + + return success; + } catch (e) { + console.error("Failed to save to API", e); + return false; + } finally { + setIsLoading(false); + } + }, [loadedPages, pendingMetadata]); const undo = async () => { await historyManager.undo({ pageId: '', layouts: loadedPages, - updateLayout: updateLayoutCallback + updateLayout: updateLayoutCallback, + pageMetadata: pendingMetadata, + updatePageMetadata: updatePageMetadataCallback }); updateHistoryState(); }; @@ -399,13 +403,21 @@ export const LayoutProvider: React.FC = ({ children }) => { await historyManager.redo({ pageId: '', layouts: loadedPages, - updateLayout: updateLayoutCallback + updateLayout: updateLayoutCallback, + pageMetadata: pendingMetadata, + updatePageMetadata: updatePageMetadataCallback }); updateHistoryState(); }; + const clearHistory = useCallback(() => { + historyManager.clear(); + updateHistoryState(); + }, [historyManager, updateHistoryState]); + return ( = ({ children }) => { undo, redo, canUndo, - canRedo + canRedo, + clearHistory, + executeCommand: async (command: Command) => { + await historyManager.execute(command, { + pageId: '', + layouts: loadedPages, + updateLayout: updateLayoutCallback, + pageMetadata: pendingMetadata, + updatePageMetadata: updatePageMetadataCallback + }); + updateHistoryState(); + } }}> {children} diff --git a/packages/ui/src/lib/db.ts b/packages/ui/src/lib/db.ts index 9d2105cc..3ae1c15c 100644 --- a/packages/ui/src/lib/db.ts +++ b/packages/ui/src/lib/db.ts @@ -20,106 +20,28 @@ export interface FeedPost { category_paths?: any[][]; // Array of category paths (each path is root -> leaf) } -const requestCache = new Map>(); - +// Deprecated: Caching now handled by React Query +// Keeping for backward compatibility type CacheStorageType = 'memory' | 'local'; -interface StoredCacheItem { - value: T; - timestamp: number; - timeout: number; -} - export const fetchWithDeduplication = async ( key: string, fetcher: () => Promise, timeout: number = 25000, storage: CacheStorageType = 'local' ): Promise => { - // 1. Check LocalStorage if requested - if (storage === 'local' && typeof window !== 'undefined') { - const localKey = `db-cache-${key}`; - const stored = localStorage.getItem(localKey); - if (stored) { - try { - const item: StoredCacheItem = JSON.parse(stored); - if (Date.now() - item.timestamp < item.timeout) { - return item.value; - } else { - localStorage.removeItem(localKey); // Clean up expired - } - } catch (e) { - localStorage.removeItem(localKey); - } - } - } - - // 2. Check Memory Cache (In-flight or recent) - if (!requestCache.has(key)) { - const promise = fetcher().then((data) => { - // Save to LocalStorage if requested and successful - if (storage === 'local' && typeof window !== 'undefined') { - const localKey = `db-cache-${key}`; - const item: StoredCacheItem = { - value: data, - timestamp: Date.now(), - timeout: timeout - }; - try { - localStorage.setItem(localKey, JSON.stringify(item)); - } catch (e) { - console.warn('Failed to save to persistent cache', e); - } - } - return data; - }).catch((err) => { - requestCache.delete(key); - throw err; - }).finally(() => { - // Clear memory cache after timeout to allow new fetches - // For 'local' storage, we technically might not need to clear memory cache as fast, - // but keeping them in sync involves less complexity if we just let memory cache expire. - // If it expires from memory, next call checks local storage again. - timeout && setTimeout(() => requestCache.delete(key), timeout); - }); - requestCache.set(key, promise); - } else { - console.debug(`[db] Cache HIT: ${key}`); - } - - return requestCache.get(key) as Promise; + // Pass-through without caching + return fetcher(); }; export const invalidateCache = (key: string) => { - // Clear memory cache - requestCache.delete(key); - - // Clear local storage cache - if (typeof window !== 'undefined') { - localStorage.removeItem(`db-cache-${key}`); - } + // No-op as internal caching is removed }; 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); - } + // Explicit cache invalidation is handled by the server on mutation + // No need to call /api/cache/invalidate manually + console.debug('invalidateServerCache: Skipped manual invalidation for', types); }; export const fetchPostById = async (id: string, client?: SupabaseClient) => { @@ -268,9 +190,6 @@ export const checkLikeStatus = async (userId: string, pictureId: string, client? export const toggleLike = async (userId: string, pictureId: string, isLiked: boolean, client?: SupabaseClient) => { const supabase = client || defaultSupabase; - // Invalidate like status cache immediately - const cacheKey = `like-${userId}-${pictureId}`; - if (requestCache.has(cacheKey)) requestCache.delete(cacheKey); if (isLiked) { const { error } = await supabase @@ -322,12 +241,7 @@ export const updatePostDetails = async (postId: string, updates: { title: string .eq('id', postId); if (error) throw error; - // Invalidate post cache - const cacheKey = `post-${postId}`; - if (requestCache.has(cacheKey)) requestCache.delete(cacheKey); - const fullCacheKey = `full-post-${postId}`; - if (requestCache.has(fullCacheKey)) requestCache.delete(fullCacheKey); - + // Cache invalidation handled by React Query // Invalidate Server Cache await invalidateServerCache(['posts']); }; @@ -369,9 +283,7 @@ export const updateUserSettings = async (userId: string, settings: any, client?: .update({ settings }) .eq('user_id', userId); - // Invalidate settings cache - const cacheKey = `settings-${userId}`; - if (requestCache.has(cacheKey)) requestCache.delete(cacheKey); + // Cache invalidation handled by React Query or not needed for now }; export const getUserOpenAIKey = async (userId: string, client?: SupabaseClient) => { @@ -995,6 +907,7 @@ export const augmentFeedPosts = (posts: any[]): FeedPost[] => { }; // --- Category Management --- + export interface Category { id: string; name: string; @@ -1004,6 +917,11 @@ export interface Category { visibility: 'public' | 'unlisted' | 'private'; parent_category_id?: string; created_at?: string; + meta?: { + type?: string; + assignedTypes?: string[]; // Array of Type IDs + [key: string]: any; + }; children?: { child: Category }[]; } @@ -1022,7 +940,23 @@ export const fetchCategories = async (options?: { parentSlug?: string; includeCh return await res.json(); }; +export const fetchTypes = async (options?: { kind?: string; visibility?: string }): Promise => { + const { data: sessionData } = await defaultSupabase.auth.getSession(); + const token = sessionData.session?.access_token; + const headers: HeadersInit = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const params = new URLSearchParams(); + if (options?.kind) params.append('kind', options.kind); + if (options?.visibility) params.append('visibility', options.visibility); + + const res = await fetch(`/api/types?${params.toString()}`, { headers }); + if (!res.ok) throw new Error(`Failed to fetch types: ${res.statusText}`); + return await res.json(); +}; + export const createCategory = async (category: Partial & { parentId?: string; relationType?: string }) => { + const { data: sessionData } = await defaultSupabase.auth.getSession(); const token = sessionData.session?.access_token; const headers: HeadersInit = { 'Content-Type': 'application/json' }; @@ -1066,39 +1000,86 @@ export const deleteCategory = async (id: string) => { return await res.json(); }; -export const updatePageMeta = async (pageId: string, metaUpdates: any) => { - // We need to merge with existing meta. - // Ideally we do this via a stored procedure or fetch-modify-save to avoid overwriting. - // Or Supabase jsonb_set / || operator. - // Using Supabase client directly since this interacts with 'pages' table - const { data: page, error: fetchError } = await defaultSupabase - .from('pages') - .select('meta, owner, slug') - .eq('id', pageId) - .single(); +// --- Page API Wrappers --- - if (fetchError) throw fetchError; +export const createPage = async (pageData: any): Promise => { + const { data: sessionData } = await defaultSupabase.auth.getSession(); + const token = sessionData.session?.access_token; + if (!token) throw new Error('Not authenticated'); - const currentMeta = (page?.meta as any) || {}; - const newMeta = { ...currentMeta, ...metaUpdates }; + const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin; + const response = await fetch(`${baseUrl}/api/pages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(pageData) + }); - const { data, error } = await defaultSupabase - .from('pages') - .update({ meta: newMeta, updated_at: new Date().toISOString() }) - .eq('id', pageId) - .select() - .single(); - - if (error) throw error; - - // Invalidate caches - invalidateCache(`page-details-${pageId}`); - if (page.owner && page.slug) { - invalidateUserPageCache(page.owner, page.slug); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to create page'); } - return data; + return await response.json(); +}; + +export const updatePage = async (pageId: string, updates: any): Promise => { + const { data: sessionData } = await defaultSupabase.auth.getSession(); + const token = sessionData.session?.access_token; + if (!token) throw new Error('Not authenticated'); + + const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin; + const response = await fetch(`${baseUrl}/api/pages/${pageId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(updates) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to update page'); + } + + return await response.json(); +}; + +export const deletePage = async (pageId: string): Promise => { + const { data: sessionData } = await defaultSupabase.auth.getSession(); + const token = sessionData.session?.access_token; + if (!token) throw new Error('Not authenticated'); + + const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin; + const response = await fetch(`${baseUrl}/api/pages/${pageId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to delete page'); + } +}; + +export const updatePageMeta = async (pageId: string, metaUpdates: any) => { + // Fetch current state via API to merge + const page = await fetchPageDetailsById(pageId); + if (!page) throw new Error("Page not found"); + + const currentMeta = page.meta || {}; + const newMeta = { ...currentMeta, ...metaUpdates }; + + // Use API wrapper to update + const result = await updatePage(pageId, { meta: newMeta }); + + return result; }; export const updatePostMeta = async (postId: string, metaUpdates: any) => { @@ -1122,11 +1103,6 @@ export const updatePostMeta = async (postId: string, metaUpdates: any) => { .single(); if (error) throw error; - - // Invalidate post cache - invalidateCache(`post-${postId}`); - invalidateCache(`full-post-${postId}`); - return data; }; @@ -1157,3 +1133,80 @@ export const updateLayoutMeta = async (layoutId: string, metaUpdates: any) => { return await updateRes.json(); }; + +export const updateLayout = async (layoutId: string, layoutData: any, client?: SupabaseClient) => { + const supabase = client || defaultSupabase; + const { data: sessionData } = await supabase.auth.getSession(); + const token = sessionData.session?.access_token; + + const headers: HeadersInit = { + 'Content-Type': 'application/json' + }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await fetch(`/api/layouts/${layoutId}`, { + method: 'PATCH', + headers, + body: JSON.stringify(layoutData) + }); + + if (!res.ok) { + throw new Error(`Failed to update layout: ${res.statusText}`); + } + + return await res.json(); +}; + +export const createLayout = async (layoutData: any, client?: SupabaseClient) => { + const supabase = client || defaultSupabase; + const { data: sessionData } = await supabase.auth.getSession(); + const token = sessionData.session?.access_token; + + const headers: HeadersInit = { + 'Content-Type': 'application/json' + }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await fetch(`/api/layouts`, { + method: 'POST', + headers, + body: JSON.stringify(layoutData) + }); + + if (!res.ok) { + throw new Error(`Failed to create layout: ${res.statusText}`); + } + + return await res.json(); +}; + +export const getLayouts = async (filters?: { type?: string, visibility?: string, limit?: number, offset?: number }, client?: SupabaseClient) => { + const supabase = client || defaultSupabase; + const { data: sessionData } = await supabase.auth.getSession(); + const token = sessionData.session?.access_token; + + const headers: HeadersInit = { + 'Content-Type': 'application/json' + }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const params = new URLSearchParams(); + if (filters?.type) params.append('type', filters.type); + if (filters?.visibility) params.append('visibility', filters.visibility); + if (filters?.limit) params.append('limit', filters.limit.toString()); + if (filters?.offset) params.append('offset', filters.offset.toString()); + + const res = await fetch(`/api/layouts?${params.toString()}`, { + method: 'GET', + headers + }); + + if (!res.ok) { + throw new Error(`Failed to fetch layouts: ${res.statusText}`); + } + + // Wrap in object to match Supabase response format { data, error } + const data = await res.json(); + return { data, error: null }; +}; + diff --git a/packages/ui/src/lib/layoutStorage.ts b/packages/ui/src/lib/layoutStorage.ts index 474ce550..76e08cd0 100644 --- a/packages/ui/src/lib/layoutStorage.ts +++ b/packages/ui/src/lib/layoutStorage.ts @@ -4,8 +4,8 @@ import { supabase } from '@/integrations/supabase/client'; export interface LayoutStorageService { load(pageId?: string): Promise; - save(data: RootLayoutData, pageId?: string): Promise; - saveToApiOnly(data: RootLayoutData, pageId?: string): Promise; + save(data: RootLayoutData, pageId?: string, metadata?: Record): Promise; + saveToApiOnly(data: RootLayoutData, pageId?: string, metadata?: Record): Promise; } // Database-only service for page layouts (localStorage disabled for DB items) @@ -41,6 +41,8 @@ export class DatabaseLayoutService implements LayoutStorageService { } async load(pageId?: string): Promise { + console.log('Loading layout for page:', pageId); + if (!pageId) return null; const isPage = pageId.startsWith('page-'); @@ -65,28 +67,44 @@ export class DatabaseLayoutService implements LayoutStorageService { } try { - const { data, error } = await supabase - .from(table) - .select(`${column}`) - .eq('id', actualId) - .single(); + if (isPage) { + // Use API for pages + const { fetchPageDetailsById } = await import('@/lib/db'); + const data = await fetchPageDetailsById(actualId); - if (!error && data && data[column]) { - const rootData = data[column] as unknown as RootLayoutData; - if (isCollection && layoutKey) { - const pageLayout = rootData.pages?.[layoutKey] || null; - return { - pages: { [pageId]: pageLayout }, - version: rootData.version || '1.0.0', - lastUpdated: rootData.lastUpdated || Date.now(), - } as RootLayoutData; + if (data && data.page && data.page.content) { + // Normalize content if it's stringified + let content = data.page.content; + if (typeof content === 'string') { + try { content = JSON.parse(content); } catch (e) { /* ignore */ } + } + + return content as RootLayoutData; } - - return rootData; - } else if (error) { - logger.error(`❌ Failed to load layout from ${table}`, { id: actualId, error }); + return null; } else { - logger.info(`ℹ️ No content found in ${table} for:`, actualId); + // Fallback to Supabase for collections or if API fails/not implemented for collections + const { data, error } = await supabase + .from(table) + .select(`${column}`) + .eq('id', actualId) + .single(); + + if (!error && data && data[column]) { + const rootData = data[column] as unknown as RootLayoutData; + if (isCollection && layoutKey) { + const pageLayout = rootData.pages?.[layoutKey] || null; + return { + pages: { [pageId]: pageLayout }, + version: rootData.version || '1.0.0', + lastUpdated: rootData.lastUpdated || Date.now(), + } as RootLayoutData; + } + + return rootData; + } else if (error) { + logger.error(`❌ Failed to load layout from ${table}`, { id: actualId, error }); + } } } catch (error) { logger.error(`❌ Failed to load layout from ${table}`, { id: actualId, error }); @@ -95,7 +113,7 @@ export class DatabaseLayoutService implements LayoutStorageService { return null; } - async save(data: RootLayoutData, pageId?: string): Promise { + async save(data: RootLayoutData, pageId?: string, metadata?: Record): Promise { if (!pageId) return false; if (!pageId.startsWith('page-') && !pageId.startsWith('collection-')) { @@ -104,10 +122,10 @@ export class DatabaseLayoutService implements LayoutStorageService { return true; } - return this.saveToApiOnly(data, pageId); + return this.saveToApiOnly(data, pageId, metadata); } - async saveToApiOnly(data: RootLayoutData, pageId?: string): Promise { + async saveToApiOnly(data: RootLayoutData, pageId?: string, metadata?: Record): Promise { if (!pageId) return false; const isPage = pageId.startsWith('page-'); @@ -135,6 +153,22 @@ export class DatabaseLayoutService implements LayoutStorageService { try { let dataToSave: any = data; + if (isPage) { + try { + const { updatePage } = await import('@/lib/db'); + await updatePage(actualId, { + content: dataToSave, + updated_at: new Date().toISOString(), + ...(metadata || {}) + }); + logger.info(`✅ Successfully saved page layout via API for:`, actualId); + return true; + } catch (error) { + logger.error(`❌ Failed to save page layout via API`, { id: actualId, error }); + return false; + } + } + if (isCollection && layoutKey) { const { data: existingData, error: fetchError } = await supabase .from('collections') diff --git a/packages/ui/src/lib/openai.ts b/packages/ui/src/lib/openai.ts index 03ac0402..fc72aa93 100644 --- a/packages/ui/src/lib/openai.ts +++ b/packages/ui/src/lib/openai.ts @@ -111,7 +111,7 @@ export const createOpenAIClient = async (apiKey?: string): Promise { @@ -38,7 +40,6 @@ const findWidgetLocation = (containers: LayoutContainer[], widgetId: string): { return null; }; - // --- Add Widget Command --- export class AddWidgetCommand implements Command { id: string; @@ -244,3 +245,492 @@ export class UpdateWidgetSettingsCommand implements Command { } } } +// --- Update Page Parent Command --- +export class UpdatePageParentCommand implements Command { + id: string; + type = 'UPDATE_PAGE_PARENT'; + timestamp: number; + + private pageId: string; + private oldParent: { id: string | null, title?: string, slug?: string } | null; + private newParent: { id: string | null, title?: string, slug?: string } | null; + private onUpdate?: (parent: { id: string | null, title?: string, slug?: string } | null) => void; + + constructor( + pageId: string, + oldParent: { id: string | null, title?: string, slug?: string } | null, + newParent: { id: string | null, title?: string, slug?: string } | null, + onUpdate?: (parent: { id: string | null, title?: string, slug?: string } | null) => void + ) { + this.id = crypto.randomUUID(); + this.timestamp = Date.now(); + this.pageId = pageId; + this.oldParent = oldParent; + this.newParent = newParent; + this.onUpdate = onUpdate; + } + + async execute(context: CommandContext): Promise { + // Update local metadata state through context + context.updatePageMetadata(this.pageId, { parent: this.newParent?.id ?? null }); + + if (this.onUpdate) this.onUpdate(this.newParent); + } + + async undo(context: CommandContext): Promise { + // Revert local metadata state + context.updatePageMetadata(this.pageId, { parent: this.oldParent?.id ?? null }); + + if (this.onUpdate) this.onUpdate(this.oldParent); + } +} + +// --- Update Page Meta Command --- +export class UpdatePageMetaCommand implements Command { + id: string; + type = 'UPDATE_PAGE_META'; + timestamp: number; + + private pageId: string; + private oldMeta: Record; + private newMeta: Record; + private onUpdate?: (meta: Record) => void; + + constructor( + pageId: string, + oldMeta: Record, + newMeta: Record, + onUpdate?: (meta: Record) => void + ) { + this.id = crypto.randomUUID(); + this.timestamp = Date.now(); + this.pageId = pageId; + this.oldMeta = oldMeta; + this.newMeta = newMeta; + this.onUpdate = onUpdate; + } + + async execute(context: CommandContext): Promise { + context.updatePageMetadata(this.pageId, this.newMeta); + if (this.onUpdate) this.onUpdate(this.newMeta); + } + + async undo(context: CommandContext): Promise { + context.updatePageMetadata(this.pageId, this.oldMeta); + if (this.onUpdate) this.onUpdate(this.oldMeta); + } +} + +// --- Add Container Command --- +export class AddContainerCommand implements Command { + id: string; + type = 'ADD_CONTAINER'; + timestamp: number; + + private pageId: string; + private parentContainerId?: string; + private container: LayoutContainer; + + constructor(pageId: string, container: LayoutContainer, parentContainerId?: string) { + this.id = crypto.randomUUID(); + this.timestamp = Date.now(); + this.pageId = pageId; + this.container = container; + this.parentContainerId = parentContainerId; + } + + async execute(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; + + if (this.parentContainerId) { + const parent = findContainer(newLayout.containers, this.parentContainerId); + if (parent) { + this.container.order = parent.children.length; + parent.children.push(this.container); + } + } else { + this.container.order = newLayout.containers.length; + newLayout.containers.push(this.container); + } + + 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; + + const remove = (containers: LayoutContainer[]): boolean => { + const idx = containers.findIndex(c => c.id === this.container.id); + if (idx !== -1) { + containers.splice(idx, 1); + containers.forEach((c, i) => c.order = i); + return true; + } + for (const c of containers) { + if (remove(c.children)) return true; + } + return false; + }; + + remove(newLayout.containers); + context.updateLayout(this.pageId, newLayout); + } +} + +// --- Remove Container Command --- +export class RemoveContainerCommand implements Command { + id: string; + type = 'REMOVE_CONTAINER'; + timestamp: number; + + private pageId: string; + private containerId: string; + + // State capture + private parentId: string | null = null; + private index: number = -1; + private container: LayoutContainer | null = null; + + constructor(pageId: string, containerId: string) { + this.id = crypto.randomUUID(); + this.timestamp = Date.now(); + this.pageId = pageId; + this.containerId = containerId; + } + + async execute(context: CommandContext): Promise { + const layout = context.layouts.get(this.pageId); + if (!layout) throw new Error(`Layout not found: ${this.pageId}`); + + // Capture state + const findAndCapture = (containers: LayoutContainer[], parentId: string | null) => { + for (let i = 0; i < containers.length; i++) { + if (containers[i].id === this.containerId) { + this.parentId = parentId; + this.index = i; + this.container = JSON.parse(JSON.stringify(containers[i])); // Deep clone + return true; + } + if (findAndCapture(containers[i].children, containers[i].id)) return true; + } + return false; + }; + + if (findAndCapture(layout.containers, null)) { + const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout; + UnifiedLayoutManager.removeContainer(newLayout, this.containerId, true); + context.updateLayout(this.pageId, newLayout); + } else { + console.warn(`Container ${this.containerId} not found for removal`); + } + } + + async undo(context: CommandContext): Promise { + if (!this.container || this.index === -1) { + console.warn("Cannot undo remove container: State 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; + + if (this.parentId === null) { + // Root container + if (this.index >= newLayout.containers.length) { + newLayout.containers.push(this.container); + } else { + newLayout.containers.splice(this.index, 0, this.container); + } + newLayout.containers.forEach((c, i) => c.order = i); + } else { + const parent = findContainer(newLayout.containers, this.parentId); + if (parent) { + if (this.index >= parent.children.length) { + parent.children.push(this.container); + } else { + parent.children.splice(this.index, 0, this.container); + } + parent.children.forEach((c, i) => c.order = i); + } + } + + context.updateLayout(this.pageId, newLayout); + } +} + +// --- Move Container Command --- +export class MoveContainerCommand implements Command { + id: string; + type = 'MOVE_CONTAINER'; + timestamp: number; + + private pageId: string; + private containerId: string; + private direction: 'up' | 'down'; + + constructor(pageId: string, containerId: string, direction: 'up' | 'down') { + this.id = crypto.randomUUID(); + this.timestamp = Date.now(); + this.pageId = pageId; + this.containerId = containerId; + this.direction = direction; + } + + async execute(context: CommandContext): Promise { + const layout = context.layouts.get(this.pageId); + if (!layout) return; + + const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout; + if (UnifiedLayoutManager.moveRootContainer(newLayout, this.containerId, this.direction)) { + context.updateLayout(this.pageId, newLayout); + } + } + + async undo(context: CommandContext): Promise { + const layout = context.layouts.get(this.pageId); + if (!layout) return; + + const reverseDirection = this.direction === 'up' ? 'down' : 'up'; + const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout; + if (UnifiedLayoutManager.moveRootContainer(newLayout, this.containerId, reverseDirection)) { + context.updateLayout(this.pageId, newLayout); + } + } +} + +// --- Move Widget Command --- +export class MoveWidgetCommand implements Command { + id: string; + type = 'MOVE_WIDGET'; + timestamp: number; + + private pageId: string; + private widgetId: string; + private direction: 'up' | 'down' | 'left' | 'right'; + + constructor(pageId: string, widgetId: string, direction: 'up' | 'down' | 'left' | 'right') { + this.id = crypto.randomUUID(); + this.timestamp = Date.now(); + this.pageId = pageId; + this.widgetId = widgetId; + this.direction = direction; + } + + async execute(context: CommandContext): Promise { + const layout = context.layouts.get(this.pageId); + if (!layout) return; + + const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout; + if (UnifiedLayoutManager.moveWidgetInContainer(newLayout, this.widgetId, this.direction)) { + context.updateLayout(this.pageId, newLayout); + } + } + + async undo(context: CommandContext): Promise { + const layout = context.layouts.get(this.pageId); + if (!layout) return; + + const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout; + + let reverseDirection: 'up' | 'down' | 'left' | 'right' = 'up'; + switch (this.direction) { + case 'up': reverseDirection = 'down'; break; + case 'down': reverseDirection = 'up'; break; + case 'left': reverseDirection = 'right'; break; + case 'right': reverseDirection = 'left'; break; + } + + if (UnifiedLayoutManager.moveWidgetInContainer(newLayout, this.widgetId, reverseDirection)) { + context.updateLayout(this.pageId, newLayout); + } + } +} + +// --- Update Container Columns Command --- +export class UpdateContainerColumnsCommand implements Command { + id: string; + type = 'UPDATE_CONTAINER_COLUMNS'; + timestamp: number; + + private pageId: string; + private containerId: string; + private newColumns: number; + private oldColumns: number | null = null; + + constructor(pageId: string, containerId: string, columns: number) { + this.id = crypto.randomUUID(); + this.timestamp = Date.now(); + this.pageId = pageId; + this.containerId = containerId; + this.newColumns = columns; + } + + async execute(context: CommandContext): Promise { + const layout = context.layouts.get(this.pageId); + if (!layout) return; + + // Capture old + if (this.oldColumns === null) { + const container = findContainer(layout.containers, this.containerId); + if (container) this.oldColumns = container.columns; + } + + const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout; + if (UnifiedLayoutManager.updateContainerColumns(newLayout, this.containerId, this.newColumns)) { + context.updateLayout(this.pageId, newLayout); + } + } + + async undo(context: CommandContext): Promise { + if (this.oldColumns === null) return; + + const layout = context.layouts.get(this.pageId); + if (!layout) return; + + const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout; + if (UnifiedLayoutManager.updateContainerColumns(newLayout, this.containerId, this.oldColumns)) { + context.updateLayout(this.pageId, newLayout); + } + } +} + +// --- Update Container Settings Command --- +export class UpdateContainerSettingsCommand implements Command { + id: string; + type = 'UPDATE_CONTAINER_SETTINGS'; + timestamp: number; + + private pageId: string; + private containerId: string; + private newSettings: Partial; + private oldSettings: Partial | null = null; + + constructor(pageId: string, containerId: string, settings: Partial) { + this.id = crypto.randomUUID(); + this.timestamp = Date.now(); + this.pageId = pageId; + this.containerId = containerId; + this.newSettings = settings; + } + + async execute(context: CommandContext): Promise { + const layout = context.layouts.get(this.pageId); + if (!layout) return; + + // Capture old + if (this.oldSettings === null) { + const container = findContainer(layout.containers, this.containerId); + if (container) this.oldSettings = { ...container.settings }; + } + + const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout; + if (UnifiedLayoutManager.updateContainerSettings(newLayout, this.containerId, this.newSettings)) { + context.updateLayout(this.pageId, newLayout); + } + } + + async undo(context: CommandContext): Promise { + if (this.oldSettings === null) return; + + const layout = context.layouts.get(this.pageId); + if (!layout) return; + + const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout; + if (UnifiedLayoutManager.updateContainerSettings(newLayout, this.containerId, this.oldSettings)) { + context.updateLayout(this.pageId, newLayout); + } + } +} + +// --- Rename Widget Command --- +export class RenameWidgetCommand implements Command { + id: string; + type = 'RENAME_WIDGET'; + timestamp: number; + + private pageId: string; + private oldId: string; + private newId: string; + + constructor(pageId: string, oldId: string, newId: string) { + this.id = crypto.randomUUID(); + this.timestamp = Date.now(); + this.pageId = pageId; + this.oldId = oldId; + this.newId = newId; + } + + async execute(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; + if (UnifiedLayoutManager.renameWidget(newLayout, this.oldId, this.newId)) { + context.updateLayout(this.pageId, newLayout); + } else { + throw new Error(`Failed to rename widget: ${this.newId} might already exist or widget not found`); + } + } + + async undo(context: CommandContext): Promise { + const layout = context.layouts.get(this.pageId); + if (!layout) return; + + const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout; + if (UnifiedLayoutManager.renameWidget(newLayout, this.newId, this.oldId)) { + context.updateLayout(this.pageId, newLayout); + } + } +} + +// --- Replace Layout Command --- +export class ReplaceLayoutCommand implements Command { + id: string; + type = 'REPLACE_LAYOUT'; + timestamp: number; + + private pageId: string; + private newLayout: PageLayout; + private oldLayout: PageLayout | null = null; + + constructor(pageId: string, newLayout: PageLayout) { + this.id = crypto.randomUUID(); + this.timestamp = Date.now(); + this.pageId = pageId; + this.newLayout = newLayout; + } + + async execute(context: CommandContext): Promise { + const currentLayout = context.layouts.get(this.pageId); + // If current layout not found, we might be importing for the first time or initializing. + // But for undo history, we need something to undo TO. + // If no current layout, maybe we just set it. + // But context usually requires existing key. + // Let's assume layout exists if we are in editor. + + if (currentLayout) { + this.oldLayout = JSON.parse(JSON.stringify(currentLayout)); + } + + // Apply new layout + const layoutToApply = JSON.parse(JSON.stringify(this.newLayout)) as PageLayout; + layoutToApply.id = this.pageId; + layoutToApply.updatedAt = Date.now(); + + context.updateLayout(this.pageId, layoutToApply); + } + + async undo(context: CommandContext): Promise { + if (this.oldLayout) { + context.updateLayout(this.pageId, this.oldLayout); + } + } +} diff --git a/packages/ui/src/lib/page-commands/types.ts b/packages/ui/src/lib/page-commands/types.ts index b655ffb9..6cff408c 100644 --- a/packages/ui/src/lib/page-commands/types.ts +++ b/packages/ui/src/lib/page-commands/types.ts @@ -4,6 +4,8 @@ export interface CommandContext { pageId: string; layouts: Map; updateLayout: (pageId: string, layout: PageLayout) => void; + pageMetadata: Map>; + updatePageMetadata: (pageId: string, metadata: Record) => void; } export interface Command { diff --git a/packages/ui/src/lib/registerWidgets.ts b/packages/ui/src/lib/registerWidgets.ts index 8e3e368e..6e93c3e1 100644 --- a/packages/ui/src/lib/registerWidgets.ts +++ b/packages/ui/src/lib/registerWidgets.ts @@ -14,6 +14,7 @@ import PageCardWidget from '@/components/widgets/PageCardWidget'; import LayoutContainerWidget from '@/components/widgets/LayoutContainerWidget'; import MarkdownTextWidget from '@/components/widgets/MarkdownTextWidget'; import GalleryWidget from '@/components/widgets/GalleryWidget'; +import TabsWidget from '@/components/widgets/TabsWidget'; export function registerAllWidgets() { // Clear existing registrations (useful for HMR) @@ -113,6 +114,60 @@ export function registerAllWidgets() { } }); + widgetRegistry.register({ + component: TabsWidget, + metadata: { + id: 'tabs-widget', + name: 'Tabs Widget', + category: 'layout', + description: 'Organize content into switchable tabs', + icon: Layout, + defaultProps: { + tabs: [ + { id: 'tab-1', label: 'Tab 1', layoutId: `tabs-${Date.now()}-tab-1` }, + { id: 'tab-2', label: 'Tab 2', layoutId: `tabs-${Date.now()}-tab-2` } + ], + orientation: 'horizontal', + tabBarPosition: 'top' + }, + configSchema: { + tabs: { + type: 'tabs-editor', + label: 'Tabs', + description: 'Manage tabs and their order', + default: [] + }, + tabBarPosition: { + type: 'select', + label: 'Tab Bar Position', + description: 'Position of the tab bar', + options: [ + { value: 'top', label: 'Top' }, + { value: 'bottom', label: 'Bottom' }, + { value: 'left', label: 'Left' }, + { value: 'right', label: 'Right' } + ], + default: 'top' + }, + tabBarClassName: { + type: 'classname', + label: 'Tab Bar Style', + description: 'Tailwind classes for the tab bar', + default: '' + }, + contentClassName: { + type: 'classname', + label: 'Content Area Style', + description: 'Tailwind classes for the content area', + default: '' + } + }, + minSize: { width: 400, height: 300 }, + resizable: true, + tags: ['layout', 'tabs', 'container'] + } + }); + widgetRegistry.register({ component: GalleryWidget, metadata: { @@ -151,6 +206,40 @@ export function registerAllWidgets() { { value: 'cover', label: 'Cover (fill container, may crop image)' } ], default: 'cover' + }, + thumbnailsPosition: { + type: 'select', + label: 'Thumbnails Position', + description: 'Where to place the thumbnails relative to the main image', + options: [ + { value: 'bottom', label: 'Bottom' }, + { value: 'top', label: 'Top' }, + { value: 'left', label: 'Left' }, + { value: 'right', label: 'Right' } + ], + default: 'bottom' + }, + thumbnailsOrientation: { + type: 'select', + label: 'Thumbnails Orientation', + description: 'Direction of the thumbnail strip', + options: [ + { value: 'horizontal', label: 'Horizontal' }, + { value: 'vertical', label: 'Vertical' } + ], + default: 'horizontal' + }, + zoomEnabled: { + type: 'boolean', + label: 'Enable Pan Zoom', + description: 'Enlarge image and pan on hover', + default: false + }, + thumbnailsClassName: { + type: 'classname', + label: 'Thumbnails CSS Class', + description: 'Additional CSS classes for the thumbnail strip container', + default: '' } }, minSize: { width: 600, height: 500 }, diff --git a/packages/ui/src/lib/schema-utils.ts b/packages/ui/src/lib/schema-utils.ts new file mode 100644 index 00000000..66596058 --- /dev/null +++ b/packages/ui/src/lib/schema-utils.ts @@ -0,0 +1,110 @@ +import { TypeDefinition } from '../components/types/db'; + +// Mapping from our primitive type names to JSON Schema types +export const primitiveToJsonSchema: Record = { + 'string': { type: 'string' }, + 'int': { type: 'integer' }, + 'float': { type: 'number' }, + 'bool': { type: 'boolean' }, + 'boolean': { type: 'boolean' }, // Handle both just in case + 'array': { type: 'array', items: {} }, + 'object': { type: 'object' }, + 'enum': { type: 'string', enum: [] }, + 'flags': { type: 'array', items: { type: 'string' } }, + 'reference': { type: 'string' }, + 'alias': { type: 'string' } +}; + +// Recursive function to generate schema for any type (primitive or structure) +export const generateSchemaForType = (typeId: string, types: TypeDefinition[], visited = new Set()): any => { + // Prevent infinite recursion for circular references + if (visited.has(typeId)) { + return { type: 'object', description: 'Circular reference detected' }; + } + + const type = types.find(t => t.id === typeId); + if (!type) return { type: 'string' }; + + // If it's a primitive, return the JSON schema mapping + if (type.kind === 'primitive') { + return primitiveToJsonSchema[type.name] || { type: 'string' }; + } + + // If it's a structure, recursively build its schema + if (type.kind === 'structure' && type.structure_fields) { + visited.add(typeId); + const properties: Record = {}; + const required: string[] = []; + + type.structure_fields.forEach(field => { + const fieldType = types.find(t => t.id === field.field_type_id); + if (fieldType) { + // If the field type has a parent (it's a field definition), we need to resolve the parent's schema + const typeToResolve = fieldType.parent_type_id + ? types.find(t => t.id === fieldType.parent_type_id) + : fieldType; // Should effectively not happen for 'field' kind without parent, but safe fallback + + if (typeToResolve) { + // Recursively generate schema + properties[field.field_name] = { + ...generateSchemaForType(typeToResolve.id, types, new Set(visited)), + title: field.field_name, + ...(fieldType.description && { description: fieldType.description }) + }; + if (field.required) { + required.push(field.field_name); + } + } + } + }); + + return { + type: 'object', + properties, + ...(required.length > 0 && { required }) + }; + } + + // Fallback for other kinds (alias, etc -> resolve to their target if possible, currently alias is just string for now) + return { type: 'string' }; +}; + +// Recursive function to generate UI schema for a type +export const generateUiSchemaForType = (typeId: string, types: TypeDefinition[], visited = new Set()): any => { + if (visited.has(typeId)) return {}; + + const type = types.find(t => t.id === typeId); + if (!type || type.kind !== 'structure' || !type.structure_fields) { + return {}; + } + + visited.add(typeId); + const uiSchema: Record = { + 'ui:options': { orderable: false }, + 'ui:classNames': 'grid grid-cols-1 md:grid-cols-2 gap-4' + }; + + type.structure_fields.forEach(field => { + const fieldType = types.find(t => t.id === field.field_type_id); + const parentType = fieldType?.parent_type_id + ? types.find(t => t.id === fieldType.parent_type_id) + : null; + + const isNestedStructure = parentType?.kind === 'structure'; + const fieldUiSchema = fieldType?.meta?.uiSchema || {}; + + if (isNestedStructure && parentType) { + // Recursively generate UI schema for nested structure + const nestedUiSchema = generateUiSchemaForType(parentType.id, types, new Set(visited)); + uiSchema[field.field_name] = { + ...fieldUiSchema, + ...nestedUiSchema, + 'ui:classNames': 'col-span-full border-t pt-4 mt-2' + }; + } else { + uiSchema[field.field_name] = fieldUiSchema; + } + }); + + return uiSchema; +}; diff --git a/packages/ui/src/lib/unifiedLayoutManager.ts b/packages/ui/src/lib/unifiedLayoutManager.ts index 3d4deab1..e59a9e7d 100644 --- a/packages/ui/src/lib/unifiedLayoutManager.ts +++ b/packages/ui/src/lib/unifiedLayoutManager.ts @@ -109,10 +109,10 @@ export class UnifiedLayoutManager { } // Save root data to storage (database-only, no localStorage) - static async saveRootData(data: RootLayoutData, pageId?: string): Promise { + static async saveRootData(data: RootLayoutData, pageId?: string, metadata?: Record): Promise { try { data.lastUpdated = Date.now(); - await layoutStorage.save(data, pageId); + await layoutStorage.save(data, pageId, metadata); } catch (error) { console.error('Failed to save layouts to database:', error); } @@ -163,7 +163,7 @@ export class UnifiedLayoutManager { } // Save page layout (database-only) - static async savePageLayout(layout: PageLayout): Promise { + static async savePageLayout(layout: PageLayout, metadata?: Record): Promise { layout.updatedAt = Date.now(); // Create a minimal RootLayoutData for just this page @@ -174,7 +174,7 @@ export class UnifiedLayoutManager { }; // Save directly to database - await this.saveRootData(pageData, layout.id); + await this.saveRootData(pageData, layout.id, metadata); } // Find container by ID in layout (recursive) @@ -682,19 +682,18 @@ export class UnifiedLayoutManager { return count; } + // Export layout to JSON // Export layout to JSON static async exportPageLayout(pageId: string): Promise { const layout = await this.getPageLayout(pageId); return JSON.stringify(layout, null, 2); } - // Import layout from JSON - static async importPageLayout(pageId: string, jsonData: string): Promise { + // Parse layout from JSON (Pure, no side effects) + static parseLayoutJSON(jsonData: string, targetPageId?: string): PageLayout { try { const parsedData = JSON.parse(jsonData) as PageLayout; - console.log('[ULM] Raw imported data:', parsedData); - // Basic validation if (!parsedData.id || !parsedData.containers) { throw new Error('Invalid layout data for import.'); @@ -709,16 +708,11 @@ export class UnifiedLayoutManager { }); }; countWidgets(parsedData.containers); - console.log(`[ULM] Imported layout has ${widgetCount} widgets.`); // Sanitization: Ensure all widget IDs are unique within the imported layout - // This fixes issues where templates might contain hardcoded IDs or duplicates const seenIds = new Set(); const sanitizeIds = (containers: LayoutContainer[]) => { containers.forEach(container => { - // Container ID check (less critical but good practice) - // Skipping container ID check for now to avoid breaking references if any - container.widgets.forEach(widget => { if (seenIds.has(widget.id)) { const newId = this.generateWidgetId(); @@ -727,19 +721,29 @@ export class UnifiedLayoutManager { } seenIds.add(widget.id); }); - sanitizeIds(container.children); }); }; - sanitizeIds(parsedData.containers); - // Ensure the ID in the JSON matches the target pageId - if (parsedData.id !== pageId) { - console.warn(`[ULM] Mismatch between target pageId (${pageId}) and imported ID (${parsedData.id}). Overwriting ID.`); - parsedData.id = pageId; + // Ensure the ID in the JSON matches the target pageId if provided + if (targetPageId && parsedData.id !== targetPageId) { + console.warn(`[ULM] Mismatch between target pageId (${targetPageId}) and imported ID (${parsedData.id}). Overwriting ID.`); + parsedData.id = targetPageId; } + return parsedData; + } catch (e) { + console.error("Failed to parse layout JSON", e); + throw e; + } + } + + // Import layout from JSON (Saves to DB) + static async importPageLayout(pageId: string, jsonData: string): Promise { + try { + const parsedData = this.parseLayoutJSON(jsonData, pageId); + // Load the existing root data const rootData = await this.loadRootData(pageId); diff --git a/packages/ui/src/pages/Post/renderers/components/CompactFilmStrip.tsx b/packages/ui/src/pages/Post/renderers/components/CompactFilmStrip.tsx index 3908b664..8d0a0dc7 100644 --- a/packages/ui/src/pages/Post/renderers/components/CompactFilmStrip.tsx +++ b/packages/ui/src/pages/Post/renderers/components/CompactFilmStrip.tsx @@ -18,6 +18,8 @@ interface CompactFilmStripProps { onGalleryPickerOpen: (index: number) => void; cacheBustKeys: Record; thumbnailLayout?: 'strip' | 'grid'; + orientation?: 'horizontal' | 'vertical'; // New prop + className?: string; } export const CompactFilmStrip: React.FC = ({ @@ -31,7 +33,9 @@ export const CompactFilmStrip: React.FC = ({ onDeletePicture, onGalleryPickerOpen, cacheBustKeys, - thumbnailLayout = 'strip' + thumbnailLayout = 'strip', + orientation = 'horizontal', + className }) => { const scrollContainerRef = useRef(null); const [draggedIndex, setDraggedIndex] = useState(null); @@ -42,12 +46,16 @@ export const CompactFilmStrip: React.FC = ({ if (container) { const handleWheel = (e: WheelEvent) => { if (e.deltaY !== 0) { - e.preventDefault(); - if (thumbnailLayout === 'grid') { - // Grid mode: vertical scrolling - container.scrollTop += e.deltaY; + const isVerticalScroll = thumbnailLayout === 'grid' || orientation === 'vertical'; + + if (isVerticalScroll) { + // Let native vertical scroll happen if content overflows + // But if we are in 'strip' mode strictly, we might want horizontal scroll mapping + // For now, if orientation is vertical, we expect standard vertical scrolling behavior + return; } else { - // Strip mode: horizontal scrolling + // Horizontal strip: map vertical scroll to horizontal scroll + e.preventDefault(); container.scrollLeft += e.deltaY; } } @@ -55,7 +63,7 @@ export const CompactFilmStrip: React.FC = ({ container.addEventListener('wheel', handleWheel, { passive: false }); return () => container.removeEventListener('wheel', handleWheel); } - }, [thumbnailLayout]); + }, [thumbnailLayout, orientation]); const handleDragStart = (e: React.DragEvent, index: number) => { e.stopPropagation(); @@ -141,12 +149,18 @@ export const CompactFilmStrip: React.FC = ({ } return ( -
+
{groupedItems.map((item, index) => ( @@ -156,10 +170,17 @@ export const CompactFilmStrip: React.FC = ({ onDragStart={(e) => isEditMode && handleDragStart(e, index)} onDragOver={(e) => isEditMode && handleDragOver(e, index)} onDrop={(e) => isEditMode && handleDrop(e, index)} - className={`relative landscape:mx-1 landscape:py-0 lg:landscape:m-2 flex-shrink-0 w-24 h-24 landscape:w-16 landscape:h-16 lg:landscape:w-32 lg:landscape:h-32 overflow-hidden cursor-pointer transition-all ${item.id === mediaItem?.id - ? 'shadow-lg scale-105' - : 'hover:scale-102' - }`} + className={` + relative flex-shrink-0 overflow-hidden cursor-pointer transition-all + ${orientation === 'vertical' + ? 'w-24 h-24 lg:w-32 lg:h-32 mx-auto my-1' // Vertical sizing + : 'w-24 h-24 landscape:w-16 landscape:h-16 lg:landscape:w-32 lg:landscape:h-32 landscape:mx-1 landscape:py-0 lg:landscape:m-2' // Horizontal sizing + } + ${item.id === mediaItem?.id + ? 'shadow-lg scale-105' + : 'hover:scale-102' + } + `} onClick={() => { onMediaSelect(item); }} @@ -235,7 +256,7 @@ export const CompactFilmStrip: React.FC = ({ sizes="150px" loading="lazy" /> - {isVideoType(normalizeMediaType(item.type || detectMediaType(item.image_url))) && ( + {isVideoType(normalizeMediaType((item as any).type || detectMediaType(item.image_url))) && (
diff --git a/packages/ui/src/pages/Post/renderers/components/CompactMediaViewer.tsx b/packages/ui/src/pages/Post/renderers/components/CompactMediaViewer.tsx index 4098ecbe..863c33d9 100644 --- a/packages/ui/src/pages/Post/renderers/components/CompactMediaViewer.tsx +++ b/packages/ui/src/pages/Post/renderers/components/CompactMediaViewer.tsx @@ -8,6 +8,7 @@ import ResponsiveImage from "@/components/ResponsiveImage"; import { PostMediaItem } from "../../types"; import { getTikTokVideoId, getYouTubeVideoId, isTikTokUrl } from "@/utils/mediaUtils"; import { Play } from 'lucide-react'; +import { SpyGlassImage } from "./SpyGlassImage"; interface CompactMediaViewerProps { mediaItem: PostMediaItem; @@ -25,6 +26,7 @@ interface CompactMediaViewerProps { videoPlaybackUrl?: string; videoPosterUrl?: string; imageFit?: 'contain' | 'cover'; + zoomEnabled?: boolean; } export const CompactMediaViewer: React.FC = ({ @@ -42,7 +44,8 @@ export const CompactMediaViewer: React.FC = ({ isOwner, videoPlaybackUrl, videoPosterUrl, - imageFit = 'cover' + imageFit = 'cover', + zoomEnabled = false }) => { const playerRef = useRef(null); const [externalVideoState, setExternalVideoState] = React.useState>({}); @@ -198,6 +201,18 @@ export const CompactMediaViewer: React.FC = ({ } + // Main Image Viewer with conditional SpyGlass + if (zoomEnabled) { + return ( + onExpand(mediaItem)} + imageFit={imageFit} + /> + ); + } return ( = ({ showDesktopLayout = true, thumbnailLayout = 'strip', imageFit = 'cover', + thumbnailsPosition = 'bottom', + thumbnailsOrientation = 'horizontal', + zoomEnabled = false, + thumbnailsClassName = '', className = "" }) => { const currentImageIndex = mediaItems.findIndex(item => item.id === selectedItem.id); + const [zoomActivated, setZoomActivated] = React.useState(false); + + const handleThumbnailClick = (item: PostMediaItem) => { + setZoomActivated(true); + onMediaSelect(item); + }; const effectiveType = selectedItem.mediaType || detectMediaType(selectedItem.image_url); const isVideo = isVideoType(normalizeMediaType(effectiveType)); + // Determine flex direction based on thumbnail position + const getFlexDirection = () => { + switch (thumbnailsPosition) { + case 'top': return 'flex-col-reverse'; + case 'left': return 'flex-row-reverse'; + case 'right': return 'flex-row'; + case 'bottom': + default: return 'flex-col'; + } + }; + // Determine if we need a fixed width/height for the filmstrip container based on position + const isSidebar = thumbnailsPosition === 'left' || thumbnailsPosition === 'right'; return ( -
+
{/* Main Media Viewer - takes remaining space */} -
+
= ({ videoPlaybackUrl={videoPlaybackUrl} videoPosterUrl={videoPosterUrl} imageFit={imageFit} + zoomEnabled={zoomEnabled && zoomActivated} />
- {/* Filmstrip - centered to match main image */} -
+ {/* Filmstrip */} +
diff --git a/packages/ui/src/pages/Post/renderers/components/SpyGlassImage.tsx b/packages/ui/src/pages/Post/renderers/components/SpyGlassImage.tsx new file mode 100644 index 00000000..8d734f01 --- /dev/null +++ b/packages/ui/src/pages/Post/renderers/components/SpyGlassImage.tsx @@ -0,0 +1,129 @@ +import React, { useState, useRef } from 'react'; +import { cn } from '@/lib/utils'; +import ResponsiveImage from '@/components/ResponsiveImage'; + +interface SpyGlassImageProps { + src: string; + alt: string; + className?: string; + zoomLevel?: number; // Zoom level (e.g., 2 = 200%) + onClick?: () => void; + imageFit?: 'contain' | 'cover'; +} + +export const SpyGlassImage: React.FC = ({ + src, + alt, + className, + zoomLevel = 2, + onClick, + imageFit = 'cover' +}) => { + const containerRef = useRef(null); + const [isHovering, setIsHovering] = useState(false); + const [backgroundPosition, setBackgroundPosition] = useState('0% 0%'); + const [currentZoom, setCurrentZoom] = useState(zoomLevel); + + // Reset zoom when zoomLevel prop changes or when not hovering (optional, but good UX to reset) + // Actually, keeping the zoom level persistent while hovering is better. + // We can reset it when entering if we want, but user might want to keep their setting. + // Let's rely on state. + + // Update state if prop changes, but only if we haven't touched it? + // Or just simple effect: + React.useEffect(() => { + setCurrentZoom(zoomLevel); + }, [zoomLevel]); + + const handleMouseMove = (e: React.MouseEvent) => { + if (!containerRef.current) return; + + const { left, top, width, height } = containerRef.current.getBoundingClientRect(); + + // Calculate mouse position relative to the image container + const x = e.clientX - left; + const y = e.clientY - top; + + // Calculate percentage for background position + const xPercent = (x / width) * 100; + const yPercent = (y / height) * 100; + + setBackgroundPosition(`${xPercent}% ${yPercent}%`); + }; + + const handleWheel = (e: React.WheelEvent) => { + // Prevent page scroll + e.stopPropagation(); + // e.preventDefault(); // React's SyntheticEvent doesn't support passive false helper easily here without ref listener, + // but we can try. If browser complains about passive, we might need a ref listener. + + // Let's blindly try to update zoom + const delta = -e.deltaY * 0.005; // speed factor + setCurrentZoom(prev => { + const next = prev + delta; + return Math.min(Math.max(next, 1.1), 10); // Clamp between 1.1x and 10x + }); + }; + + // Use a ref for wheel listener to support non-passive event prevention if needed + React.useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const onWheel = (e: WheelEvent) => { + if (isHovering) { + e.preventDefault(); + const delta = -e.deltaY * 0.005; + setCurrentZoom(prev => { + const next = prev + delta; + return Math.min(Math.max(next, 1.1), 10); + }); + } + }; + + container.addEventListener('wheel', onWheel, { passive: false }); + return () => container.removeEventListener('wheel', onWheel); + }, [isHovering]); + + const handleMouseEnter = () => setIsHovering(true); + const handleMouseLeave = () => setIsHovering(false); + + return ( +
+ {/* Main Image (Visible when not hovering) */} + + + {/* Zoomed Background Image (Visible when hovering) */} + {isHovering && ( +
+ )} +
+ ); +}; diff --git a/packages/ui/src/pages/UserPage.tsx b/packages/ui/src/pages/UserPage.tsx index c36f6abe..967613ce 100644 --- a/packages/ui/src/pages/UserPage.tsx +++ b/packages/ui/src/pages/UserPage.tsx @@ -1,30 +1,27 @@ 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 { ArrowLeft, PanelLeftClose, PanelLeftOpen } from "lucide-react"; +import { PanelLeftClose, PanelLeftOpen } from "lucide-react"; import { T, translate } from "@/i18n"; -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 { GenericCanvas } from "@/components/hmi/GenericCanvas"; +import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; + import MarkdownRenderer from "@/components/MarkdownRenderer"; import { Sidebar } from "@/components/sidebar/Sidebar"; import { TableOfContents } from "@/components/sidebar/TableOfContents"; 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 { fetchUserPage } 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")); + +const UserPageEdit = lazy(() => import("./UserPageEdit")); type Layout = Database['public']['Tables']['layouts']['Row']; @@ -90,12 +87,6 @@ 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); - - - - - // TOC State const [headings, setHeadings] = useState([]); @@ -109,111 +100,6 @@ 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); @@ -256,36 +142,6 @@ 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(); @@ -342,7 +198,7 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia // If parent changed, we don't have the new parent's title/slug unless we fetch it. // So if parent ID changed, we should probably re-fetch the whole page data from server. - if (updatedPage.parent !== page?.parent) { + if (updatedPage.parent !== page?.parent && !isEditMode) { // Re-fetch everything to get correct parent details if (userId && updatedPage.slug) { fetchUserPageData(userId, updatedPage.slug); @@ -352,8 +208,6 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia } }; - - if (loading) { return (
@@ -375,47 +229,34 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia ); } + if (isEditMode && isOwner) { + return ( + Loading Editor...
}> + setIsEditMode(false)} + onPageUpdate={handlePageUpdate} + /> + + ); + } + 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 */} @@ -490,9 +331,17 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia orgSlug={orgSlug} onPageUpdate={handlePageUpdate} onToggleEditMode={() => setIsEditMode(!isEditMode)} - onWidgetRename={setSelectedWidgetId} - templates={templates} - onLoadTemplate={handleLoadTemplate} + onWidgetRename={() => { }} // Not needed in view mode + // templates={templates} // Do we need templates in view mode? PageActions uses it for Apply Template in dropdown + // If we remove templates state from here, we can't pass it. + // For now let's skip passing templates to View Mode details if it's not critical. + // Or we keep template loading here as well? + // Actually PageActions (in UserPageDetails) DOES have an "Apply Template" option. + // If we want that in View Mode, we need templates. + // But usually "Apply Template" is an edit action. + // If so, maybe PageActions should only show "Apply Template" in Edit Mode? + // Checked PageActions code? Not visible. + // Assuming it's fine to omit templates in view mode for now as per refactor goal to move edit actions out. /> {/* Content Body */} @@ -505,16 +354,11 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia { }} /> )}
@@ -546,21 +390,8 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
- {/* Right Sidebar - Property Panel */} - {isEditMode && isOwner && selectedWidgetId && ( - <> - - -
- -
-
- - )} + {/* No Right Sidebar in View Mode */} +
); diff --git a/packages/ui/src/pages/UserPageEdit.tsx b/packages/ui/src/pages/UserPageEdit.tsx new file mode 100644 index 00000000..a24bb521 --- /dev/null +++ b/packages/ui/src/pages/UserPageEdit.tsx @@ -0,0 +1,646 @@ + +import { useState, useEffect } from "react"; +import { Link } from "react-router-dom"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { PanelLeftClose, PanelLeftOpen } from "lucide-react"; +import { T, translate } from "@/i18n"; +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"; +import { MobileTOC } from "@/components/sidebar/MobileTOC"; +import { MarkdownHeading } from "@/lib/toc"; +import { useLayout } from "@/contexts/LayoutContext"; +import { UserPageDetails } from "@/components/user-page/UserPageDetails"; +import { useLayouts } from "@/hooks/useLayouts"; +import { Database } from "@/integrations/supabase/types"; +import PageRibbonBar from "@/components/user-page/ribbons/PageRibbonBar"; +import { SaveTemplateDialog } from "@/components/user-page/SaveTemplateDialog"; +import { getLayouts } from "@/lib/db"; +import { HierarchyTree } from "@/components/sidebar/HierarchyTree"; +import { UserPageTypeFields } from "@/components/user-page/UserPageTypeFields"; + +type Layout = Database['public']['Tables']['layouts']['Row']; + +interface Page { + id: string; + title: string; + slug: string; + content: any; + owner: string; + parent: string | null; + parent_page?: { + title: string; + slug: string; + } | null; + type: string | null; + tags: string[] | null; + is_public: boolean; + visible: boolean; + created_at: string; + updated_at: string; + meta?: any; + category_paths?: { + id: string; + name: string; + slug: string; + }[][]; + categories?: { // Legacy/fallback support + id: string; + name: string; + slug: string; + }[]; +} + +interface UserProfile { + id: string; + username: string | null; + display_name: string | null; + avatar_url: string | null; +} + +interface UserPageEditProps { + page: Page; + userProfile: UserProfile | null; + isOwner: boolean; + userId: string; + orgSlug?: string; + headings: MarkdownHeading[]; + childPages: { id: string; title: string; slug: string }[]; + onExitEditMode: () => void; + onPageUpdate: (updatedPage: Page) => void; +} + +const UserPageEdit = ({ + page, + userProfile, + isOwner, + userId, + orgSlug, + headings, + childPages, + onExitEditMode, + onPageUpdate, +}: UserPageEditProps) => { + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); + const [selectedWidgetId, setSelectedWidgetId] = useState(null); + const [showHierarchy, setShowHierarchy] = useState(false); + + // Auto-collapse sidebar if no TOC headings + + useEffect(() => { + if (headings.length === 0) { + setIsSidebarCollapsed(true); + } + }, [headings.length]); + + // Template State + const [templates, setTemplates] = useState([]); + const { getLayouts } = useLayouts(); + const { + importPageLayout, + exportPageLayout, + addWidgetToPage, + removeWidgetFromPage, + addPageContainer, + undo, + redo, + canUndo, + canRedo, + getLoadedPageLayout + } = useLayout(); + const [selectedContainerId, setSelectedContainerId] = useState(null); + const [editingWidgetId, setEditingWidgetId] = useState(null); + const [newlyAddedWidgetId, setNewlyAddedWidgetId] = useState(null); + const [activeTemplateId, setActiveTemplateId] = useState(null); + const [showSaveTemplateDialog, setShowSaveTemplateDialog] = useState(false); + + useEffect(() => { + if (isOwner) { + loadTemplates(); + } + }, [isOwner]); + + const loadTemplates = async () => { + const { data, error } = await getLayouts({ type: 'canvas' }); + if (data) { + setTemplates(data); + } + }; + + const handleWidgetClick = async (widgetId: string) => { + // Always add (no toggle/remove) + await handleAddWidget(widgetId); + }; + + 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); + setEditingWidgetId(newWidget.id); + setNewlyAddedWidgetId(newWidget.id); + setSelectedWidgetId(newWidget.id); + } 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); + setActiveTemplateId(template.id); + toast.success(`Loaded layout: ${template.name}`); + } catch (e) { + console.error("Failed to load layout", e); + toast.error("Failed to load layout"); + } + }; + + const handleSaveToTemplate = async () => { + if (!activeTemplateId || !page) return; + try { + const pageId = `page-${page.id}`; + const layout = getLoadedPageLayout(pageId); + if (!layout) return; + + // Import db dynamically to avoid circ dep issues if any, or just direct import + const { updateLayout } = await import('@/lib/db'); + + await updateLayout(activeTemplateId, { + layout_json: layout, + updated_at: new Date().toISOString() + }); + toast.success("Template updated successfully"); + } catch (e) { + console.error("Failed to save template", e); + toast.error("Failed to save template"); + } + }; + + const handleSaveAsNewTemplate = async () => { + setShowSaveTemplateDialog(true); + }; + + const onSaveTemplate = async (name: string) => { + if (!page || !name) return; + + try { + const pageId = `page-${page.id}`; + const layout = getLoadedPageLayout(pageId); + if (!layout) return; + + const { createLayout } = await import('@/lib/db'); + const newLayout = await createLayout({ + name, + layout_json: layout, + type: 'canvas', + visibility: 'private' + }); + + toast.success("Template created successfully"); + setActiveTemplateId(newLayout.id); + loadTemplates(); // Refresh list + } catch (e) { + console.error("Failed to create template", e); + toast.error("Failed to create template"); + } + }; + + + const handleNewLayout = async () => { + if (!page) return; + if (!confirm(translate("Are you sure you want to clear the layout?"))) return; + + try { + const pageId = `page-${page.id}`; + // Load empty layout + const emptyLayout = { + id: pageId, + containers: [], + name: 'Empty Layout', + createdAt: Date.now(), + updatedAt: Date.now() + }; + const jsonString = JSON.stringify(emptyLayout); + await importPageLayout(pageId, jsonString); // This uses ReplaceLayoutCommand so it's undoable! + setActiveTemplateId(null); + toast.success("Layout cleared"); + } catch (e) { + console.error("Failed to clear layout", e); + toast.error("Failed to clear layout"); + } + }; + + // 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]); + + // Export Layout Handler + const handleExportLayout = async () => { + if (!page) return; + try { + const pageIdStr = `page-${page.id}`; + // Note: we need the current layout state. useLayout provides exportPageLayout which gets it from store + const jsonData = await exportPageLayout(pageIdStr); + const blob = new Blob([jsonData], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${page.title.toLowerCase().replace(/\s+/g, '-')}-layout.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (error) { + console.error('Failed to export layout:', error); + toast.error("Failed to export layout"); + } + }; + + // Import Layout Handler + const handleImportLayout = () => { + if (!page) return; + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = async (e) => { + try { + const jsonData = e.target?.result as string; + await importPageLayout(`page-${page.id}`, jsonData); + setSelectedContainerId(null); + setSelectedWidgetId(null); + toast.success("Layout imported successfully"); + } catch (error) { + console.error('Failed to import layout:', error); + toast.error('Failed to import layout. Please check the file format.'); + } + }; + reader.readAsText(file); + } + }; + input.click(); + }; + + + // Delete Page Handler + const handleDeletePage = async () => { + if (!page) return; + if (!confirm(translate("Are you sure you want to delete this page?"))) return; + + try { + const { deletePage } = await import('@/lib/db'); + await deletePage(page.id); + toast.success(translate("Page deleted")); + + // Navigate away + const redirectUrl = orgSlug ? `/org/${orgSlug}/user/${userId}` : `/user/${userId}`; + window.location.href = redirectUrl; // Force reload to clear cache? Or just navigate. + } catch (e) { + console.error("Failed to delete page", e); + toast.error(translate("Failed to delete page")); + } + }; + + // Compute active widgets for ribbon state + const currentLayout = page ? getLoadedPageLayout(`page-${page.id}`) : null; + + + // Helper to resolve assigned types (moved from UserPageDetails) + const getAssignedTypes = () => { + const assignedTypesMap = new Map(); + if (page?.category_paths) { + page.category_paths.forEach(path => { + path.forEach(cat => { + if ((cat as any).assigned_types && Array.isArray((cat as any).assigned_types)) { + (cat as any).assigned_types.forEach((t: any) => { + assignedTypesMap.set(t.id, t); + }); + } + }); + }); + } + return Array.from(assignedTypesMap.values()); + }; + + const assignedTypes = getAssignedTypes(); + const hasTypeFields = assignedTypes.length > 0; + const [showTypeFields, setShowTypeFields] = useState(false); + + // Sidebar logic: Type Fields vs Widget Properties + // If widget selected, hide type fields. If type fields toggled, clear widget selection. + + useEffect(() => { + if (selectedWidgetId) { + setShowTypeFields(false); + } + }, [selectedWidgetId]); + + const handleToggleTypeFields = () => { + if (showTypeFields) { + setShowTypeFields(false); + } else { + setSelectedWidgetId(null); + setShowTypeFields(true); + } + }; + + return ( + <> + { + onExitEditMode(); + setSelectedWidgetId(null); + setSelectedContainerId(null); + setShowTypeFields(false); + }} + onPageUpdate={onPageUpdate} + onDelete={handleDeletePage} + onMetaUpdated={() => { }} + templates={templates} + onLoadTemplate={handleLoadTemplate} + onToggleWidget={handleWidgetClick} + + onAddContainer={handleAddContainer} + onUndo={undo} + onRedo={redo} + canUndo={canUndo} + canRedo={canRedo} + onExportLayout={handleExportLayout} + onImportLayout={handleImportLayout} + activeTemplateId={activeTemplateId || undefined} + onSaveToTemplate={handleSaveToTemplate} + onSaveAsNewTemplate={handleSaveAsNewTemplate} + onNewLayout={handleNewLayout} + showHierarchy={showHierarchy} + onToggleHierarchy={() => { + setShowHierarchy(!showHierarchy); + if (isSidebarCollapsed) setIsSidebarCollapsed(false); + }} + onToggleTypeFields={handleToggleTypeFields} + showTypeFields={showTypeFields} + hasTypeFields={hasTypeFields} + /> + +
+ {/* Sidebar Left */} + {(headings.length > 0 || childPages.length > 0 || showHierarchy) && ( + + )} + + {/* Right Content */} + + +
+
+ {/* Mobile TOC */} +
+ {headings.length > 0 && } +
+ + + + {/* 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 + + )} +
+
+
+
+
+ + {/* Right Sidebar - Property Panel OR Type Fields */} + {(selectedWidgetId || showTypeFields) && ( + <> + + +
+ {selectedWidgetId ? ( + + ) : showTypeFields ? ( +
+ onPageUpdate({ ...page, meta: newMeta })} + /> +
+ ) : null} +
+
+ + )} +
+
+ + setShowSaveTemplateDialog(false)} + onSave={onSaveTemplate} + /> + + ); +}; + +export default UserPageEdit;