ui:commands|fields|cats|ribbons
This commit is contained in:
parent
06685c6530
commit
633df15f65
22
packages/ui/src/actions/ActionProvider.tsx
Normal file
22
packages/ui/src/actions/ActionProvider.tsx
Normal 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}</>;
|
||||
};
|
||||
52
packages/ui/src/actions/default-actions.ts
Normal file
52
packages/ui/src/actions/default-actions.ts
Normal 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') }
|
||||
}
|
||||
];
|
||||
58
packages/ui/src/actions/store.ts
Normal file
58
packages/ui/src/actions/store.ts
Normal 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));
|
||||
}
|
||||
}));
|
||||
64
packages/ui/src/actions/types.ts
Normal file
64
packages/ui/src/actions/types.ts
Normal 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[];
|
||||
}
|
||||
41
packages/ui/src/actions/useActions.ts
Normal file
41
packages/ui/src/actions/useActions.ts
Normal 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
|
||||
};
|
||||
};
|
||||
@ -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 && (
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
175
packages/ui/src/components/sidebar/HierarchyTree.tsx
Normal file
175
packages/ui/src/components/sidebar/HierarchyTree.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
61
packages/ui/src/components/user-page/SaveTemplateDialog.tsx
Normal file
61
packages/ui/src/components/user-page/SaveTemplateDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
148
packages/ui/src/components/user-page/UserPageTypeFields.tsx
Normal file
148
packages/ui/src/components/user-page/UserPageTypeFields.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
90
packages/ui/src/components/widgets/IconPicker.tsx
Normal file
90
packages/ui/src/components/widgets/IconPicker.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
184
packages/ui/src/components/widgets/TabsPropertyEditor.tsx
Normal file
184
packages/ui/src/components/widgets/TabsPropertyEditor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
152
packages/ui/src/components/widgets/TabsWidget.tsx
Normal file
152
packages/ui/src/components/widgets/TabsWidget.tsx
Normal 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;
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 };
|
||||
};
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 },
|
||||
|
||||
110
packages/ui/src/lib/schema-utils.ts
Normal file
110
packages/ui/src/lib/schema-utils.ts
Normal 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;
|
||||
};
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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 >);
|
||||
|
||||
646
packages/ui/src/pages/UserPageEdit.tsx
Normal file
646
packages/ui/src/pages/UserPageEdit.tsx
Normal 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;
|
||||
Loading…
Reference in New Issue
Block a user