ui:commands|fields|cats|ribbons

This commit is contained in:
lovebird 2026-02-11 18:38:54 +01:00
parent 06685c6530
commit 633df15f65
39 changed files with 4051 additions and 1152 deletions

View File

@ -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<ActionProviderProps> = ({ children }) => {
const { registerAction } = useActions();
useEffect(() => {
const defaults = createDefaultActions();
defaults.forEach(action => {
registerAction(action);
});
}, [registerAction]);
// TODO: Add keyboard shortcut listener here
return <>{children}</>;
};

View File

@ -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') }
}
];

View File

@ -0,0 +1,58 @@
import { create } from 'zustand';
import { Action, ActionState } from './types';
interface ActionStore extends ActionState {
// Internal state
}
export const useActionStore = create<ActionStore>((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<Action>) => 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));
}
}));

View File

@ -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<void>;
/**
* 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<Record<ActionVisibility, boolean>>;
/**
* Tooltip text
*/
tooltip?: string;
/**
* Any additional metadata
*/
metadata?: Record<string, any>;
/**
* Parent action ID if this is a sub-action
*/
parentId?: string;
}
export type ActionState = {
actions: Record<string, Action>;
registerAction: (action: Action) => void;
unregisterAction: (actionId: string) => void;
updateAction: (actionId: string, updates: Partial<Action>) => void;
getAction: (actionId: string) => Action | undefined;
getActionsByGroup: (group: string) => Action[];
getActionsByPath: (path: string) => Action[];
}

View File

@ -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
};
};

View File

@ -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" */}
<Button
variant="outline"
size="sm"
onClick={(e) => { e.stopPropagation(); setShowPagePicker(true); }}
className="text-muted-foreground hover:text-foreground"
title={translate("Set Parent Page")}
>
<GitMerge className="h-4 w-4" />
{showLabels && <span className="ml-2 hidden md:inline"><T>Parent</T></span>}
</Button>
<PagePickerDialog
isOpen={showPagePicker}
onClose={() => setShowPagePicker(false)}
onSelect={handleParentUpdate}
currentValue={page.parent}
forbiddenIds={[page.id]}
/>
<Button
variant="outline"
size="sm"
onClick={(e) => { e.stopPropagation(); setShowCreationWizard(true); }}
className="text-muted-foreground hover:text-foreground"
title={translate("Add Child Page")}
>
<FilePlus className="h-4 w-4" />
{showLabels && <span className="ml-2 hidden md:inline"><T>Add Child</T></span>}
</Button>
<PageCreationWizard
isOpen={showCreationWizard}
onClose={() => setShowCreationWizard(false)}
parentId={page.id}
/>
{/* Dev Mode: Dump JSON */}
{import.meta.env.DEV && (

View File

@ -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}`);
}
}
});

View File

@ -278,26 +278,6 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
</>
)}
</Button>
<Button
onClick={handleImportLayout}
size="sm"
className="glass-button"
title="Import layout"
>
<Upload className="h-4 w-4 mr-2" />
<T>Import</T>
</Button>
<Button
onClick={handleExportLayout}
size="sm"
className="glass-button"
title="Export layout"
>
<Download className="h-4 w-4 mr-2" />
<T>Export</T>
</Button>
</div>
)}
</div>

View File

@ -123,6 +123,7 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
onMove={onMoveWidget}
isEditing={editingWidgetId === widget.id}
onEditWidget={onEditWidget}
isNew={newlyAddedWidgetId === widget.id}
/>
))}
@ -522,9 +523,9 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
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<WidgetItemProps> = ({
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}
/>
)
}

View File

@ -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 (
<div className="select-none">
<div
className={cn(
"flex items-center gap-1.5 py-1 pr-2 cursor-pointer hover:bg-muted/50 transition-colors text-sm rounded-sm group",
isSelected && "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 font-medium"
)}
style={{ paddingLeft: `${depth * 12 + 4}px` }}
onClick={onClick}
>
<div
className={cn(
"p-0.5 rounded transition-colors flex items-center justify-center w-5 h-5",
hasChildren ? "hover:bg-black/5 dark:hover:bg-white/10 cursor-pointer" : "opacity-0 pointer-events-none"
)}
onClick={(e) => {
e.stopPropagation();
onToggleExpand?.();
}}
>
{hasChildren && (
isExpanded ? <ChevronDown className="h-3 w-3 opacity-50" /> : <ChevronRight className="h-3 w-3 opacity-50" />
)}
</div>
<Icon className={cn("h-3.5 w-3.5 shrink-0", isSelected ? "opacity-100" : "opacity-60")} />
<span className="truncate text-xs">{label}</span>
</div>
{hasChildren && isExpanded && (
<div>{children}</div>
)}
</div>
);
};
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<Set<string>>(new Set());
// Auto-expand all containers on load/change
React.useEffect(() => {
const allIds = new Set<string>();
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 (
<TreeNode
key={widget.id}
label={name}
icon={Icon}
isSelected={selectedWidgetId === widget.id}
onClick={(e) => {
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 (
<React.Fragment key={container.id}>
<TreeNode
label={title}
icon={LayoutGrid}
isSelected={selectedContainerId === container.id}
onClick={() => 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))}
</>
)}
</TreeNode>
</React.Fragment>
);
};
if (!containers || containers.length === 0) {
return (
<div className="px-4 py-8 text-center text-xs text-muted-foreground flex flex-col items-center gap-2">
<LayoutGrid className="h-8 w-8 opacity-20" />
<p>No layout elements</p>
</div>
);
}
return (
<div className="pb-2">
{containers.map(c => renderContainer(c, 0))}
</div>
);
};

View File

@ -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<SaveTemplateDialogProps> = ({
isOpen,
onClose,
onSave
}) => {
const [name, setName] = useState('');
const handleSave = () => {
if (name.trim()) {
onSave(name.trim());
setName('');
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle><T>Save as New Template</T></DialogTitle>
<DialogDescription>
<T>Enter a unique name for your new layout template.</T>
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={translate("Template Name")}
className="col-span-4"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSave();
}
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}><T>Cancel</T></Button>
<Button onClick={handleSave} disabled={!name.trim()}><T>Save</T></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -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<UserPageDetailsProps> = ({
const [slugError, setSlugError] = useState<string | null>(null);
const [savingField, setSavingField] = useState<string | null>(null);
const { executeCommand } = useLayout();
const checkSlugCollision = async (newSlug: string): Promise<boolean> => {
if (newSlug === page?.slug) return false;
try {
@ -120,18 +124,25 @@ export const UserPageDetails: React.FC<UserPageDetailsProps> = ({
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<UserPageDetailsProps> = ({
}
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<UserPageDetailsProps> = ({
.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<UserPageDetailsProps> = ({
}}
autoFocus
disabled={savingField === 'title'}
data-testid="page-title-input"
/>
<Button
size="sm"
variant="ghost"
onClick={handleSaveTitle}
disabled={savingField === 'title'}
data-testid="page-title-save"
>
<Check className="h-4 w-4" />
</Button>
@ -283,6 +286,7 @@ export const UserPageDetails: React.FC<UserPageDetailsProps> = ({
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}
</h1>
@ -433,6 +437,8 @@ export const UserPageDetails: React.FC<UserPageDetailsProps> = ({
</div>
)}
</div>
</div >
);
};

View File

@ -22,18 +22,8 @@ export const UserPageTopBar: React.FC<UserPageTopBarProps> = ({
if (embedded) return null;
return (
<div className="border-b bg-background/95 backdrop-blur z-10 shrink-0">
<div className="container mx-auto py-2 flex items-center justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(orgSlug ? `/org/${orgSlug}/user/${userId}` : `/user/${userId}`)}
className="text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"><T>Back to profile</T></span>
</Button>
</div>
<div className="">
</div>
);
};

View File

@ -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<UserPageTypeFieldsProps> = ({
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<Record<string, any>>(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 <div className="flex justify-center p-4"><Loader2 className="animate-spin h-6 w-6 text-muted-foreground" /></div>;
}
return (
<div className="space-y-6 mt-8">
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Type Properties</h2>
<Accordion type="multiple" className="w-full">
{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 (
<AccordionItem key={type.id} value={type.id} className="border-b last:border-0 border-border">
<AccordionTrigger className="px-4 py-3 hover:no-underline hover:bg-muted/50 transition-colors">
<div className="flex flex-col items-start text-left">
<span className="font-semibold text-sm tracking-tight">{type.name}</span>
{type.description && (
<span className="text-[10px] text-muted-foreground font-normal mt-0.5 max-w-[200px] truncate">
{type.description}
</span>
)}
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4 pt-2">
<Form
schema={schema}
uiSchema={finalUiSchema}
formData={typeData}
validator={validator}
widgets={customWidgets}
templates={customTemplates}
onChange={(e) => isEditMode && handleFormChange(type.id, e.formData)}
onSubmit={(e) => handleFormSubmit(type.id, e.formData)}
readonly={!isEditMode}
className={isEditMode ? "" : "pointer-events-none opacity-80"}
>
{isEditMode ? (
<div className="flex justify-end mt-4">
<button type="submit" className="bg-primary text-primary-foreground hover:bg-primary/90 px-4 py-2 rounded-md text-xs font-medium transition-colors">
Save {type.name} Values
</button>
</div>
) : <></>}
</Form>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</div>
);
};

View File

@ -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<string>;
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
}) => (
<button
onClick={onClick}
disabled={disabled}
{...props}
className={cn(
"flex items-center gap-2 px-2 py-0.5 h-7 w-full text-left rounded-sm transition-colors text-xs font-medium group/btn",
!disabled && "hover:bg-accent/60",
@ -157,23 +200,41 @@ export const PageRibbonBar = ({
onMetaUpdated,
templates,
onLoadTemplate,
onAddWidget,
onToggleWidget,
onAddContainer,
className,
onUndo,
onRedo,
canUndo = false,
canRedo = false
canRedo = false,
onImportLayout,
onExportLayout,
activeTemplateId,
onSaveToTemplate,
onSaveAsNewTemplate,
onNewLayout,
activeWidgets = new Set(),
showHierarchy,
onToggleHierarchy,
onToggleTypeFields,
showTypeFields,
hasTypeFields
}: PageRibbonBarProps) => {
const [activeTab, setActiveTab] = useState<'page' | 'insert' | 'view' | 'advanced'>('page');
const { executeCommand, saveToApi, loadPageLayout, clearHistory } = useLayout();
const [activeTab, setActiveTab] = useState<'page' | 'widgets' | 'layouts' | 'view' | 'advanced'>('page');
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(false);
const [showCategoryManager, setShowCategoryManager] = useState(false);
const [showPagePicker, setShowPagePicker] = useState(false);
const [showCreationWizard, setShowCreationWizard] = useState(false);
const [showCancelDialog, setShowCancelDialog] = useState(false);
const { updateAction, getActionsByGroup } = useActions();
// Logic duplicated from PageActions
const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
const invalidatePageCache = async () => {
const invalidatePageCache = React.useCallback(async () => {
try {
const session = await supabase.auth.getSession();
const token = session.data.session?.access_token;
@ -197,57 +258,135 @@ export const PageRibbonBar = ({
} catch (e) {
console.error('Failed to invalidate cache:', e);
}
}, [page.owner, page.slug, baseUrl]);
const handleParentUpdate = React.useCallback(async (newParentPage: any | null) => {
if (loading) return;
setLoading(true);
// Construct old/new parent details for Undo/Redo
const oldParent = {
id: page.parent,
title: page.parent_page?.title,
slug: page.parent_page?.slug
};
const handleToggleVisibility = async (e?: React.MouseEvent) => {
const newParent = newParentPage ? {
id: newParentPage.id,
title: newParentPage.title,
slug: newParentPage.slug
} : null;
try {
await executeCommand(new UpdatePageParentCommand(
page.id,
oldParent,
newParent,
async (parentDetails) => {
// Callback for UI updates (executed on do and undo)
const updatedPage = { ...page, parent: parentDetails?.id ?? null };
// Optimistically update parent_page object for UI
if (parentDetails && parentDetails.title && parentDetails.slug) {
updatedPage.parent_page = {
title: parentDetails.title,
slug: parentDetails.slug
};
} else {
updatedPage.parent_page = null;
}
onPageUpdate(updatedPage);
}
));
toast.success(translate('Page parent updated'));
} catch (error) {
console.error('Error updating page parent:', error);
toast.error(translate('Failed to update page parent'));
} finally {
setLoading(false);
}
}, [loading, page, executeCommand, onPageUpdate]);
const handleFinish = React.useCallback(async () => {
if (loading) return;
setLoading(true);
try {
await saveToApi();
toast.success(translate('Page saved'));
onToggleEditMode();
} catch (e) {
console.error(e);
toast.error(translate('Failed to save'));
} finally {
setLoading(false);
}
}, [loading, saveToApi, onToggleEditMode]);
const performCancel = React.useCallback(async () => {
try {
setLoading(true);
await loadPageLayout(page.id); // Revert to server state
clearHistory(); // Clear undo stack
onToggleEditMode(); // Exit edit mode
toast.info(translate('Changes discarded'));
} catch (error) {
console.error("Failed to discard changes", error);
toast.error(translate('Error discarding changes'));
} finally {
setLoading(false);
setShowCancelDialog(false);
}
}, [page.id, loadPageLayout, clearHistory, onToggleEditMode]);
const handleCancel = React.useCallback(() => {
if (!canUndo) {
// No changes to discard, just exit
onToggleEditMode();
return;
}
setShowCancelDialog(true);
}, [canUndo, onToggleEditMode]);
const handleToggleVisibility = React.useCallback(async (e?: React.MouseEvent) => {
e?.stopPropagation();
if (loading) return;
setLoading(true);
try {
const { error } = await supabase
.from('pages')
.update({ visible: !page.visible })
.eq('id', page.id);
if (error) throw error;
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'));
invalidatePageCache();
} catch (error) {
console.error('Error toggling visibility:', error);
toast.error(translate('Failed to update page visibility'));
} finally {
setLoading(false);
}
};
}, [loading, page, onPageUpdate]);
const handleTogglePublic = async (e?: React.MouseEvent) => {
const handleTogglePublic = React.useCallback(async (e?: React.MouseEvent) => {
e?.stopPropagation();
if (loading) return;
setLoading(true);
try {
const { error } = await supabase
.from('pages')
.update({ is_public: !page.is_public })
.eq('id', page.id);
if (error) throw error;
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'));
} finally {
setLoading(false);
}
};
}, [loading, page, onPageUpdate]);
const handleDumpJson = async () => {
const handleDumpJson = React.useCallback(async () => {
try {
const pageJson = JSON.stringify(page, null, 2);
console.log('Page JSON:', pageJson);
@ -257,11 +396,23 @@ export const PageRibbonBar = ({
console.error("Failed to dump JSON", e);
toast.error("Failed to dump JSON");
}
};
}, [page]);
// Sync actions with props
React.useEffect(() => {
if (onUndo) updateAction(UNDO_ACTION_ID, { handler: onUndo, disabled: !canUndo });
if (onRedo) updateAction(REDO_ACTION_ID, { handler: onRedo, disabled: !canRedo });
updateAction(FINISH_ACTION_ID, { handler: handleFinish });
updateAction(CANCEL_ACTION_ID, { handler: handleCancel });
}, [onUndo, onRedo, canUndo, canRedo, updateAction, handleFinish, handleCancel]);
const historyActions = getActionsByGroup('History').filter(a => a.visibilities?.Ribbon);
const exitActions = getActionsByGroup('Exit').filter(a => a.visibilities?.Ribbon);
if (!isOwner) return null;
return (
<ActionProvider>
<div className={cn("flex flex-col w-full bg-background border-b shadow-sm relative z-40", className)}>
{/* Context & Tabs Row */}
<div className="flex items-center border-b bg-muted/30">
@ -270,15 +421,66 @@ export const PageRibbonBar = ({
</div>
<div className="flex-1 flex overflow-x-auto scrollbar-none pl-2">
<RibbonTab active={activeTab === 'page'} onClick={() => setActiveTab('page')}><T>PAGE</T></RibbonTab>
<RibbonTab active={activeTab === 'insert'} onClick={() => setActiveTab('insert')}><T>INSERT</T></RibbonTab>
<RibbonTab active={activeTab === 'widgets'} onClick={() => setActiveTab('widgets')}><T>WIDGETS</T></RibbonTab>
<RibbonTab active={activeTab === 'layouts'} onClick={() => setActiveTab('layouts')}><T>LAYOUTS</T></RibbonTab>
<RibbonTab active={activeTab === 'view'} onClick={() => setActiveTab('view')}><T>VIEW</T></RibbonTab>
<RibbonTab active={activeTab === 'advanced'} onClick={() => setActiveTab('advanced')}><T>ADVANCED</T></RibbonTab>
</div>
</div>
{/* Ribbon Toolbar Area */}
<div className="h-24 flex items-center bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 overflow-x-auto shadow-inner px-2">
<div className="h-24 flex items-center bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 shadow-inner px-0">
{/* Fixed 'Finish' Section - Always Visible */}
<div className="flex-none flex h-full items-center border-r border-border/40 bg-background/50 z-10 px-1">
<RibbonGroup label="Exit">
{exitActions.map(action => (
<RibbonItemLarge
key={action.id}
icon={action.icon}
label={action.label}
onClick={() => action.handler && action.handler()}
active={action.id === FINISH_ACTION_ID} // Finish is active in original
iconColor={action.id === FINISH_ACTION_ID ? "text-green-600 dark:text-green-400" : "text-red-500 dark:text-red-400"}
/>
))}
</RibbonGroup>
<RibbonGroup label="History">
{historyActions.map(action => (
<RibbonItemLarge
key={action.id}
icon={action.icon}
label={action.label}
onClick={() => action.handler && action.handler()}
disabled={action.disabled}
iconColor="text-purple-600 dark:text-purple-400"
/>
))}
</RibbonGroup>
{/* Fields Toggle */}
<RibbonGroup label="Data">
<RibbonItemLarge
icon={Database}
label="Fields"
onClick={onToggleTypeFields}
active={showTypeFields}
disabled={!hasTypeFields}
iconColor="text-teal-600 dark:text-teal-400"
/>
</RibbonGroup>
</div>
{/* Scrollable Content Section */}
<div
ref={scrollContainerRef}
onWheel={(e) => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollLeft += e.deltaY;
}
}}
className="flex-1 flex items-center h-full overflow-x-auto overflow-y-hidden scrollbar-custom px-2"
>
{/* === PAGE TAB === */}
{activeTab === 'page' && (
<>
@ -290,6 +492,7 @@ export const PageRibbonBar = ({
active={!page.visible}
onClick={handleToggleVisibility}
iconColor={page.visible ? "text-emerald-500" : "text-gray-400"}
data-testid="page-visibility-toggle"
/>
<RibbonItemSmall
icon={page.is_public ? GitMerge : Settings}
@ -297,6 +500,7 @@ export const PageRibbonBar = ({
active={!page.is_public}
onClick={handleTogglePublic}
iconColor={page.is_public ? "text-amber-500" : "text-gray-400"}
data-testid="page-public-toggle"
/>
</div>
<RibbonItemLarge
@ -305,32 +509,36 @@ export const PageRibbonBar = ({
onClick={() => setShowCategoryManager(true)}
iconColor="text-yellow-600 dark:text-yellow-400"
/>
</RibbonGroup>
<RibbonGroup label="History">
<div className="flex gap-1">
<RibbonItemLarge
icon={Undo2}
label="Undo"
onClick={onUndo}
disabled={!canUndo}
iconColor="text-purple-600 dark:text-purple-400"
icon={GitMerge}
label="Parent"
onClick={() => setShowPagePicker(true)}
iconColor="text-orange-500 dark:text-orange-400"
/>
<RibbonItemLarge
icon={Redo2}
label="Redo"
onClick={onRedo}
disabled={!canRedo}
iconColor="text-purple-600 dark:text-purple-400"
icon={FilePlus}
label="Add Child"
onClick={() => setShowCreationWizard(true)}
iconColor="text-green-600 dark:text-green-500"
/>
</div>
</RibbonGroup>
<RibbonGroup label="Actions">
<RibbonItemLarge
icon={Save}
label="Update"
onClick={onMetaUpdated}
onClick={async () => {
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 && (
@ -344,92 +552,179 @@ export const PageRibbonBar = ({
)}
</RibbonGroup>
<RibbonGroup label="Layouts">
<RibbonGroup label="File">
<RibbonItemLarge
icon={Upload}
label="Import"
onClick={onImportLayout}
iconColor="text-blue-500"
/>
<RibbonItemLarge
icon={Download}
label="Export"
onClick={onExportLayout}
iconColor="text-blue-500"
/>
</RibbonGroup>
</>
)}
{/* === 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 (
<>
<RibbonGroup label="Structure">
{/* Add Container Action */}
<RibbonItemLarge
icon={Grid}
label="Container"
onClick={onAddContainer}
iconColor="text-cyan-500"
/>
{structureWidgets.map(widget => (
<RibbonItemLarge
key={widget.metadata.id}
icon={widget.metadata.icon}
label={widget.metadata.name}
onClick={() => 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"}
/>
))}
</RibbonGroup>
{mediaWidgets.length > 0 && (
<RibbonGroup label="Media">
{mediaWidgets.map(widget => (
<RibbonItemLarge
key={widget.metadata.id}
icon={widget.metadata.icon}
label={widget.metadata.name}
onClick={() => 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"}
/>
))}
</RibbonGroup>
)}
{contentWidgets.length > 0 && (
<RibbonGroup label="Content">
{contentWidgets.map(widget => (
<RibbonItemLarge
key={widget.metadata.id}
icon={widget.metadata.icon}
label={widget.metadata.name}
onClick={() => 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"}
/>
))}
</RibbonGroup>
)}
{advancedWidgets.length > 0 && (
<RibbonGroup label="Advanced">
{advancedWidgets.map(widget => (
<RibbonItemLarge
key={widget.metadata.id}
icon={widget.metadata.icon}
label={widget.metadata.name}
onClick={() => 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"}
/>
))}
</RibbonGroup>
)}
</>
);
})()}
{/* === LAYOUTS TAB === */}
{activeTab === 'layouts' && (
<>
<RibbonGroup label="Actions">
<RibbonItemLarge
icon={FilePlus}
label="New Layout"
onClick={onNewLayout}
iconColor="text-green-500"
/>
{onSaveAsNewTemplate && (
<RibbonItemLarge
icon={Save}
label="Save as New"
onClick={onSaveAsNewTemplate}
iconColor="text-blue-500"
/>
)}
{activeTemplateId && onSaveToTemplate && (
<RibbonItemLarge
icon={Save}
label="Update Layout"
onClick={onSaveToTemplate}
iconColor="text-pink-600 dark:text-pink-400"
/>
)}
</RibbonGroup>
<RibbonGroup label="Templates">
{templates?.map(t => (
<RibbonItemLarge
key={t.id}
icon={LayoutTemplate}
label={t.name}
onClick={() => onLoadTemplate?.(t)}
active={t.id === activeTemplateId}
iconColor="text-indigo-500 dark:text-indigo-400"
/>
))}
{(!templates || templates.length === 0) && (
<div className="text-xs text-muted-foreground px-2 italic"><T>No Layouts</T></div>
<div className="text-xs text-muted-foreground px-2 italic"><T>No Templates</T></div>
)}
</RibbonGroup>
</>
)}
{/* === INSERT TAB === */}
{activeTab === 'insert' && (
<>
<RibbonGroup label="Layout">
<RibbonItemLarge
icon={Grid}
label="Add Container"
onClick={onAddContainer}
iconColor="text-cyan-500"
/>
</RibbonGroup>
{/* Dynamic Widget Groups */}
{Array.from(new Set(widgetRegistry.getAll().map(w => w.metadata.category)))
.filter(cat => cat !== 'system' && cat !== 'hidden') // Filter internal categories if needed
.sort((a, b) => {
// Custom sort order: display, chart, control, others
const order = { display: 1, chart: 2, control: 3, custom: 4 };
return (order[a as keyof typeof order] || 99) - (order[b as keyof typeof order] || 99);
})
.map(category => {
const widgets = widgetRegistry.getByCategory(category);
if (widgets.length === 0) return null;
return (
<RibbonGroup key={category} label={category.charAt(0).toUpperCase() + category.slice(1)}>
<div className="flex flex-col flex-wrap justify-center h-full gap-y-1">
{/* If we have many items, use small items in grid/column, else large */}
{widgets.length <= 2 ? (
widgets.map(widget => (
<RibbonItemLarge
key={widget.metadata.id}
icon={widget.metadata.icon}
label={widget.metadata.name}
onClick={() => onAddWidget?.(widget.metadata.id)}
iconColor="text-blue-600 dark:text-blue-400"
/>
))
) : (
<div className="grid grid-cols-2 gap-x-2 gap-y-1">
{widgets.map(widget => (
<RibbonItemSmall
key={widget.metadata.id}
icon={widget.metadata.icon}
label={widget.metadata.name}
onClick={() => onAddWidget?.(widget.metadata.id)}
iconColor="text-blue-600 dark:text-blue-400"
/>
))}
</div>
)}
</div>
</RibbonGroup>
);
})
}
</>
)}
{/* === VIEW TAB === */}
{activeTab === 'view' && (
<>
<RibbonGroup label="Mode">
<RibbonGroup label="Panels">
<RibbonItemLarge
icon={MousePointer2}
label="Finish Edit"
onClick={onToggleEditMode}
active
iconColor="text-green-600 dark:text-green-400"
icon={ListTree}
label="Hierarchy"
onClick={onToggleHierarchy}
active={showHierarchy}
iconColor="text-indigo-500"
/>
</RibbonGroup>
</>
@ -448,16 +743,6 @@ export const PageRibbonBar = ({
</RibbonGroup>
</>
)}
{/* Always visible 'Finish' on far right? Or just in View tab. Fusion has 'Finish Sketch' big checkmark */}
<div className="ml-auto px-4 border-l flex items-center">
<Button
onClick={onToggleEditMode}
className="gap-2 bg-green-600 hover:bg-green-700 text-white shadow-md hover:shadow-lg transition-all"
>
<MousePointer2 className="h-4 w-4" />
<span className="font-semibold tracking-wide text-xs">FINISH</span>
</Button>
</div>
</div>
@ -469,13 +754,28 @@ export const PageRibbonBar = ({
currentPageId={page.id}
currentPageMeta={page.meta}
onPageMetaUpdate={async (newMeta) => {
// Similar logic to handleMetaUpdate in PageActions
// Use UpdatePageMetaCommand for undo/redo support
const pageId = `page-${page.id}`;
// Construct old meta from current page.meta
// We only need keys present in newMeta
const oldMeta: Record<string, any> = {};
Object.keys(newMeta).forEach(key => {
oldMeta[key] = page.meta?.[key] ?? null;
});
try {
const { updatePageMeta } = await import('@/lib/db');
await updatePageMeta(page.id, newMeta);
invalidatePageCache();
onPageUpdate({ ...page, meta: newMeta });
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'));
@ -484,7 +784,39 @@ export const PageRibbonBar = ({
filterByType="pages"
defaultMetaType="pages"
/>
<PagePickerDialog
isOpen={showPagePicker}
onClose={() => setShowPagePicker(false)}
onSelect={handleParentUpdate}
currentValue={page.parent}
forbiddenIds={[page.id]}
/>
<PageCreationWizard
isOpen={showCreationWizard}
onClose={() => setShowCreationWizard(false)}
parentId={page.id}
/>
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{translate('Discard changes?')}</AlertDialogTitle>
<AlertDialogDescription>
{translate('You have unsaved changes. Are you sure you want to discard them and exit?')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{translate('Keep Editing')}</AlertDialogCancel>
<AlertDialogAction onClick={performCancel} className="bg-red-600 hover:bg-red-700">
{translate('Discard & Exit')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</ActionProvider>
);
};

View File

@ -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
</Select>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input
value={editingCategory.description || ''}
onChange={(e) => setEditingCategory({ ...editingCategory, description: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Assigned Types</Label>
<div className="border rounded-md p-2 max-h-40 overflow-y-auto space-y-2">
{types.length === 0 && <div className="text-xs text-muted-foreground p-1">No assignable types found.</div>}
{types.map(type => (
<div key={type.id} className="flex items-center gap-2">
<Checkbox
id={`type-${type.id}`}
checked={(editingCategory.meta?.assignedTypes || []).includes(type.id)}
onCheckedChange={(checked) => {
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
}
});
}}
/>
<Label htmlFor={`type-${type.id}`} className="text-sm font-normal cursor-pointer">
{type.name} <span className="text-xs text-muted-foreground">({type.kind})</span>
</Label>
</div>
))}
</div>
<div className="text-[10px] text-muted-foreground">
Assign types to allow using them in this category.
</div>
</div>
<Button className="w-full" onClick={handleSave} disabled={actionLoading}>
{actionLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save

View File

@ -14,6 +14,10 @@ interface GalleryWidgetProps {
pictureIds?: string[];
thumbnailLayout?: 'strip' | 'grid';
imageFit?: 'contain' | 'cover';
thumbnailsPosition?: 'bottom' | 'top' | 'left' | 'right';
thumbnailsOrientation?: 'horizontal' | 'vertical';
zoomEnabled?: boolean;
thumbnailsClassName?: string;
onPropsChange?: (props: Record<string, any>) => void;
isEditMode?: boolean;
id?: string;
@ -23,6 +27,10 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
pictureIds: propPictureIds = [],
thumbnailLayout = 'strip',
imageFit = 'cover',
thumbnailsPosition = 'bottom',
thumbnailsOrientation = 'horizontal',
zoomEnabled = false,
thumbnailsClassName = '',
onPropsChange,
isEditMode = false,
id
@ -69,8 +77,6 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
const items = await fetchMediaItemsByIds(pictureIds, { maintainOrder: true });
console.log('Fetched media items:', items);
// Transform to PostMediaItem format
const postMediaItems = items.map((item, index) => ({
...item,
@ -81,13 +87,10 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
comments: [{ count: 0 }]
})) as PostMediaItem[];
console.log('Transformed to PostMediaItems:', postMediaItems);
setMediaItems(postMediaItems);
// Always set first item as selected when items change
if (postMediaItems.length > 0) {
console.log('Setting selected item:', postMediaItems[0]);
setSelectedItem(postMediaItems[0]);
}
} catch (error) {
@ -202,6 +205,10 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
showDesktopLayout={true}
thumbnailLayout={thumbnailLayout}
imageFit={imageFit}
thumbnailsPosition={thumbnailsPosition}
thumbnailsOrientation={thumbnailsOrientation}
zoomEnabled={zoomEnabled}
thumbnailsClassName={thumbnailsClassName}
className="h-full w-full [&_.hidden]:!block"
/>
</div>

View File

@ -0,0 +1,90 @@
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { T } from '@/i18n';
import { cn } from '@/lib/utils';
import * as Icons from 'lucide-react';
// Common icons suitable for tabs
const COMMON_ICONS = [
'Layout', 'Monitor', 'Smartphone', 'Tablet', 'Grid', 'List', 'Type', 'Image', 'Video', 'FileText',
'Settings', 'User', 'Users', 'Home', 'Calendar', 'Clock', 'Map', 'Link', 'ExternalLink',
'Star', 'Heart', 'Bell', 'Search', 'Menu', 'X', 'Check', 'Plus', 'Minus', 'Info', 'AlertCircle',
'ChevronRight', 'ChevronDown', 'ArrowRight', 'ArrowLeft', 'Layers', 'Box', 'Package', 'ShoppingBag',
'CreditCard', 'DollarSign', 'PieChart', 'BarChart', 'Activity', 'Zap', 'Terminal', 'Code', 'Database'
];
interface IconPickerProps {
value?: string;
onChange: (iconName: string) => void;
className?: string;
}
export const IconPicker: React.FC<IconPickerProps> = ({ value, onChange, className }) => {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const filteredIcons = COMMON_ICONS.filter(iconName =>
iconName.toLowerCase().includes(search.toLowerCase())
);
const SelectedIcon = value && (Icons as any)[value] ? (Icons as any)[value] : Icons.Square;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn("w-10 h-8 p-0 shrink-0", className)}
title={value || "Select Icon"}
>
<SelectedIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-2 w-64" align="start">
<div className="space-y-2">
<Input
placeholder="Search icons..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 text-xs"
/>
<ScrollArea className="h-48">
<div className="grid grid-cols-5 gap-1">
{filteredIcons.map(iconName => {
const Icon = (Icons as any)[iconName];
if (!Icon) return null;
return (
<Button
key={iconName}
variant="ghost"
size="sm"
className={cn(
"h-8 w-8 p-0 hover:bg-slate-100 dark:hover:bg-slate-800",
value === iconName && "bg-slate-100 dark:bg-slate-800 text-primary border border-primary/20"
)}
onClick={() => {
onChange(iconName);
setOpen(false);
}}
title={iconName}
>
<Icon className="h-4 w-4" />
</Button>
);
})}
</div>
{filteredIcons.length === 0 && (
<div className="p-4 text-center text-xs text-slate-500">
<T>No icons found</T>
</div>
)}
</ScrollArea>
</div>
</PopoverContent>
</Popover>
);
};

View File

@ -117,7 +117,8 @@ const PageCardWidget: React.FC<PageCardWidgetProps> = ({
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);

View File

@ -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<PagePickerDialogProps> = ({
}, [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<PagePickerDialogProps> = ({
onClick={() => setSelectedId(page.id)}
onDoubleClick={() => {
setSelectedId(page.id);
onSelect(page.id);
onSelect(page);
onClose();
}}
className={cn(

View File

@ -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<TabDefinition>) => void;
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: tab.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style} className="flex flex-col gap-2 p-3 bg-slate-50 dark:bg-slate-800/50 rounded-md border border-slate-200 dark:border-slate-700/50 mb-2">
<div className="flex items-center gap-2">
<button {...attributes} {...listeners} className="cursor-grab hover:text-slate-900 dark:hover:text-slate-200 text-slate-400">
<GripVertical className="h-4 w-4" />
</button>
<IconPicker
value={tab.icon}
onChange={(newIcon) => onUpdate(tab.id, { icon: newIcon })}
/>
<Input
value={tab.label}
onChange={(e) => onUpdate(tab.id, { label: e.target.value })}
className="h-8 text-sm flex-1"
placeholder="Tab Label"
/>
<Button
variant="ghost"
size="sm"
onClick={() => onRemove(tab.id)}
className="h-8 w-8 p-0 text-slate-400 hover:text-red-500"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Optional: Icon picker or other details here */}
<div className="pl-6 text-[10px] text-slate-400 font-mono truncate">
ID: {tab.layoutId}
</div>
</div>
);
};
export const TabsPropertyEditor: React.FC<TabsPropertyEditorProps> = ({
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-<widgetId>-<slugged-tab-name>
// 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-<widgetId>-<slugged-tab-name>
// 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<TabDefinition>) => {
onChange(value.map(t => t.id === id ? { ...t, ...updates } : t));
};
return (
<div className="space-y-4">
<div className="text-xs text-slate-500">
<T>Manage your tabs below. Drag to reorder.</T>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={value.map(t => t.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{value.map((tab) => (
<SortableTabItem
key={tab.id}
tab={tab}
onRemove={handleRemoveTab}
onUpdate={handleUpdateTab}
/>
))}
</div>
</SortableContext>
</DndContext>
<Button
onClick={handleAddTab}
variant="outline"
size="sm"
className="w-full border-dashed"
>
<Plus className="h-4 w-4 mr-2" />
<T>Add Tab</T>
</Button>
</div>
);
};

View File

@ -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<string, any>) => void;
}
const TabsWidget: React.FC<TabsWidgetProps> = ({
widgetInstanceId,
tabs = [],
activeTabId,
orientation = 'horizontal',
tabBarPosition = 'top',
className = '',
tabBarClassName = '',
contentClassName = '',
isEditMode = false,
onPropsChange
}) => {
const [currentTabId, setCurrentTabId] = useState<string | undefined>(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 ? <Icon className="w-4 h-4 mr-2" /> : 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 (
<div className="flex items-center justify-center p-8 border-2 border-dashed border-slate-300 dark:border-slate-700 rounded-lg">
<div className="text-center text-slate-500">
<LucideIcons.Layers className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p><T>No tabs configured.</T></p>
{isEditMode && <p className="text-xs mt-1"><T>Add tabs in widget settings.</T></p>}
</div>
</div>
);
}
return (
<div className={cn("flex w-full h-full min-h-[300px]", flexDirection, className)}>
{/* Tab Bar */}
<div className={tabBarClasses}>
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => handleTabClick(tab.id)}
className={tabButtonClasses(tab.id === currentTabId)}
>
{renderIcon(tab.icon)}
<span>{tab.label}</span>
</button>
))}
</div>
{/* Content Area */}
<div className={cn("flex-1 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-b-md relative overflow-hidden", contentClassName)}>
{currentTab ? (
<GenericCanvas
key={currentTab.layoutId} // Important: force remount so GenericCanvas loads new pageId
pageId={currentTab.layoutId}
pageName={currentTab.label}
isEditMode={isEditMode}
showControls={false} // Tabs usually hide nested canvas controls to look cleaner
className="p-4"
/>
) : (
<div className="flex items-center justify-center h-full text-slate-400">
<T>Select a tab</T>
</div>
)}
</div>
</div>
);
};
export default TabsWidget;

View File

@ -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<WidgetPropertiesFormProps> = ({
const [settings, setSettings] = useState<Record<string, any>>(currentProps);
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const [imagePickerField, setImagePickerField] = useState<string | null>(null);
const [pagePickerOpen, setPagePickerOpen] = useState(false);
const [pagePickerField, setPagePickerField] = useState<string | null>(null);
const [markdownEditorOpen, setMarkdownEditorOpen] = useState(false);
const [activeMarkdownField, setActiveMarkdownField] = useState<string | null>(null);
@ -189,6 +193,44 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
</div>
);
case 'pagePicker':
return (
<div key={key} className="space-y-2">
<Label htmlFor={key} className="text-xs font-medium text-slate-500 dark:text-slate-400">
<T>{config.label}</T>
</Label>
<div className="flex gap-2">
<Input
id={key}
type="text"
value={value || ''}
onChange={(e) => updateSetting(key, e.target.value)}
placeholder={config.default || 'No page selected'}
className="flex-1 font-mono text-[10px] h-8"
readOnly
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 px-2"
onClick={() => {
setPagePickerField(key);
setPagePickerOpen(true);
}}
>
<FileText className="h-3 w-3 mr-1" />
<span className="text-xs"><T>Select Page</T></span>
</Button>
</div>
{config.description && (
<p className="text-[10px] text-slate-400 dark:text-slate-500">
<T>{config.description}</T>
</p>
)}
</div>
);
case 'markdown':
return (
<div key={key} className="space-y-2">
@ -225,6 +267,45 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
</div>
);
case 'classname':
return (
<div key={key} className="space-y-1">
<Label htmlFor={key} className="text-xs font-medium text-slate-500 dark:text-slate-400">
<T>{config.label}</T>
</Label>
<TailwindClassPicker
value={value || ''}
onChange={(newValue) => updateSetting(key, newValue)}
placeholder={config.default || 'Select classes...'}
className="w-full"
/>
{config.description && (
<p className="text-[10px] text-slate-400 dark:text-slate-500">
<T>{config.description}</T>
</p>
)}
</div>
);
case 'tabs-editor':
return (
<div key={key} className="space-y-4">
<Label className="text-xs font-medium text-slate-500 dark:text-slate-400">
<T>{config.label}</T>
</Label>
<TabsPropertyEditor
value={value || []}
onChange={(newValue) => updateSetting(key, newValue)}
widgetInstanceId={widgetInstanceId || 'new-widget'}
/>
{config.description && (
<p className="text-[10px] text-slate-400 dark:text-slate-500">
<T>{config.description}</T>
</p>
)}
</div>
);
default:
return null;
}
@ -346,6 +427,23 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
/>
)}
{/* Page Picker Dialog */}
{pagePickerField && (
<PagePickerDialog
isOpen={pagePickerOpen}
onClose={() => {
setPagePickerOpen(false);
setPagePickerField(null);
}}
onSelect={(page) => {
updateSetting(pagePickerField, page?.id || null);
setPagePickerOpen(false);
setPagePickerField(null);
}}
currentValue={settings[pagePickerField]}
/>
)}
{/* Markdown Editor Modal */}
<Dialog open={markdownEditorOpen} onOpenChange={setMarkdownEditorOpen}>
<DialogContent className="max-w-4xl h-[80vh] flex flex-col p-0 gap-0">
@ -376,3 +474,4 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
</div>
);
};

View File

@ -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<void>;
canUndo: boolean;
canRedo: boolean;
clearHistory: () => void;
executeCommand: (command: Command) => Promise<void>;
// State
isLoading: boolean;
@ -54,343 +67,334 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
// Pending Metadata State
const [pendingMetadata, setPendingMetadata] = useState<Map<string, Record<string, any>>>(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<string, any>) => {
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();
}, []);
const loadPageLayout = async (pageId: string, defaultName?: string) => {
// Only load if not already cached
if (!loadedPages.has(pageId)) {
// 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 (error) {
console.error(`Failed to load page layout ${pageId}:`, error);
} catch (e) {
console.error("Failed to load page layout", e);
} finally {
setIsLoading(false);
}
}
};
}, []);
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`);
}
const clearPageLayout = useCallback(async (pageId: string) => {
setLoadedPages(prev => {
const next = new Map(prev);
next.delete(pageId);
return next;
});
}, []);
// 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
// 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 loaded");
const widgetInstance = UnifiedLayoutManager.createWidgetInstance(widgetId);
// 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]);
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]);
const updateWidgetProps = useCallback(async (pageId: string, widgetInstanceId: string, props: Record<string, any>) => {
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]);
// 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<LayoutContainer['settings']>) => {
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, // ULM default
gap: 16,
widgets: [],
children: [],
order: 0
}
],
// Preserve created if exists
createdAt: currentLayout.createdAt,
updatedAt: Date.now()
order: 0 // Command execution will set order
};
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<WidgetInstance> => {
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);
const command = new AddContainerCommand(pageId, newContainer, parentContainerId);
await historyManager.execute(command, {
pageId,
layouts: loadedPages,
updateLayout: updateLayoutCallback
});
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
updateLayout: updateLayoutCallback,
pageMetadata: pendingMetadata,
updatePageMetadata: updatePageMetadataCallback
});
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<LayoutContainer['settings']>) => {
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<string, any>) => {
try {
const command = new UpdateWidgetSettingsCommand(pageId, widgetInstanceId, props);
}, [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
updateLayout: updateLayoutCallback,
pageMetadata: pendingMetadata,
updatePageMetadata: updatePageMetadataCallback
});
updateHistoryState();
} catch (error) {
console.error('Failed to update widget props:', error);
throw error;
}
};
}, [loadedPages, historyManager, updateLayoutCallback, pendingMetadata, updatePageMetadataCallback, updateHistoryState]);
const renameWidget = async (pageId: string, widgetInstanceId: string, newId: string): Promise<boolean> => {
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<boolean> => {
return true;
};
const exportPageLayout = async (pageId: string): Promise<string> => {
const movePageContainer = useCallback(async (pageId: string, containerId: string, direction: 'up' | 'down') => {
const layout = loadedPages.get(pageId);
if (!layout) throw new Error('Layout not found');
return JSON.stringify(layout, null, 2);
};
const importPageLayout = async (pageId: string, jsonData: string): Promise<PageLayout> => {
try {
const importedLayout = JSON.parse(jsonData) as PageLayout;
importedLayout.id = pageId;
setLoadedPages(prev => new Map(prev).set(pageId, importedLayout));
await UnifiedLayoutManager.savePageLayout(importedLayout);
historyManager.clear();
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]);
return importedLayout;
} catch (e) {
console.error('Failed to import layout:', e);
throw e;
}
};
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 hydratePageLayout = (pageId: string, layout: PageLayout) => {
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<boolean> => {
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<LayoutProviderProps> = ({ 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 (
<LayoutContext.Provider value={{
// ... existing values ...
loadPageLayout,
getLoadedPageLayout,
clearPageLayout,
@ -428,7 +440,18 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ 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}
</LayoutContext.Provider>

View File

@ -20,106 +20,28 @@ export interface FeedPost {
category_paths?: any[][]; // Array of category paths (each path is root -> leaf)
}
const requestCache = new Map<string, Promise<any>>();
// Deprecated: Caching now handled by React Query
// Keeping for backward compatibility
type CacheStorageType = 'memory' | 'local';
interface StoredCacheItem<T> {
value: T;
timestamp: number;
timeout: number;
}
export const fetchWithDeduplication = async <T>(
key: string,
fetcher: () => Promise<T>,
timeout: number = 25000,
storage: CacheStorageType = 'local'
): Promise<T> => {
// 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<T> = 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<T> = {
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<T>;
// 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<any[]> => {
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<Category> & { 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<any> => {
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<any> => {
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<void> => {
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 };
};

View File

@ -4,8 +4,8 @@ import { supabase } from '@/integrations/supabase/client';
export interface LayoutStorageService {
load(pageId?: string): Promise<RootLayoutData | null>;
save(data: RootLayoutData, pageId?: string): Promise<boolean>;
saveToApiOnly(data: RootLayoutData, pageId?: string): Promise<boolean>;
save(data: RootLayoutData, pageId?: string, metadata?: Record<string, any>): Promise<boolean>;
saveToApiOnly(data: RootLayoutData, pageId?: string, metadata?: Record<string, any>): Promise<boolean>;
}
// 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<RootLayoutData | null> {
console.log('Loading layout for page:', pageId);
if (!pageId) return null;
const isPage = pageId.startsWith('page-');
@ -65,6 +67,23 @@ export class DatabaseLayoutService implements LayoutStorageService {
}
try {
if (isPage) {
// Use API for pages
const { fetchPageDetailsById } = await import('@/lib/db');
const data = await fetchPageDetailsById(actualId);
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 null;
} else {
// Fallback to Supabase for collections or if API fails/not implemented for collections
const { data, error } = await supabase
.from(table)
.select(`${column}`)
@ -85,8 +104,7 @@ export class DatabaseLayoutService implements LayoutStorageService {
return rootData;
} else if (error) {
logger.error(`❌ Failed to load layout from ${table}`, { id: actualId, error });
} else {
logger.info(` No content found in ${table} for:`, actualId);
}
}
} 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<boolean> {
async save(data: RootLayoutData, pageId?: string, metadata?: Record<string, any>): Promise<boolean> {
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<boolean> {
async saveToApiOnly(data: RootLayoutData, pageId?: string, metadata?: Record<string, any>): Promise<boolean> {
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')

View File

@ -111,7 +111,7 @@ export const createOpenAIClient = async (apiKey?: string): Promise<OpenAI | null
console.log('[createOpenAIClient] resolved token:', token ? token.substring(0, 10) + '...' : 'null');
return new OpenAI({
apiKey: token, // This is sent as Bearer token to our proxy
baseURL: `${import.meta.env.VITE_SERVER_URL || 'http://localhost:3333'}/api/openai/v1`, // Use our server proxy with absolute URL
baseURL: `${import.meta.env.VITE_SERVER_IMAGE_API_URL || 'http://localhost:3333'}/api/openai/v1`,
dangerouslyAllowBrowser: true // Required for client-side usage
});
} catch (error) {

View File

@ -1,5 +1,7 @@
import { Command, CommandContext } from './types';
import { WidgetInstance, PageLayout, LayoutContainer } from '@/lib/unifiedLayoutManager';
// Force rebuild
import { WidgetInstance, PageLayout, LayoutContainer, UnifiedLayoutManager } from '@/lib/unifiedLayoutManager';
// Helper to find a container by ID in the layout tree
const findContainer = (containers: LayoutContainer[], id: string): LayoutContainer | null => {
@ -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<void> {
// 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<void> {
// 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<string, any>;
private newMeta: Record<string, any>;
private onUpdate?: (meta: Record<string, any>) => void;
constructor(
pageId: string,
oldMeta: Record<string, any>,
newMeta: Record<string, any>,
onUpdate?: (meta: Record<string, any>) => 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<void> {
context.updatePageMetadata(this.pageId, this.newMeta);
if (this.onUpdate) this.onUpdate(this.newMeta);
}
async undo(context: CommandContext): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<LayoutContainer['settings']>;
private oldSettings: Partial<LayoutContainer['settings']> | null = null;
constructor(pageId: string, containerId: string, settings: Partial<LayoutContainer['settings']>) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.containerId = containerId;
this.newSettings = settings;
}
async execute(context: CommandContext): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
if (this.oldLayout) {
context.updateLayout(this.pageId, this.oldLayout);
}
}
}

View File

@ -4,6 +4,8 @@ export interface CommandContext {
pageId: string;
layouts: Map<string, PageLayout>;
updateLayout: (pageId: string, layout: PageLayout) => void;
pageMetadata: Map<string, Record<string, any>>;
updatePageMetadata: (pageId: string, metadata: Record<string, any>) => void;
}
export interface Command {

View File

@ -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 },

View File

@ -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, any> = {
'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<string>()): 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<string, any> = {};
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<string>()): 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<string, any> = {
'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;
};

View File

@ -109,10 +109,10 @@ export class UnifiedLayoutManager {
}
// Save root data to storage (database-only, no localStorage)
static async saveRootData(data: RootLayoutData, pageId?: string): Promise<void> {
static async saveRootData(data: RootLayoutData, pageId?: string, metadata?: Record<string, any>): Promise<void> {
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<void> {
static async savePageLayout(layout: PageLayout, metadata?: Record<string, any>): Promise<void> {
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<string> {
const layout = await this.getPageLayout(pageId);
return JSON.stringify(layout, null, 2);
}
// Import layout from JSON
static async importPageLayout(pageId: string, jsonData: string): Promise<PageLayout> {
// 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<string>();
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<PageLayout> {
try {
const parsedData = this.parseLayoutJSON(jsonData, pageId);
// Load the existing root data
const rootData = await this.loadRootData(pageId);

View File

@ -18,6 +18,8 @@ interface CompactFilmStripProps {
onGalleryPickerOpen: (index: number) => void;
cacheBustKeys: Record<string, number>;
thumbnailLayout?: 'strip' | 'grid';
orientation?: 'horizontal' | 'vertical'; // New prop
className?: string;
}
export const CompactFilmStrip: React.FC<CompactFilmStripProps> = ({
@ -31,7 +33,9 @@ export const CompactFilmStrip: React.FC<CompactFilmStripProps> = ({
onDeletePicture,
onGalleryPickerOpen,
cacheBustKeys,
thumbnailLayout = 'strip'
thumbnailLayout = 'strip',
orientation = 'horizontal',
className
}) => {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
@ -42,12 +46,16 @@ export const CompactFilmStrip: React.FC<CompactFilmStripProps> = ({
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<CompactFilmStripProps> = ({
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<CompactFilmStripProps> = ({
}
return (
<div className="p-0 mt-2 landscape:p-0 lg:landscape:p-0 hidden lg:block landscape:block w-auto max-w-full">
<div className={`
p-0 mt-2 landscape:p-0 lg:landscape:p-0
${orientation === 'vertical' ? 'block' : 'hidden lg:block landscape:block'}
${orientation === 'vertical' ? 'w-auto h-full' : 'w-auto max-w-full'}
${className || ''}
`}>
<div
className={thumbnailLayout === 'grid'
? "flex flex-wrap gap-1 justify-center max-h-[200px] overflow-y-auto scrollbar-hide"
: "flex gap-1 overflow-x-auto scrollbar-hide justify-center"
}
className={`
flex gap-1 justify-center scrollbar-hide
${thumbnailLayout === 'grid' ? 'flex-wrap max-h-[200px] overflow-y-auto' : ''}
${orientation === 'vertical' ? 'flex-col overflow-y-auto h-full max-h-none' : 'overflow-x-auto'}
`}
ref={scrollContainerRef}
>
{groupedItems.map((item, index) => (
@ -156,10 +170,17 @@ export const CompactFilmStrip: React.FC<CompactFilmStripProps> = ({
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
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<CompactFilmStripProps> = ({
sizes="150px"
loading="lazy"
/>
{isVideoType(normalizeMediaType(item.type || detectMediaType(item.image_url))) && (
{isVideoType(normalizeMediaType((item as any).type || detectMediaType(item.image_url))) && (
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="w-6 h-6 bg-black/50 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z" /></svg>

View File

@ -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<CompactMediaViewerProps> = ({
@ -42,7 +44,8 @@ export const CompactMediaViewer: React.FC<CompactMediaViewerProps> = ({
isOwner,
videoPlaybackUrl,
videoPosterUrl,
imageFit = 'cover'
imageFit = 'cover',
zoomEnabled = false
}) => {
const playerRef = useRef<MediaPlayerInstance>(null);
const [externalVideoState, setExternalVideoState] = React.useState<Record<string, boolean>>({});
@ -198,6 +201,18 @@ export const CompactMediaViewer: React.FC<CompactMediaViewerProps> = ({
}
// Main Image Viewer with conditional SpyGlass
if (zoomEnabled) {
return (
<SpyGlassImage
src={`${mediaItem.image_url}${cacheBustKeys[mediaItem.id] ? `?v=${cacheBustKeys[mediaItem.id]}` : ''}`}
alt={mediaItem.title}
className="w-full h-full"
onClick={() => onExpand(mediaItem)}
imageFit={imageFit}
/>
);
}
return (
<ResponsiveImage

View File

@ -64,6 +64,18 @@ export interface GalleryProps {
/** Custom className for container */
className?: string;
/** Position of the thumbnails relative to the main image */
thumbnailsPosition?: 'bottom' | 'top' | 'left' | 'right';
/** Orientation of the thumbnail strip */
thumbnailsOrientation?: 'horizontal' | 'vertical';
/** Whether to enable zoom (spyglass) on main image */
zoomEnabled?: boolean;
/** Additional CSS classes for thumbnails strip */
thumbnailsClassName?: string;
}
/**
@ -103,20 +115,42 @@ export const Gallery: React.FC<GalleryProps> = ({
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 (
<div className={`flex flex-col h-full ${className}`}>
<div className={`flex ${getFlexDirection()} h-full ${className}`}>
{/* Main Media Viewer - takes remaining space */}
<div className="flex-1 relative w-full min-h-0">
<div className={`flex-1 relative min-0 ${isSidebar ? 'h-full' : 'w-full'}`}>
<CompactMediaViewer
mediaItem={selectedItem}
isVideo={isVideo}
@ -133,23 +167,32 @@ export const Gallery: React.FC<GalleryProps> = ({
videoPlaybackUrl={videoPlaybackUrl}
videoPosterUrl={videoPosterUrl}
imageFit={imageFit}
zoomEnabled={zoomEnabled && zoomActivated}
/>
</div>
{/* Filmstrip - centered to match main image */}
<div className="flex justify-center w-full">
{/* Filmstrip */}
<div className={`
flex justify-center
${isSidebar ? 'h-full w-auto border-l border-zinc-800' : 'w-full h-auto'}
${thumbnailsPosition === 'left' ? 'border-r border-l-0' : ''}
${thumbnailsPosition === 'top' ? 'border-b' : ''}
${thumbnailsPosition === 'bottom' ? 'border-t' : ''}
`}>
<CompactFilmStrip
mediaItems={mediaItems}
localMediaItems={localMediaItems}
setLocalMediaItems={setLocalMediaItems}
isEditMode={isEditMode}
mediaItem={selectedItem}
onMediaSelect={onMediaSelect}
onMediaSelect={handleThumbnailClick}
isOwner={isOwner}
onDeletePicture={onDeletePicture}
onGalleryPickerOpen={onGalleryPickerOpen}
cacheBustKeys={cacheBustKeys}
thumbnailLayout={thumbnailLayout}
orientation={thumbnailsOrientation}
className={thumbnailsClassName}
/>
</div>
</div>

View File

@ -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<SpyGlassImageProps> = ({
src,
alt,
className,
zoomLevel = 2,
onClick,
imageFit = 'cover'
}) => {
const containerRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
// 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 (
<div
ref={containerRef}
className={cn("relative w-full h-full overflow-hidden cursor-zoom-in group", className)}
onMouseMove={handleMouseMove}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={onClick}
>
{/* Main Image (Visible when not hovering) */}
<ResponsiveImage
src={src}
alt={alt}
imgClassName={cn(
"w-full h-full transition-opacity duration-200",
imageFit === 'contain' ? 'object-contain' : 'object-cover',
isHovering ? 'opacity-0' : 'opacity-100'
)}
sizes="(max-width: 1024px) 100vw, 1200px"
/>
{/* Zoomed Background Image (Visible when hovering) */}
{isHovering && (
<div
className="absolute inset-0 z-10 bg-no-repeat pointer-events-none"
style={{
backgroundImage: `url(${src})`,
backgroundSize: `${currentZoom * 100}%`,
backgroundPosition: backgroundPosition,
// Match visual fit of the underlying image if possible,
// though 'cover' is standard for this type of zoom.
// If imageFit is 'contain', the zoom effect might look misaligned if the image doesn't fill the box.
// For 'cover', this background approach works perfectly.
}}
/>
)}
</div>
);
};

View File

@ -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<string | null>(null);
// TOC State
const [headings, setHeadings] = useState<MarkdownHeading[]>([]);
@ -109,111 +100,6 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
const isOwner = currentUser?.id === userId;
// Template State
const [templates, setTemplates] = useState<Layout[]>([]);
const { getLayouts } = useLayouts();
const { importPageLayout, addWidgetToPage, addPageContainer, undo, redo, canUndo, canRedo } = useLayout();
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(null);
const [editingWidgetId, setEditingWidgetId] = useState<string | null>(null);
const [newlyAddedWidgetId, setNewlyAddedWidgetId] = useState<string | null>(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 (
<div className="min-h-screen bg-background flex items-center justify-center">
@ -375,47 +229,34 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
);
}
if (isEditMode && isOwner) {
return (
<div className={`${embedded ? 'h-full' : 'h-[calc(100vh-3.5rem)]'} bg-background flex flex-col overflow-hidden`}>
{/* 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 ? (
<Suspense fallback={<div className="h-24 bg-muted/30 animate-pulse w-full border-b" />}>
<PageRibbonBar
<Suspense fallback={<div className="h-screen w-full flex items-center justify-center"><T>Loading Editor...</T></div>}>
<UserPageEdit
page={page}
userProfile={userProfile}
isOwner={isOwner}
onToggleEditMode={() => {
setIsEditMode(false);
setSelectedWidgetId(null);
setSelectedContainerId(null);
}}
userId={userId || ''}
orgSlug={orgSlug}
headings={headings}
childPages={childPages}
onExitEditMode={() => setIsEditMode(false)}
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}
/>
</Suspense>
) : (
);
}
return (
<div className={`${embedded ? 'h-full' : 'h-[calc(100vh-3.5rem)]'} bg-background flex flex-col overflow-hidden`}>
{/* Top Header (Back button) or Ribbon Bar - Fixed if not embedded */}
{!embedded && (
<UserPageTopBar
embedded={embedded}
orgSlug={orgSlug}
userId={userId || ''}
isOwner={isOwner}
/>
)
)}
{/* 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
<GenericCanvas
pageId={`page-${page.id}`}
pageName={page.title}
isEditMode={isEditMode && isOwner}
showControls={isEditMode && isOwner}
isEditMode={false}
showControls={false}
initialLayout={page.content}
selectedWidgetId={selectedWidgetId}
onSelectWidget={setSelectedWidgetId}
selectedContainerId={selectedContainerId}
onSelectContainer={setSelectedContainerId}
editingWidgetId={editingWidgetId}
onEditWidget={handleEditWidget}
newlyAddedWidgetId={newlyAddedWidgetId}
selectedWidgetId={null}
onSelectWidget={() => { }}
/>
)}
</div>
@ -546,21 +390,8 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
</div>
</ResizablePanel>
{/* Right Sidebar - Property Panel */}
{isEditMode && isOwner && selectedWidgetId && (
<>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={25} minSize={20} maxSize={50} order={2} id="user-page-props">
<div className="h-full flex flex-col shrink-0 transition-all duration-300 overflow-hidden bg-background">
<WidgetPropertyPanel
pageId={`page-${page.id}`}
selectedWidgetId={selectedWidgetId}
onWidgetRenamed={setSelectedWidgetId}
/>
</div>
</ResizablePanel>
</>
)}
{/* No Right Sidebar in View Mode */}
</ResizablePanelGroup>
</div>
</div >);

View File

@ -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<string | null>(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<Layout[]>([]);
const { getLayouts } = useLayouts();
const {
importPageLayout,
exportPageLayout,
addWidgetToPage,
removeWidgetFromPage,
addPageContainer,
undo,
redo,
canUndo,
canRedo,
getLoadedPageLayout
} = useLayout();
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(null);
const [editingWidgetId, setEditingWidgetId] = useState<string | null>(null);
const [newlyAddedWidgetId, setNewlyAddedWidgetId] = useState<string | null>(null);
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(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<string, any>();
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 (
<>
<PageRibbonBar
page={page}
isOwner={isOwner}
onToggleEditMode={() => {
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}
/>
<div className="flex-1 flex overflow-hidden min-h-0">
{/* Sidebar Left */}
{(headings.length > 0 || childPages.length > 0 || showHierarchy) && (
<Sidebar className={`${isSidebarCollapsed ? 'w-12' : 'w-[300px]'} border-r bg-background/50 h-full hidden lg:flex flex-col shrink-0 transition-all duration-300`}>
<div className={`flex items-center ${isSidebarCollapsed ? 'justify-center' : 'justify-end'} p-2 sticky top-0 bg-background/50 z-10`}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
title={isSidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{isSidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
</Button>
</div>
{!isSidebarCollapsed && (
<div className="overflow-y-auto flex-1 pb-4 scrollbar-custom">
{childPages.length > 0 && (
<div className="px-4 py-2 border-b mb-2">
<h3 className="text-sm font-semibold mb-2 text-muted-foreground uppercase tracking-wider text-xs"><T>Child Pages</T></h3>
<div className="flex flex-col gap-1">
{childPages.map(child => (
<Link
key={child.id}
to={orgSlug ? `/org/${orgSlug}/user/${userId}/pages/${child.slug}` : `/user/${userId}/pages/${child.slug}`}
className="text-sm py-1 px-2 rounded hover:bg-muted truncate block text-foreground/80 hover:text-primary transition-colors"
>
{child.title}
</Link>
))}
</div>
</div>
)}
{headings.length > 0 && (
<TableOfContents
headings={headings}
minHeadingLevel={2}
title=""
className="border-t-0 pt-2 px-4"
/>
)}
{showHierarchy && currentLayout && (
<HierarchyTree
containers={currentLayout.containers}
selectedWidgetId={selectedWidgetId}
selectedContainerId={selectedContainerId}
onSelectWidget={(id) => {
setSelectedWidgetId(id);
// Ensure editor is open/focused if needed
const widget = currentLayout.containers.flatMap((c: any) => [c, ...c.children]).flatMap((c: any) => c.widgets).find((w: any) => w.id === id);
if (widget) setEditingWidgetId(id);
}}
onSelectContainer={setSelectedContainerId}
/>
)}
</div>
)}
</Sidebar>
)}
{/* Right Content */}
<ResizablePanelGroup direction="horizontal" className="flex-1 h-full min-w-0">
<ResizablePanel defaultSize={75} minSize={30} order={1}>
<div className="h-full overflow-y-auto scrollbar-custom">
<div className="container mx-auto p-4 md:p-8 max-w-5xl">
{/* Mobile TOC */}
<div className="lg:hidden mb-6">
{headings.length > 0 && <MobileTOC headings={headings} />}
</div>
<UserPageDetails
page={page}
userProfile={userProfile}
isOwner={isOwner}
isEditMode={true}
userId={userId || ''}
orgSlug={orgSlug}
onPageUpdate={onPageUpdate}
onToggleEditMode={onExitEditMode}
onWidgetRename={setSelectedWidgetId}
templates={templates}
onLoadTemplate={handleLoadTemplate}
/>
{/* Content Body */}
<div>
{page.content && typeof page.content === 'string' ? (
<div className="prose prose-lg dark:prose-invert max-w-none pb-12">
<MarkdownRenderer content={page.content} />
</div>
) : (
<GenericCanvas
pageId={`page-${page.id}`}
pageName={page.title}
isEditMode={true}
showControls={true}
initialLayout={page.content}
selectedWidgetId={selectedWidgetId}
onSelectWidget={setSelectedWidgetId}
selectedContainerId={selectedContainerId}
onSelectContainer={setSelectedContainerId}
editingWidgetId={editingWidgetId}
onEditWidget={handleEditWidget}
newlyAddedWidgetId={newlyAddedWidgetId}
/>
)}
</div>
{/* Footer */}
<div className="mt-8 pt-8 border-t text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<div>
<T>Last updated:</T> {new Date(page.updated_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</div>
{page.parent && (
<Link
to={`/pages/${page.parent}`}
className="text-primary hover:underline"
>
<T>View parent page</T>
</Link>
)}
</div>
</div>
</div>
</div>
</ResizablePanel>
{/* Right Sidebar - Property Panel OR Type Fields */}
{(selectedWidgetId || showTypeFields) && (
<>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={25} minSize={20} maxSize={50} order={2} id="user-page-props">
<div className="h-full flex flex-col shrink-0 transition-all duration-300 overflow-hidden bg-background">
{selectedWidgetId ? (
<WidgetPropertyPanel
pageId={`page-${page.id}`}
selectedWidgetId={selectedWidgetId}
onWidgetRenamed={setSelectedWidgetId}
/>
) : showTypeFields ? (
<div className="h-full overflow-y-auto p-4">
<UserPageTypeFields
pageId={page.id}
pageMeta={page.meta}
assignedTypes={assignedTypes}
isEditMode={true}
onMetaUpdate={(newMeta) => onPageUpdate({ ...page, meta: newMeta })}
/>
</div>
) : null}
</div>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</div>
<SaveTemplateDialog
isOpen={showSaveTemplateDialog}
onClose={() => setShowSaveTemplateDialog(false)}
onSave={onSaveTemplate}
/>
</>
);
};
export default UserPageEdit;