vfs | filebrowser | layout aid

This commit is contained in:
lovebird 2026-02-16 19:51:21 +01:00
parent 320a9db409
commit 0d30aa1c87
33 changed files with 3272 additions and 1129 deletions

View File

@ -18,8 +18,8 @@ const buttonVariants = cva(
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
sm: "h-9 rounded-md px-2",
lg: "h-11 rounded-md px-4",
icon: "h-10 w-10",
},
},
@ -32,7 +32,7 @@ const buttonVariants = cva(
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}

View File

@ -265,15 +265,15 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
size="icon"
className="h-6 w-6"
title="Fullscreen"
onClick={() => {
setActiveMarkdownField(key);
setMarkdownEditorOpen(true);
}}
>
<Maximize2 className="h-3.5 w-3.5 mr-1" />
<T>Fullscreen</T>
<Maximize2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
@ -281,7 +281,7 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
id={key}
value={value || ''}
onChange={(e) => updateSetting(key, e.target.value)}
className="w-full min-h-[80px] text-sm font-mono resize-y"
className="w-full min-h-[80px] max-h-[200px] text-sm font-mono resize-y overflow-auto break-all"
placeholder={config.default}
/>
{config.description && (

View File

@ -1,10 +1,14 @@
import React from 'react';
import { T } from '@/i18n';
import React, { useState } from 'react';
import { T, translate } from '@/i18n';
import { toast } from 'sonner';
import { WidgetInstance } from '@/modules/layout/LayoutManager';
import { widgetRegistry } from '@/lib/widgetRegistry';
import { WidgetPropertiesForm } from './WidgetPropertiesForm';
import { useLayout } from '@/modules/layout/LayoutContext';
import { useWidgetSnippets, WidgetSnippetData } from '@/modules/layout/useWidgetSnippets';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { BookmarkPlus } from 'lucide-react';
interface WidgetPropertyPanelProps {
pageId: string;
@ -22,15 +26,20 @@ export const WidgetPropertyPanel: React.FC<WidgetPropertyPanelProps> = ({
contextVariables = {}
}) => {
const { loadedPages, updateWidgetProps, renameWidget } = useLayout();
const { saveSnippet } = useWidgetSnippets();
const page = loadedPages.get(pageId);
// Save as snippet state
const [showSaveInput, setShowSaveInput] = useState(false);
const [snippetName, setSnippetName] = useState('');
const [saving, setSaving] = useState(false);
// Find the selected widget instance
const findWidget = (): WidgetInstance | null => {
if (!page || !selectedWidgetId) return null;
for (const container of page.containers) {
const widget = container.widgets.find(w => w.id === selectedWidgetId);
if (widget) return widget;
// TODO: Recursive search if needed, currently assumes flat-ish structure or top-level containers
}
return null;
};
@ -47,7 +56,6 @@ export const WidgetPropertyPanel: React.FC<WidgetPropertyPanelProps> = ({
}
const handleSettingsChange = (newSettings: Record<string, any>) => {
// Live update
updateWidgetProps(pageId, widget.id, newSettings).catch(console.error);
};
@ -69,18 +77,78 @@ export const WidgetPropertyPanel: React.FC<WidgetPropertyPanelProps> = ({
}
};
const handleSaveAsSnippet = async () => {
if (!snippetName.trim() || !widget) return;
setSaving(true);
try {
const data: WidgetSnippetData = {
widgetId: widget.widgetId,
props: { ...widget.props },
};
await saveSnippet(snippetName.trim(), data);
setShowSaveInput(false);
setSnippetName('');
} finally {
setSaving(false);
}
};
return (
<div className={`flex flex-col h-full dark:bg-slate-900/50 border-l border-slate-200 dark:border-slate-800 ${className}`}>
<div className="p-4 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
<h3 className="font-semibold text-sm truncate" title={widgetDefinition.metadata.name}>
{widgetDefinition.metadata.name}
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
<T>Properties</T>
</p>
<div className={`flex flex-col h-full min-w-0 overflow-hidden dark:bg-slate-900/50 border-l border-slate-200 dark:border-slate-800 ${className}`}>
<div className="p-4 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 space-y-2">
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-sm truncate" title={widgetDefinition.metadata.name}>
{widgetDefinition.metadata.name}
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
<T>Properties</T>
</p>
</div>
{!showSaveInput && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
title={translate("Save as Widget")}
onClick={() => {
setSnippetName(widgetDefinition.metadata.name);
setShowSaveInput(true);
}}
>
<BookmarkPlus className="h-4 w-4" />
</Button>
)}
</div>
{showSaveInput && (
<div className="flex items-center gap-1.5">
<Input
value={snippetName}
onChange={(e) => setSnippetName(e.target.value)}
placeholder={translate("Widget name...")}
className="h-7 text-xs"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveAsSnippet();
if (e.key === 'Escape') {
setShowSaveInput(false);
setSnippetName('');
}
}}
/>
<Button
size="sm"
onClick={handleSaveAsSnippet}
disabled={!snippetName.trim() || saving}
className="h-7 text-xs shrink-0"
>
<T>Save</T>
</Button>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="flex-1 overflow-y-auto scrollbar-custom p-4">
{widgetDefinition.metadata.configSchema ? (
<WidgetPropertiesForm
widgetDefinition={widgetDefinition}

View File

@ -168,6 +168,8 @@
height: 100%;
background-color: var(--body-bg);
background-image: var(--body-bg-gradient);
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
&:before {
content: '';
@ -306,15 +308,15 @@
/* Heading styles */
.prose h1 {
@apply text-3xl font-bold mb-4 mt-6;
@apply text-2xl font-bold mb-4 mt-6;
}
.prose h2 {
@apply text-2xl font-bold mb-3 mt-5;
@apply text-xl font-bold mb-3 mt-5;
}
.prose h3 {
@apply text-xl font-semibold mb-2 mt-4;
@apply font-semibold mb-2 mt-4;
}
.prose h4 {

View File

@ -18,6 +18,7 @@ import { JSONSchema } from 'openai/lib/jsonschema';
import { createImage as createImageRouter, editImage as editImageRouter } from '@/lib/image-router';
import { generateTextWithImagesTool } from '@/lib/markdownImageTools';
import { createPageTool } from '@/lib/pageTools';
import { createWidgetsTool } from '@/lib/tools-layout';
type LogFunction = (level: string, message: string, data?: any) => void;
@ -46,7 +47,8 @@ export type PresetType =
| 'generate-metadata' // Generate + metadata
| 'generate-publish' // Generate + metadata + publish
| 'metadata-only' // Only metadata
| 'optimize-generate'; // Optimize + generate + metadata
| 'optimize-generate' // Optimize + generate + metadata
| 'layout-generator'; // Generate widget layout fragments
const PRESET_TOOLS: Record<PresetType, (apiKey?: string) => RunnableToolFunctionWithParse<any>[]> = {
'generate-only': (apiKey) => [
@ -69,6 +71,9 @@ const PRESET_TOOLS: Record<PresetType, (apiKey?: string) => RunnableToolFunction
generateImageTool(apiKey),
generateImageMetadataTool(apiKey)
],
'layout-generator': () => [
createWidgetsTool(),
],
};
// Get user's session token for proxy authentication
@ -1117,6 +1122,15 @@ Title: 5-8 words. Description: 2-3 sentences.`,
createPageTool(userId || 'anonymous-user', addLog),
],
systemPrompt: `You are an AI assistant that writes well-structured, comprehensive markdown documents. Based on the user's request, generate the full text content for a page, then call the 'create_page' tool with that content and a suitable title and tags. Your final response must be only the 'create_page' tool call.`,
},
'layout-generator': {
name: 'Layout Generator',
description: 'Generate widget layout fragments (containers + widgets) from a text description.',
model: 'gpt-4o',
tools: [
createWidgetsTool(addLog),
],
systemPrompt: `You are a layout generation assistant for a widget-based page editor. When the user describes a layout, call the create_widgets tool with the correct containers and widgets. Follow the widget schema exactly. Use appropriate columns, widget types, and props. Be creative with HTML widgets when no specific widget type fits. Always return valid JSON.`,
}
});

View File

@ -14,7 +14,8 @@ import type {
GalleryWidgetProps,
PageCardWidgetProps,
MarkdownTextWidgetProps,
LayoutContainerWidgetProps
LayoutContainerWidgetProps,
FileBrowserWidgetProps
} from '@polymech/shared';
import PageCardWidget from '@/modules/pages/PageCardWidget';
@ -27,6 +28,7 @@ import MarkdownTextWidget from '@/components/widgets/MarkdownTextWidget';
import GalleryWidget from '@/components/widgets/GalleryWidget';
import TabsWidget from '@/components/widgets/TabsWidget';
import { HtmlWidget } from '@/components/widgets/HtmlWidget';
import FileBrowserWidget from '@/modules/pages/FileBrowserWidget';
export function registerAllWidgets() {
// Clear existing registrations (useful for HMR)
@ -481,4 +483,87 @@ export function registerAllWidgets() {
}
});
// File Browser Widget
widgetRegistry.register<FileBrowserWidgetProps>({
component: FileBrowserWidget,
metadata: {
id: 'file-browser',
name: 'File Browser',
category: 'custom',
description: 'Browse files and directories on VFS mounts',
icon: Monitor,
defaultProps: {
mount: 'test',
path: '/',
glob: '*.*',
mode: 'simple',
viewMode: 'list',
sortBy: 'name',
showToolbar: true,
variables: {}
},
configSchema: {
mount: {
type: 'text',
label: 'Mount',
description: 'VFS mount name from config/vfs.json',
default: 'test'
},
path: {
type: 'text',
label: 'Initial Path',
description: 'Starting directory path (relative to mount root)',
default: '/'
},
glob: {
type: 'text',
label: 'Glob Pattern',
description: 'Filter files by glob pattern (e.g. *.jpg, **/*.png)',
default: '*.*'
},
mode: {
type: 'select',
label: 'Mode',
description: 'Simple = browse only. Advanced = file detail panel.',
options: [
{ value: 'simple', label: 'Simple' },
{ value: 'advanced', label: 'Advanced' }
],
default: 'simple'
},
viewMode: {
type: 'select',
label: 'View Mode',
description: 'List or thumbnail grid',
options: [
{ value: 'list', label: 'List' },
{ value: 'thumbs', label: 'Thumbnails' }
],
default: 'list'
},
sortBy: {
type: 'select',
label: 'Sort By',
description: 'Default sort order',
options: [
{ value: 'name', label: 'Name' },
{ value: 'ext', label: 'Extension' },
{ value: 'date', label: 'Date' },
{ value: 'type', label: 'Type' }
],
default: 'name'
},
showToolbar: {
type: 'boolean',
label: 'Show Toolbar',
description: 'Show navigation toolbar with breadcrumbs, sort, and view toggle',
default: true
}
},
minSize: { width: 300, height: 300 },
resizable: true,
tags: ['file', 'browser', 'vfs', 'storage', 'explorer']
}
});
}

View File

@ -0,0 +1,188 @@
import { z } from 'zod';
import { zodFunction } from '@/lib/openai';
type LogFunction = (level: string, message: string, data?: any) => void;
const defaultLog: LogFunction = (level, message, data) => console.log(`[${level}] ${message}`, data);
// ── Embedded widget system documentation (from public/docs/widgets-llm.md) ──
// NOTE: intentionally omits page-level wrapping (content.pages["page-{id}"]) —
// the LLM only generates container+widget fragments; the editor inserts them
// via AddContainerCommand for proper undo/redo support.
const WIDGET_SYSTEM_DOCS = `
# Widget System Container & Widget Reference
Generate an array of containers, each containing widgets. The editor will handle
insertion, ID assignment, and undo/redo. You only need to produce the structure.
## Available Widgets
### html-widget HTML Content
Raw HTML with \${var} substitution.
Props: content (HTML string), variables (JSON string), className (Tailwind).
### photo-card Photo Card
Single image card. Props: pictureId (uuid|null), showHeader (bool), showFooter (bool),
contentDisplay ("below"|"overlay"|"overlay-always"), imageFit ("contain"|"cover").
### photo-grid-widget Photo Grid (manual)
Grid of selected photos. Props: pictureIds (uuid[]).
### gallery-widget Gallery
Viewer + filmstrip. Props: pictureIds (uuid[]), thumbnailLayout ("strip"|"grid"),
imageFit, thumbnailsPosition ("top"|"bottom"|"left"|"right"), zoomEnabled (bool).
### page-card Page Card
Linked page card. Props: pageId (uuid|null), showHeader (bool), showFooter (bool),
contentDisplay ("below"|"overlay"|"overlay-always").
### markdown-text Text Block
Markdown content. Props: content (string), placeholder (string).
### tabs-widget Tabs
Each tab holds its own layout. Props: tabs (Tab[]), tabBarPosition ("top"|"bottom"|"left"|"right").
Tab: { id, label, layoutId, layoutData: { id, name, containers:[], createdAt, updatedAt, version } }
### layout-container-widget Nested Layout
Embeds an independent canvas. Props: nestedPageName (string), showControls (bool).
## Container Schema (what you produce)
{
columns: 1-4, // CSS grid columns
gap: 16, // px gap between cells
order: number, // sort position
widgets: Widget[],
children: Container[], // nested sub-containers (recursive)
settings: { title: "", showTitle: false, collapsible: false, collapsed: false }
}
## Widget Schema (inside a container)
{
widgetId: string, // one of the IDs above, e.g. "markdown-text"
order: number,
props: { ... } // widget-specific props (see above)
}
All widgets also accept a "variables" object in props for \${varName} substitution.
`.trim();
// ── Zod schemas for the tool input/output ──
const ContainerSettingsSchema = z.object({
title: z.string().default(''),
showTitle: z.boolean().default(false),
collapsible: z.boolean().default(false),
collapsed: z.boolean().default(false),
}).describe('Container display settings');
const WidgetSchema = z.object({
widgetId: z.string().describe('Registered widget id, e.g. "markdown-text", "photo-card", "html-widget"'),
order: z.number().default(0).describe('Sort order within the container'),
props: z.record(z.any()).describe('Widget-specific props — must match the widget\'s configSchema'),
}).describe('A single widget instance');
const ContainerSchema: z.ZodType<any> = z.lazy(() =>
z.object({
columns: z.number().min(1).max(4).default(1).describe('CSS grid columns (14)'),
gap: z.number().default(16).describe('Gap in px between grid cells'),
order: z.number().default(0).describe('Sort position among sibling containers'),
widgets: z.array(WidgetSchema).default([]).describe('Widgets inside this container'),
children: z.array(ContainerSchema).default([]).describe('Nested sub-containers (recursive)'),
settings: ContainerSettingsSchema.default({}).describe('Container display settings'),
}).describe('A layout container holding widgets')
);
const CreateWidgetsInputSchema = z.object({
containers: z.array(ContainerSchema)
.min(1)
.describe('Array of containers with their widgets. This is the layout fragment to insert into the page.'),
description: z.string().optional()
.describe('Brief description of what was generated (for the user).'),
});
type CreateWidgetsInput = z.infer<typeof CreateWidgetsInputSchema>;
// ── ID generation helpers ──
function randomId(length = 9): string {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
function assignIds(containers: any[]): any[] {
const now = Date.now();
return containers.map((container, ci) => {
const containerId = `container-${now + ci}-${randomId()}`;
return {
...container,
id: containerId,
type: 'container',
order: container.order ?? ci,
widgets: (container.widgets || []).map((widget: any, wi: number) => ({
...widget,
id: `widget-${now + ci}-${randomId()}`,
order: widget.order ?? wi,
})),
children: container.children ? assignIds(container.children) : [],
settings: {
title: '',
showTitle: false,
collapsible: false,
collapsed: false,
...(container.settings || {}),
},
};
});
}
// ── Tool factory ──
//
// Usage in the editor (caller side):
// const result = toolCall.function.output; // { success, containers[] }
// for (const container of result.containers) {
// commandHistory.execute(new AddContainerCommand(pageId, container));
// }
// This ensures each inserted container is individually undoable.
export const createWidgetsTool = (addLog: LogFunction = defaultLog) =>
zodFunction({
name: 'create_widgets',
description: `Generate valid widget layout fragments (containers + widgets) that can be inserted into a page.
The output must follow the exact container/widget schema described below. You MUST use valid widgetId values and correct props for each widget type.
${WIDGET_SYSTEM_DOCS}`,
schema: CreateWidgetsInputSchema,
function: async (args: CreateWidgetsInput) => {
try {
addLog('info', '[LAYOUT-TOOLS] Tool::CreateWidgets called', {
containerCount: args.containers.length,
totalWidgets: args.containers.reduce((sum, c) => sum + (c.widgets?.length || 0), 0),
});
console.log('args.containers', args)
// Assign proper IDs to all containers and widgets
const hydratedContainers = assignIds(args.containers);
addLog('info', '[LAYOUT-TOOLS] Created layout fragment', {
containers: hydratedContainers.length,
description: args.description,
});
return {
success: true,
containers: hydratedContainers,
description: args.description || 'Layout fragment generated',
message: 'Widget layout created successfully. Insert these containers into a page.',
};
} catch (error: any) {
addLog('error', '[LAYOUT-TOOLS] Tool::CreateWidgets failed', error);
return { success: false, error: error.message };
}
},
});

View File

@ -27,7 +27,6 @@ export const SelectionProvider: React.FC<{ children: ReactNode }> = ({ children
const [clipboard, setClipboard] = useState<ClipboardData | null>(null);
const selectWidget = useCallback((id: string, multi: boolean = false) => {
console.log('SelectionContext: selectWidget', { id, multi });
setSelectedWidgetIds(prev => {
if (multi) {
const newSet = new Set(prev);
@ -44,7 +43,6 @@ export const SelectionProvider: React.FC<{ children: ReactNode }> = ({ children
}, []);
const toggleWidgetSelection = useCallback((id: string) => {
console.log('SelectionContext: toggleWidgetSelection', { id });
setSelectedWidgetIds(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
@ -58,7 +56,6 @@ export const SelectionProvider: React.FC<{ children: ReactNode }> = ({ children
}, []);
const selectContainer = useCallback((id: string | null) => {
console.log('SelectionContext: selectContainer', { id });
setSelectedContainerId(id);
if (id) {
setSelectedWidgetIds(new Set()); // Clear widget selection when selecting container
@ -66,13 +63,11 @@ export const SelectionProvider: React.FC<{ children: ReactNode }> = ({ children
}, []);
const clearSelection = useCallback(() => {
console.log('SelectionContext: clearSelection');
setSelectedWidgetIds(new Set());
setSelectedContainerId(null);
}, []);
const copyToClipboard = useCallback((data: ClipboardData) => {
console.log('SelectionContext: copyToClipboard', { widgets: data.widgets.length, containers: data.containers.length });
setClipboard(JSON.parse(JSON.stringify(data))); // Deep clone
}, []);

View File

@ -4,13 +4,17 @@ import { widgetRegistry } from '@/lib/widgetRegistry';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Search, Plus, X } from 'lucide-react';
import { T } from '@/i18n';
import { Search, Plus, X, Copy, Trash2, Pencil, Check, BookmarkCheck, Loader2 } from 'lucide-react';
import { T, translate } from '@/i18n';
import { useWidgetSnippets, WidgetSnippetData } from './useWidgetSnippets';
import { Database } from '@/integrations/supabase/types';
type Layout = Database['public']['Tables']['layouts']['Row'];
interface WidgetPaletteProps {
isVisible: boolean;
onClose: () => void;
onWidgetAdd: (widgetId: string) => void;
onWidgetAdd: (widgetId: string, initialProps?: Record<string, any>) => void;
}
export const WidgetPalette: React.FC<WidgetPaletteProps> = ({
@ -20,6 +24,12 @@ export const WidgetPalette: React.FC<WidgetPaletteProps> = ({
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [activeTab, setActiveTab] = useState<'registry' | 'saved'>('registry');
// Snippet management
const { snippets, loading: snippetsLoading, cloneSnippet, removeSnippet, updateSnippetName } = useWidgetSnippets();
const [editingSnippetId, setEditingSnippetId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
// Handle Escape key
useEffect(() => {
@ -31,12 +41,9 @@ export const WidgetPalette: React.FC<WidgetPaletteProps> = ({
if (isVisible) {
document.addEventListener('keydown', handleKeyDown);
// Focus the search input when opened
setTimeout(() => {
const searchInput = document.querySelector('#widget-search-input') as HTMLInputElement;
if (searchInput) {
searchInput.focus();
}
if (searchInput) searchInput.focus();
}, 100);
}
@ -49,7 +56,6 @@ export const WidgetPalette: React.FC<WidgetPaletteProps> = ({
const allWidgets = widgetRegistry.getAll();
// Use a Set to collect unique categories from all widgets
const dynamicCategories = Array.from(new Set([
'all',
'control',
@ -66,7 +72,44 @@ export const WidgetPalette: React.FC<WidgetPaletteProps> = ({
? allWidgets
: widgetRegistry.getByCategory(selectedCategory);
const categories = dynamicCategories;
const filteredSnippets = searchQuery
? snippets.filter(s => s.name.toLowerCase().includes(searchQuery.toLowerCase()))
: snippets;
const handleSnippetClick = (snippet: Layout) => {
const data = snippet.layout_json as unknown as WidgetSnippetData;
if (data?.widgetId) {
onWidgetAdd(data.widgetId, data.props);
onClose();
}
};
const handleStartRename = (snippet: Layout, e: React.MouseEvent) => {
e.stopPropagation();
setEditingSnippetId(snippet.id);
setEditingName(snippet.name);
};
const handleConfirmRename = async (e: React.MouseEvent) => {
e.stopPropagation();
if (editingSnippetId && editingName.trim()) {
await updateSnippetName(editingSnippetId, editingName.trim());
}
setEditingSnippetId(null);
setEditingName('');
};
const handleClone = async (snippet: Layout, e: React.MouseEvent) => {
e.stopPropagation();
await cloneSnippet(snippet);
};
const handleDelete = async (snippet: Layout, e: React.MouseEvent) => {
e.stopPropagation();
if (confirm(`${translate("Delete")} "${snippet.name}"?`)) {
await removeSnippet(snippet.id);
}
};
const modalContent = (
<div
@ -91,85 +134,211 @@ export const WidgetPalette: React.FC<WidgetPaletteProps> = ({
</Button>
</CardHeader>
<CardContent className=" space-y-4">
<CardContent className="space-y-4">
{/* Tabs: Registry / Saved */}
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700 pb-2">
<Button
size="sm"
variant={activeTab === 'registry' ? 'default' : 'ghost'}
onClick={() => setActiveTab('registry')}
className="text-xs h-7"
>
<T>Widgets</T>
</Button>
<Button
size="sm"
variant={activeTab === 'saved' ? 'default' : 'ghost'}
onClick={() => setActiveTab('saved')}
className="text-xs h-7 gap-1"
>
<BookmarkCheck className="h-3 w-3" />
<T>Saved</T>
{snippets.length > 0 && (
<span className="ml-1 text-[10px] bg-primary/20 rounded-full px-1.5">
{snippets.length}
</span>
)}
</Button>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
id="widget-search-input"
placeholder="Search widgets..."
placeholder={activeTab === 'registry' ? "Search widgets..." : "Search saved widgets..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 glass-input"
/>
</div>
{/* Categories */}
<div className="flex flex-wrap gap-1">
{categories.map(category => (
<Button
key={category}
size="sm"
variant={selectedCategory === category ? "default" : "outline"}
onClick={() => setSelectedCategory(category)}
className="text-xs h-7"
>
{category.charAt(0).toUpperCase() + category.slice(1)}
</Button>
))}
</div>
{activeTab === 'registry' && (
<>
{/* Categories */}
<div className="flex flex-wrap gap-1">
{dynamicCategories.map(category => (
<Button
key={category}
size="sm"
variant={selectedCategory === category ? "default" : "outline"}
onClick={() => setSelectedCategory(category)}
className="text-xs h-7"
>
{category.charAt(0).toUpperCase() + category.slice(1)}
</Button>
))}
</div>
{/* Widget List */}
<div className="space-y-2 max-h-64 overflow-y-auto">
{widgets.map(widget => (
<div
key={widget.metadata.id}
className="flex items-center justify-between p-3 border border-slate-300/30 dark:border-white/10 rounded-lg bg-white/5 dark:bg-black/5 hover:bg-white/10 dark:hover:bg-black/10 transition-colors cursor-pointer"
onClick={() => {
onWidgetAdd(widget.metadata.id);
onClose();
}}
>
<div className="flex items-center space-x-3 flex-1 min-w-0">
{widget.metadata.icon && (
<div className="h-5 w-5 text-slate-600 dark:text-slate-300 shrink-0">
{React.createElement(widget.metadata.icon, {})}
</div>
)}
<div className="min-w-0 flex-1">
<div className="font-medium text-sm truncate">{widget.metadata.name}</div>
<div className="text-xs text-slate-500 dark:text-slate-400 truncate">
{widget.metadata.description}
{/* Widget List */}
<div className="space-y-2 max-h-64 overflow-y-auto">
{widgets.map(widget => (
<div
key={widget.metadata.id}
className="flex items-center justify-between p-3 border border-slate-300/30 dark:border-white/10 rounded-lg bg-white/5 dark:bg-black/5 hover:bg-white/10 dark:hover:bg-black/10 transition-colors cursor-pointer"
onClick={() => {
onWidgetAdd(widget.metadata.id);
onClose();
}}
>
<div className="flex items-center space-x-3 flex-1 min-w-0">
{widget.metadata.icon && (
<div className="h-5 w-5 text-slate-600 dark:text-slate-300 shrink-0">
{React.createElement(widget.metadata.icon, {})}
</div>
)}
<div className="min-w-0 flex-1">
<div className="font-medium text-sm truncate">{widget.metadata.name}</div>
<div className="text-xs text-slate-500 dark:text-slate-400 truncate">
{widget.metadata.description}
</div>
</div>
</div>
<Button
size="icon"
onClick={(e) => {
e.stopPropagation();
onWidgetAdd(widget.metadata.id);
onClose();
}}
className="h-8 w-8 glass-button shrink-0"
title={`Add ${widget.metadata.name}`}
>
<Plus className="h-4 w-4" />
</Button>
</div>
))}
{widgets.length === 0 && (
<div className="text-center text-slate-500 dark:text-slate-400 py-8">
<T>No widgets found</T>
</div>
)}
</div>
</>
)}
{activeTab === 'saved' && (
<div className="space-y-2 max-h-64 overflow-y-auto">
{snippetsLoading ? (
<div className="flex items-center justify-center py-8 text-slate-500">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
<T>Loading...</T>
</div>
) : filteredSnippets.length === 0 ? (
<div className="text-center text-slate-500 dark:text-slate-400 py-8">
<div className="text-sm mb-2"><T>No saved widgets yet</T></div>
<div className="text-xs"><T>Select a widget in the editor and use "Save as Widget" to create one.</T></div>
</div>
) : (
filteredSnippets.map(snippet => {
const snippetData = snippet.layout_json as unknown as WidgetSnippetData;
const widgetDef = snippetData?.widgetId ? widgetRegistry.get(snippetData.widgetId) : null;
<Button
size="icon"
onClick={(e) => {
e.stopPropagation(); // Prevent the div's onClick from firing
onWidgetAdd(widget.metadata.id);
onClose();
}}
className="h-8 w-8 glass-button shrink-0"
title={`Add ${widget.metadata.name}`}
>
<Plus className="h-4 w-4" />
</Button>
</div>
))}
return (
<div
key={snippet.id}
className="flex items-center justify-between p-3 border border-slate-300/30 dark:border-white/10 rounded-lg bg-white/5 dark:bg-black/5 hover:bg-white/10 dark:hover:bg-black/10 transition-colors cursor-pointer group"
onClick={() => handleSnippetClick(snippet)}
>
<div className="flex items-center space-x-3 flex-1 min-w-0">
{widgetDef?.metadata.icon && (
<div className="h-5 w-5 text-slate-600 dark:text-slate-300 shrink-0">
{React.createElement(widgetDef.metadata.icon, {})}
</div>
)}
<div className="min-w-0 flex-1">
{editingSnippetId === snippet.id ? (
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
<Input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
className="h-6 text-sm px-1"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirmRename(e as any);
if (e.key === 'Escape') {
setEditingSnippetId(null);
setEditingName('');
}
}}
/>
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={handleConfirmRename}>
<Check className="h-3 w-3" />
</Button>
</div>
) : (
<>
<div className="font-medium text-sm truncate">{snippet.name}</div>
<div className="text-xs text-slate-500 dark:text-slate-400 truncate">
{widgetDef?.metadata.name || snippetData?.widgetId || 'Unknown widget'}
</div>
</>
)}
</div>
</div>
{widgets.length === 0 && (
<div className="text-center text-slate-500 dark:text-slate-400 py-8">
<T>No widgets found</T>
</div>
)}
</div>
{/* Action buttons */}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
title={translate("Rename")}
onClick={(e) => handleStartRename(snippet, e)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
title={translate("Clone")}
onClick={(e) => handleClone(snippet, e)}
>
<Copy className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-red-500 hover:text-red-600"
title={translate("Delete")}
onClick={(e) => handleDelete(snippet, e)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
})
)}
</div>
)}
</CardContent>
</Card>
</div>
);
// Render in portal to ensure it's at the top level
return createPortal(modalContent, document.body);
};

View File

@ -391,6 +391,55 @@ export class AddContainerCommand implements Command {
}
}
// --- Batch Add Containers Command ---
// Adds multiple containers in a single execute() against one layout snapshot.
// Avoids React state-batching issue where sequential AddContainerCommand calls
// each read the same stale snapshot and only the last one survives.
export class BatchAddContainersCommand implements Command {
id: string;
type = 'BATCH_ADD_CONTAINERS';
timestamp: number;
private pageId: string;
private containers: LayoutContainer[];
constructor(pageId: string, containers: LayoutContainer[]) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.containers = containers;
}
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;
for (const container of this.containers) {
container.order = newLayout.containers.length;
newLayout.containers.push(container);
}
newLayout.updatedAt = Date.now();
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 idsToRemove = new Set(this.containers.map(c => c.id));
newLayout.containers = newLayout.containers.filter(c => !idsToRemove.has(c.id));
newLayout.containers.forEach((c, i) => c.order = i);
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}
// --- Remove Container Command ---
export class RemoveContainerCommand implements Command {
id: string;

View File

@ -0,0 +1,128 @@
import { useState, useEffect, useCallback } from "react";
import { toast } from "sonner";
import { translate } from "@/i18n";
import { useLayouts } from "./useLayouts";
import { Database } from "@/integrations/supabase/types";
type Layout = Database['public']['Tables']['layouts']['Row'];
const SNIPPET_TYPE = 'widget-snippet';
export interface WidgetSnippetData {
widgetId: string;
props?: Record<string, any>;
}
export function useWidgetSnippets() {
const [snippets, setSnippets] = useState<Layout[]>([]);
const [loading, setLoading] = useState(false);
const { getLayouts, createLayout, updateLayout, deleteLayout } = useLayouts();
const loadSnippets = useCallback(async () => {
setLoading(true);
try {
const { data, error } = await getLayouts({ type: SNIPPET_TYPE });
if (error) throw error;
setSnippets(data || []);
} catch (e) {
console.error("Failed to load widget snippets", e);
} finally {
setLoading(false);
}
}, [getLayouts]);
useEffect(() => {
loadSnippets();
}, []);
const saveSnippet = useCallback(async (name: string, widgetData: WidgetSnippetData) => {
try {
const { data, error } = await createLayout({
name,
layout_json: widgetData as any,
type: SNIPPET_TYPE,
visibility: 'private',
meta: { sourceWidgetType: widgetData.widgetId },
});
if (error) throw error;
toast.success(translate("Widget saved"));
await loadSnippets();
return data;
} catch (e) {
console.error("Failed to save widget snippet", e);
toast.error(translate("Failed to save widget"));
return null;
}
}, [createLayout, loadSnippets]);
const updateSnippetName = useCallback(async (id: string, name: string) => {
try {
const { error } = await updateLayout(id, { name });
if (error) throw error;
toast.success(translate("Widget renamed"));
await loadSnippets();
} catch (e) {
console.error("Failed to rename widget snippet", e);
toast.error(translate("Failed to rename widget"));
}
}, [updateLayout, loadSnippets]);
const updateSnippetData = useCallback(async (id: string, widgetData: WidgetSnippetData) => {
try {
const { error } = await updateLayout(id, {
layout_json: widgetData as any,
meta: { sourceWidgetType: widgetData.widgetId },
});
if (error) throw error;
toast.success(translate("Widget updated"));
await loadSnippets();
} catch (e) {
console.error("Failed to update widget snippet", e);
toast.error(translate("Failed to update widget"));
}
}, [updateLayout, loadSnippets]);
const cloneSnippet = useCallback(async (snippet: Layout) => {
const snippetData = snippet.layout_json as unknown as WidgetSnippetData;
try {
const { data, error } = await createLayout({
name: `${snippet.name} (copy)`,
layout_json: snippetData as any,
type: SNIPPET_TYPE,
visibility: 'private',
meta: snippet.meta || { sourceWidgetType: snippetData?.widgetId },
});
if (error) throw error;
toast.success(translate("Widget cloned"));
await loadSnippets();
return data;
} catch (e) {
console.error("Failed to clone widget snippet", e);
toast.error(translate("Failed to clone widget"));
return null;
}
}, [createLayout, loadSnippets]);
const removeSnippet = useCallback(async (id: string) => {
try {
const { error } = await deleteLayout(id);
if (error) throw error;
toast.success(translate("Widget deleted"));
await loadSnippets();
} catch (e) {
console.error("Failed to delete widget snippet", e);
toast.error(translate("Failed to delete widget"));
}
}, [deleteLayout, loadSnippets]);
return {
snippets,
loading,
loadSnippets,
saveSnippet,
updateSnippetName,
updateSnippetData,
cloneSnippet,
removeSnippet,
};
}

View File

@ -0,0 +1,737 @@
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import {
Folder, File, ArrowUp, List, LayoutGrid,
ArrowUpDown, Clock, FileType, Type,
ChevronRight, Info, Loader2, Download, ExternalLink, X, ChevronLeft, ZoomIn, ZoomOut,
Image, Film, Music, FileCode, FileText as FileTextIcon,
Archive, FileSpreadsheet, Presentation
} from 'lucide-react';
import type { FileBrowserWidgetProps } from '@polymech/shared';
// ── Types ────────────────────────────────────────────────────────
interface INode {
name: string;
path: string;
size: number;
mtime?: number;
mime?: string;
parent: string;
type: string;
}
type SortKey = 'name' | 'ext' | 'date' | 'type';
type MimeCategory = 'dir' | 'image' | 'video' | 'audio' | 'code' | 'document' | 'archive' | 'spreadsheet' | 'presentation' | 'other';
// ── MIME helpers ─────────────────────────────────────────────────
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico', 'tiff', 'tif']);
const VIDEO_EXTS = new Set(['mp4', 'mov', 'webm', 'mkv', 'avi', 'flv', 'wmv', 'm4v', 'ogv']);
const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma', 'opus']);
const CODE_EXTS = new Set(['ts', 'tsx', 'js', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'swift', 'kt', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd', 'lua', 'php', 'sql', 'r', 'html', 'htm', 'css', 'scss', 'sass', 'less', 'json', 'yaml', 'yml', 'toml', 'xml', 'vue', 'svelte']);
const DOC_EXTS = new Set(['md', 'txt', 'rtf', 'pdf', 'doc', 'docx', 'odt', 'tex', 'log']);
const ARCHIVE_EXTS = new Set(['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'zst', 'tgz']);
const SPREADSHEET_EXTS = new Set(['xls', 'xlsx', 'csv', 'ods', 'tsv']);
const PRESENTATION_EXTS = new Set(['ppt', 'pptx', 'odp', 'key']);
function getExt(name: string): string {
const i = name.lastIndexOf('.');
return i > 0 ? name.slice(i + 1).toLowerCase() : '';
}
function getMimeCategory(node: INode): MimeCategory {
if (node.mime === 'inode/directory' || node.type === 'dir') return 'dir';
const mime = node.mime || '';
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('video/')) return 'video';
if (mime.startsWith('audio/')) return 'audio';
if (mime.startsWith('text/x-') || mime.includes('javascript') || mime.includes('typescript') || mime.includes('json') || mime.includes('xml') || mime.includes('yaml')) return 'code';
const ext = getExt(node.name);
if (IMAGE_EXTS.has(ext)) return 'image';
if (VIDEO_EXTS.has(ext)) return 'video';
if (AUDIO_EXTS.has(ext)) return 'audio';
if (CODE_EXTS.has(ext)) return 'code';
if (SPREADSHEET_EXTS.has(ext)) return 'spreadsheet';
if (PRESENTATION_EXTS.has(ext)) return 'presentation';
if (ARCHIVE_EXTS.has(ext)) return 'archive';
if (DOC_EXTS.has(ext)) return 'document';
return 'other';
}
const CATEGORY_STYLE: Record<MimeCategory, { icon: React.FC<any>; color: string }> = {
dir: { icon: Folder, color: '#60a5fa' },
image: { icon: Image, color: '#22c55e' },
video: { icon: Film, color: '#ef4444' },
audio: { icon: Music, color: '#8b5cf6' },
code: { icon: FileCode, color: '#3b82f6' },
document: { icon: FileTextIcon, color: '#f59e0b' },
archive: { icon: Archive, color: '#eab308' },
spreadsheet: { icon: FileSpreadsheet, color: '#16a34a' },
presentation: { icon: Presentation, color: '#ec4899' },
other: { icon: File, color: '#94a3b8' },
};
function NodeIcon({ node, size = 14 }: { node: INode; size?: number }) {
const cat = getMimeCategory(node);
const { icon: Icon, color } = CATEGORY_STYLE[cat];
return <Icon size={size} style={{ color, flexShrink: 0 }} />;
}
// ── Format helpers ───────────────────────────────────────────────
function formatSize(bytes: number): string {
if (bytes === 0) return '—';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const v = bytes / Math.pow(1024, i);
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
}
function formatDate(ts?: number): string {
if (!ts) return '—';
const d = new Date(ts);
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
+ ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
}
// ── Sort ─────────────────────────────────────────────────────────
function sortNodes(nodes: INode[], sortBy: SortKey, asc: boolean): INode[] {
const dirs = nodes.filter(n => n.mime === 'inode/directory' || n.type === 'dir');
const files = nodes.filter(n => n.mime !== 'inode/directory' && n.type !== 'dir');
const cmp = (a: INode, b: INode): number => {
let r = 0;
switch (sortBy) {
case 'name': r = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); break;
case 'ext': r = getExt(a.name).localeCompare(getExt(b.name)) || a.name.localeCompare(b.name); break;
case 'date': r = (a.mtime ?? 0) - (b.mtime ?? 0); break;
case 'type': r = getMimeCategory(a).localeCompare(getMimeCategory(b)) || a.name.localeCompare(b.name); break;
}
return asc ? r : -r;
};
dirs.sort(cmp);
files.sort(cmp);
return [...dirs, ...files];
}
// ── Thumbnail helper ─────────────────────────────────────────────
function ThumbPreview({ node, mount, height = 64 }: { node: INode; mount: string; height?: number }) {
const cat = getMimeCategory(node);
const fileUrl = `/api/vfs/get/${encodeURIComponent(mount)}/${node.path}`;
if (cat === 'image') {
return <img src={fileUrl} alt={node.name} loading="lazy" style={{ width: '100%', height, objectFit: 'cover', borderRadius: 4 }} />;
}
if (cat === 'video') {
return (
<div style={{ position: 'relative', width: '100%', height }}>
<video src={fileUrl} muted preload="metadata" style={{ width: '100%', height, objectFit: 'cover', borderRadius: 4 }} />
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.3)', borderRadius: 4 }}>
<Film size={20} style={{ color: '#fff', opacity: 0.8 }} />
</div>
</div>
);
}
return <NodeIcon node={node} size={28} />;
}
// ── Toolbar button ───────────────────────────────────────────────
const TB_BTN: React.CSSProperties = {
background: 'none', border: 'none', cursor: 'pointer',
padding: 4, display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--muted-foreground, #94a3b8)', borderRadius: 4,
};
const TB_BTN_ACTIVE: React.CSSProperties = { ...TB_BTN, color: 'var(--foreground, #e2e8f0)' };
const TB_SEP: React.CSSProperties = { width: 1, height: 18, background: 'var(--border, #334155)', flexShrink: 0 };
// ── Main Component ───────────────────────────────────────────────
const FileBrowserWidget: React.FC<FileBrowserWidgetProps & { variables?: any }> = (props) => {
const {
mount = 'test',
path: initialPath = '/',
glob = '*.*',
mode = 'simple',
viewMode: initialViewMode = 'list',
sortBy: initialSort = 'name',
showToolbar = true,
} = props;
const [currentPath, setCurrentPath] = useState(initialPath);
const [nodes, setNodes] = useState<INode[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'list' | 'thumbs'>(initialViewMode);
const [sortBy, setSortBy] = useState<SortKey>(initialSort);
const [sortAsc, setSortAsc] = useState(true);
const [focusIdx, setFocusIdx] = useState(-1);
const [selected, setSelected] = useState<INode | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// ── Zoom (persisted) ─────────────────────────────────────────
const [thumbSize, setThumbSize] = useState(() => {
const v = localStorage.getItem('fb-thumb-size');
return v ? Math.max(60, Math.min(200, Number(v))) : 100;
});
const [fontSize, setFontSize] = useState(() => {
const v = localStorage.getItem('fb-font-size');
return v ? Math.max(10, Math.min(18, Number(v))) : 14;
});
const zoomIn = () => {
if (viewMode === 'thumbs') setThumbSize(s => { const n = Math.min(200, s + 20); localStorage.setItem('fb-thumb-size', String(n)); return n; });
else setFontSize(s => { const n = Math.min(18, s + 1); localStorage.setItem('fb-font-size', String(n)); return n; });
};
const zoomOut = () => {
if (viewMode === 'thumbs') setThumbSize(s => { const n = Math.max(60, s - 20); localStorage.setItem('fb-thumb-size', String(n)); return n; });
else setFontSize(s => { const n = Math.max(10, s - 1); localStorage.setItem('fb-font-size', String(n)); return n; });
};
// ── Fetch ───────────────────────────────────────────────────
const fetchDir = useCallback(async (dirPath: string) => {
setLoading(true);
setError(null);
setSelected(null);
setFocusIdx(-1);
try {
const clean = dirPath.replace(/^\/+/, '');
const base = clean
? `/api/vfs/ls/${encodeURIComponent(mount)}/${clean}`
: `/api/vfs/ls/${encodeURIComponent(mount)}`;
const url = glob && glob !== '*.*' ? `${base}?glob=${encodeURIComponent(glob)}` : base;
const res = await fetch(url);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
setNodes(await res.json());
} catch (e: any) {
setError(e.message || 'Failed to load directory');
setNodes([]);
} finally {
setLoading(false);
}
}, [mount, glob]);
useEffect(() => { fetchDir(currentPath); }, [currentPath, fetchDir]);
useEffect(() => { setCurrentPath(initialPath); }, [initialPath]);
// ── Sorted items (with optional ".." at index 0) ────────────
const canGoUp = currentPath !== '/' && currentPath !== '';
const sorted = useMemo(() => sortNodes(nodes, sortBy, sortAsc), [nodes, sortBy, sortAsc]);
// Virtual list: ".." occupies index 0 when canGoUp, real items follow
const itemCount = sorted.length + (canGoUp ? 1 : 0);
const getNode = (idx: number): INode | null => {
if (canGoUp && idx === 0) return null; // ".."
return sorted[canGoUp ? idx - 1 : idx] ?? null;
};
// ── Navigation ──────────────────────────────────────────────
const openNode = (node: INode) => {
if (getMimeCategory(node) === 'dir') {
setCurrentPath(node.path || node.name);
} else {
setSelected(prev => prev?.path === node.path ? null : node);
}
};
const goUp = useCallback(() => {
if (!canGoUp) return;
const parts = currentPath.replace(/^\/+/, '').split('/').filter(Boolean);
parts.pop();
setCurrentPath(parts.length ? parts.join('/') : '/');
}, [currentPath, canGoUp]);
const getFileUrl = (node: INode) => `/api/vfs/get/${encodeURIComponent(mount)}/${node.path}`;
const handleView = () => { if (selected) window.open(getFileUrl(selected), '_blank'); };
const handleDownload = () => {
if (!selected) return;
const a = document.createElement('a');
a.href = getFileUrl(selected);
a.download = selected.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
// ── Sort toggle ─────────────────────────────────────────────
const cycleSort = () => {
const keys: SortKey[] = ['name', 'ext', 'date', 'type'];
const i = keys.indexOf(sortBy);
if (sortAsc) { setSortAsc(false); } else { setSortBy(keys[(i + 1) % keys.length]); setSortAsc(true); }
};
const sortIcons: Record<SortKey, React.ReactNode> = { name: <Type size={16} />, ext: <FileType size={16} />, date: <Clock size={16} />, type: <ArrowUpDown size={16} /> };
// ── Breadcrumbs ─────────────────────────────────────────────
const breadcrumbs = useMemo(() => {
const parts = currentPath.replace(/^\/+/, '').split('/').filter(Boolean);
const crumbs = [{ label: mount, path: '/' }];
let acc = '';
for (const p of parts) { acc += (acc ? '/' : '') + p; crumbs.push({ label: p, path: acc }); }
return crumbs;
}, [currentPath, mount]);
// ── Keyboard navigation ─────────────────────────────────────
const scrollItemIntoView = useCallback((idx: number) => {
if (!listRef.current) return;
const items = listRef.current.querySelectorAll('[data-fb-idx]');
const el = items[idx] as HTMLElement | undefined;
el?.scrollIntoView({ block: 'nearest' });
}, []);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (itemCount === 0) return;
switch (e.key) {
case 'ArrowDown':
case 'ArrowRight':
case 'j': {
e.preventDefault();
const next = focusIdx < itemCount - 1 ? focusIdx + 1 : 0;
setFocusIdx(next);
const node = getNode(next);
if (node) setSelected(node);
else setSelected(null);
scrollItemIntoView(next);
break;
}
case 'ArrowUp':
case 'ArrowLeft':
case 'k': {
e.preventDefault();
const prev = focusIdx > 0 ? focusIdx - 1 : itemCount - 1;
setFocusIdx(prev);
const node = getNode(prev);
if (node) setSelected(node);
else setSelected(null);
scrollItemIntoView(prev);
break;
}
case 'Enter':
case 'l': {
e.preventDefault();
if (focusIdx < 0) break;
const node = getNode(focusIdx);
if (!node) { goUp(); break; }
openNode(node);
break;
}
case 'Backspace':
case 'h': {
e.preventDefault();
goUp();
break;
}
case 'Home': {
e.preventDefault();
setFocusIdx(0);
const node = getNode(0);
if (node) setSelected(node); else setSelected(null);
scrollItemIntoView(0);
break;
}
case 'End': {
e.preventDefault();
const last = itemCount - 1;
setFocusIdx(last);
const node = getNode(last);
if (node) setSelected(node); else setSelected(null);
scrollItemIntoView(last);
break;
}
case ' ': {
e.preventDefault();
if (focusIdx < 0) break;
const node = getNode(focusIdx);
const cat = node ? getMimeCategory(node) : null;
if (node && (cat === 'image' || cat === 'video')) setLightboxNode(node);
break;
}
case 'Escape': {
e.preventDefault();
setSelected(null);
setFocusIdx(-1);
break;
}
}
}, [focusIdx, itemCount, goUp, scrollItemIntoView, sorted, canGoUp]);
// Focus container on mount and after nav for keyboard capture
useEffect(() => { containerRef.current?.focus(); }, [currentPath]);
const selectedFile = selected && getMimeCategory(selected) !== 'dir' ? selected : null;
// ── Lightbox state ──────────────────────────────────────────
const [lightboxNode, setLightboxNode] = useState<INode | null>(null);
const mediaNodes = useMemo(() => sorted.filter(n => { const c = getMimeCategory(n); return c === 'image' || c === 'video'; }), [sorted]);
const lightboxIdx = lightboxNode ? mediaNodes.findIndex(n => n.path === lightboxNode.path) : -1;
const lightboxIsVideo = lightboxNode ? getMimeCategory(lightboxNode) === 'video' : false;
const lightboxPrev = () => {
if (lightboxIdx > 0) setLightboxNode(mediaNodes[lightboxIdx - 1]);
};
const lightboxNext = () => {
if (lightboxIdx < mediaNodes.length - 1) setLightboxNode(mediaNodes[lightboxIdx + 1]);
};
const closeLightbox = () => {
setLightboxNode(null);
setTimeout(() => containerRef.current?.focus(), 0);
};
// ── Row click handler ───────────────────────────────────────
const onItemClick = (idx: number) => {
setFocusIdx(idx);
const node = getNode(idx);
if (!node) return;
setSelected(prev => prev?.path === node.path ? null : node);
};
const onItemDoubleClick = (idx: number) => {
const node = getNode(idx);
if (!node) { goUp(); return; }
const cat = getMimeCategory(node);
if (cat === 'dir') {
setCurrentPath(node.path || node.name);
} else if (cat === 'image' || cat === 'video') {
setLightboxNode(node);
} else {
window.open(getFileUrl(node), '_blank');
}
};
// ── Styles ──────────────────────────────────────────────────
const focusBg = 'var(--accent, #334155)';
const selectedBg = 'rgba(59, 130, 246, 0.15)';
const selectedBorder = 'var(--ring, #3b82f6)';
// ── Render ──────────────────────────────────────────────────
return (
<div
ref={containerRef}
tabIndex={0}
onKeyDown={handleKeyDown}
style={{
display: 'flex', flexDirection: 'column', height: '100%',
border: '1px solid var(--border, #334155)', borderRadius: 6, overflow: 'hidden',
background: 'var(--background, #0f172a)', color: 'var(--foreground, #e2e8f0)',
fontFamily: 'var(--font-sans, system-ui, sans-serif)', outline: 'none',
}}
>
<style>{`
.fb-row:hover { background: var(--accent, #334155) !important; }
.fb-thumb:hover { border-color: var(--ring, #3b82f6) !important; background: var(--accent, #1e293b) !important; }
.fb-tb-btn:hover { background: var(--accent, #334155) !important; color: var(--foreground, #e2e8f0) !important; }
@media (max-width: 767px) { .fb-detail-pane { display: none !important; } }
`}</style>
{/* ═══ Toolbar ═══════════════════════════════════ */}
{showToolbar && (
<div style={{
display: 'flex', alignItems: 'center', gap: 2, padding: '4px 6px',
borderBottom: '1px solid var(--border, #334155)',
background: 'var(--muted, #1e293b)',
}}>
{/* Navigation */}
<button onClick={goUp} disabled={!canGoUp} title="Go up (Backspace)" className="fb-tb-btn"
style={{ ...TB_BTN, opacity: canGoUp ? 1 : 0.3, cursor: canGoUp ? 'pointer' : 'default' }}>
<ArrowUp size={18} />
</button>
{/* Breadcrumbs */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 1, overflow: 'hidden', padding: '0 4px' }}>
{breadcrumbs.map((c, i) => (
<React.Fragment key={c.path}>
{i > 0 && <ChevronRight size={10} style={{ opacity: 0.3, flexShrink: 0 }} />}
<button onClick={() => setCurrentPath(c.path)} style={{
background: 'none', border: 'none', cursor: 'pointer',
color: i === breadcrumbs.length - 1 ? 'var(--foreground, #e2e8f0)' : 'var(--muted-foreground, #94a3b8)',
fontWeight: i === breadcrumbs.length - 1 ? 600 : 400,
padding: '2px 3px', borderRadius: 3, whiteSpace: 'nowrap', fontSize: 11,
}}>
{c.label}
</button>
</React.Fragment>
))}
</div>
<div style={TB_SEP} />
{/* File actions — shown when a file is selected */}
{selectedFile && (<>
<button onClick={handleView} title="View in browser" className="fb-tb-btn" style={TB_BTN}>
<ExternalLink size={18} />
</button>
<button onClick={handleDownload} title="Download" className="fb-tb-btn" style={TB_BTN}>
<Download size={18} />
</button>
<div style={TB_SEP} />
</>)}
{/* Sort */}
<button onClick={cycleSort} title={`Sort: ${sortBy} (${sortAsc ? 'asc' : 'desc'})`} className="fb-tb-btn"
style={{ ...TB_BTN, gap: 2 }}>
{sortIcons[sortBy]}
<span style={{ fontSize: 9, opacity: 0.6 }}>{sortAsc ? '↑' : '↓'}</span>
</button>
{/* Zoom */}
<button onClick={zoomOut} title="Zoom out" className="fb-tb-btn" style={TB_BTN}>
<ZoomOut size={18} />
</button>
<button onClick={zoomIn} title="Zoom in" className="fb-tb-btn" style={TB_BTN}>
<ZoomIn size={18} />
</button>
{/* View mode */}
<button onClick={() => setViewMode(v => v === 'list' ? 'thumbs' : 'list')}
title={viewMode === 'list' ? 'Thumbnails' : 'List'} className="fb-tb-btn" style={TB_BTN}>
{viewMode === 'list' ? <LayoutGrid size={18} /> : <List size={18} />}
</button>
</div>
)}
{/* ═══ Content ═══════════════════════════════════ */}
{loading ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, opacity: 0.6 }}>
<Loader2 size={16} className="animate-spin" />
<span style={{ fontSize: 14 }}>Loading</span>
</div>
) : error ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, fontSize: 14, color: '#ef4444' }}>
{error}
</div>
) : itemCount === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, fontSize: 14, opacity: 0.5 }}>
Empty directory
</div>
) : (
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* ── List view ──────────────────────── */}
{viewMode === 'list' ? (
<div ref={listRef} style={{ overflowY: 'auto', flex: 1 }}>
{canGoUp && (
<div data-fb-idx={0} onClick={() => { setFocusIdx(0); goUp(); }}
className="fb-row" style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px',
cursor: 'pointer', fontSize, borderBottom: '1px solid var(--border, #1e293b)',
background: focusIdx === 0 ? focusBg : 'transparent',
}}>
<ArrowUp size={14} style={{ color: CATEGORY_STYLE.dir.color }} />
<span style={{ fontWeight: 500 }}>..</span>
</div>
)}
{sorted.map((node, i) => {
const idx = (canGoUp ? i + 1 : i);
const isDir = getMimeCategory(node) === 'dir';
const isFocused = focusIdx === idx;
const isSelected = selected?.path === node.path;
return (
<div key={node.path || node.name} data-fb-idx={idx}
onClick={() => onItemClick(idx)}
onDoubleClick={() => onItemDoubleClick(idx)}
className="fb-row" style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px',
cursor: 'pointer', fontSize,
borderBottomWidth: 1, borderBottomColor: 'var(--border, #1e293b)', borderBottomStyle: 'solid',
background: isSelected ? selectedBg : isFocused ? focusBg : 'transparent',
borderLeftWidth: 2, borderLeftColor: isSelected ? selectedBorder : 'transparent',
borderLeftStyle: isSelected ? 'outset' : 'solid',
}}>
<NodeIcon node={node} />
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{node.name}</span>
{!isDir && (
<span style={{ color: 'var(--muted-foreground, #64748b)', fontSize: 10, flexShrink: 0 }}>
{formatSize(node.size)}
</span>
)}
{mode === 'advanced' && node.mtime && (
<span style={{ color: 'var(--muted-foreground, #64748b)', fontSize: 10, flexShrink: 0, width: 120, textAlign: 'right' }}>
{formatDate(node.mtime)}
</span>
)}
</div>
);
})}
</div>
) : (
/* ── Thumb view ─────────────────────── */
<div ref={listRef} style={{
overflowY: 'auto', flex: 1, display: 'grid',
gridTemplateColumns: `repeat(auto-fill, minmax(${thumbSize}px, 1fr))`, gap: 6, padding: 8,
}}>
{canGoUp && (
<div data-fb-idx={0} onClick={() => { setFocusIdx(0); goUp(); }} className="fb-thumb" style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
padding: 8, borderRadius: 6, cursor: 'pointer', gap: 4,
borderWidth: 1, borderColor: focusIdx === 0 ? selectedBorder : 'var(--border, #334155)',
borderStyle: focusIdx === 0 ? 'outset' : 'solid',
}}>
<ArrowUp size={24} style={{ color: CATEGORY_STYLE.dir.color }} />
<span style={{ fontSize: 14 }}>..</span>
</div>
)}
{sorted.map((node, i) => {
const idx = (canGoUp ? i + 1 : i);
const isDir = getMimeCategory(node) === 'dir';
const isFocused = focusIdx === idx;
const isSelected = selected?.path === node.path;
return (
<div key={node.path || node.name} data-fb-idx={idx}
onClick={() => onItemClick(idx)}
onDoubleClick={() => onItemDoubleClick(idx)}
className="fb-thumb" style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
padding: 6, borderRadius: 6, cursor: 'pointer', gap: 4, overflow: 'hidden',
borderWidth: isSelected ? 2 : 1,
borderColor: isSelected ? selectedBorder : isFocused ? selectedBorder : 'transparent',
borderStyle: isSelected ? 'outset' : 'solid',
background: isSelected ? selectedBg : isFocused ? focusBg : 'transparent',
}}>
{isDir ? <NodeIcon node={node} size={28} /> : <ThumbPreview node={node} mount={mount} />}
<span style={{
fontSize: 14, textAlign: 'center', width: '100%',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{node.name}
</span>
</div>
);
})}
</div>
)}
{/* ── Detail panel (advanced, desktop only) ── */}
{mode === 'advanced' && selectedFile && (
<div className="fb-detail-pane" style={{
width: 200, borderLeft: '1px solid var(--border, #334155)',
padding: 10, fontSize: 11, overflowY: 'auto', flexShrink: 0,
background: 'var(--muted, #1e293b)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
<Info size={14} />
<span style={{ fontWeight: 600 }}>Details</span>
</div>
{getMimeCategory(selectedFile) === 'image' && (
<img src={getFileUrl(selectedFile)} alt={selectedFile.name}
style={{ width: '100%', borderRadius: 4, marginBottom: 8, objectFit: 'contain' }} />
)}
{getMimeCategory(selectedFile) === 'video' && (
<video key={selectedFile.path} src={getFileUrl(selectedFile)} controls muted preload="metadata"
style={{ width: '100%', borderRadius: 4, marginBottom: 8 }} />
)}
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
{([
['Name', selectedFile.name],
['Path', selectedFile.path],
['Size', formatSize(selectedFile.size)],
['Modified', formatDate(selectedFile.mtime)],
['MIME', selectedFile.mime || '—'],
['Type', getMimeCategory(selectedFile)],
] as [string, string][]).map(([label, val]) => (
<tr key={label}>
<td style={{ padding: '3px 4px 3px 0', color: 'var(--muted-foreground, #94a3b8)', whiteSpace: 'nowrap', verticalAlign: 'top' }}>{label}</td>
<td style={{ padding: '3px 0', wordBreak: 'break-all' }}>{val}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* ═══ Status bar ════════════════════════════════ */}
<div style={{
padding: '3px 10px', fontSize: 10, borderTop: '1px solid var(--border, #334155)',
color: 'var(--muted-foreground, #64748b)', display: 'flex', justifyContent: 'space-between',
background: 'var(--muted, #1e293b)',
}}>
<span>{sorted.length} item{sorted.length !== 1 ? 's' : ''}{selectedFile ? ` · ${selectedFile.name}` : ''}</span>
<span>{mount}:{currentPath || '/'}</span>
</div>
{/* ═══ Lightbox ═════════════════════════════════ */}
{lightboxNode && (
<div onClick={() => closeLightbox()} onKeyDown={(e) => {
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft') lightboxPrev();
if (e.key === 'ArrowRight') lightboxNext();
e.stopPropagation();
}} tabIndex={0} ref={el => el?.focus()} style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.85)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'zoom-out',
}}>
{/* Close */}
<button onClick={() => closeLightbox()} style={{
position: 'absolute', top: 16, right: 16, background: 'none', border: 'none',
color: '#fff', cursor: 'pointer', opacity: 0.7,
}}><X size={24} /></button>
{/* Prev */}
{lightboxIdx > 0 && (
<button onClick={(e) => { e.stopPropagation(); lightboxPrev(); }} style={{
position: 'absolute', left: 16, top: '50%', transform: 'translateY(-50%)',
background: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%',
width: 40, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}><ChevronLeft size={22} /></button>
)}
{/* Media */}
{lightboxIsVideo ? (
<video
key={lightboxNode.path}
onClick={(e) => e.stopPropagation()}
src={getFileUrl(lightboxNode)}
controls autoPlay
style={{ maxWidth: '90vw', maxHeight: '90vh', cursor: 'default', borderRadius: 4, background: '#000' }}
/>
) : (
<img
onClick={(e) => e.stopPropagation()}
src={getFileUrl(lightboxNode)}
alt={lightboxNode.name}
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', cursor: 'default', borderRadius: 4 }}
/>
)}
{/* Next */}
{lightboxIdx < mediaNodes.length - 1 && (
<button onClick={(e) => { e.stopPropagation(); lightboxNext(); }} style={{
position: 'absolute', right: 16, top: '50%', transform: 'translateY(-50%)',
background: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%',
width: 40, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}><ChevronRight size={22} /></button>
)}
{/* Counter + filename */}
<div style={{
position: 'absolute', bottom: 16, left: '50%', transform: 'translateX(-50%)',
color: '#fff', fontSize: 14, opacity: 0.7, textAlign: 'center',
}}>
{lightboxNode.name} · {lightboxIdx + 1}/{mediaNodes.length}
</div>
</div>
)}
</div>
);
};
export default FileBrowserWidget;

View File

@ -21,17 +21,7 @@ const CategoryManager = React.lazy(() => import("@/components/widgets/CategoryMa
type Layout = Database['public']['Tables']['layouts']['Row'];
interface Page {
id: string;
title: string;
content: any;
visible: boolean;
is_public: boolean;
owner: string;
slug: string;
parent: string | null;
meta?: any;
}
import { Page } from "./types";
interface PageActionsProps {
page: Page;

View File

@ -133,9 +133,9 @@ const PageCard: React.FC<PageCardProps> = ({
</div>
{showContent && (
<div className="p-4 space-y-2">
<div className="p-2 space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-xl font-semibold">{title}</h3>
<h3 className="text-sm ">{title}</h3>
</div>
{description && (

View File

@ -6,7 +6,8 @@ import { Button } from '@/components/ui/button';
import { FileText } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import type { MediaType } from '@/types';
import { T, translate } from '@/i18n';
import { ImageIcon, Plus, Upload, Loader2 } from 'lucide-react';
import { fetchPageDetailsById } from '@/modules/pages/client-pages';
interface PageCardWidgetProps {
@ -182,8 +183,9 @@ const PageCardWidget: React.FC<PageCardWidgetProps> = ({
onClick={() => setShowPagePicker(true)}
size="sm"
variant="secondary"
className="rounded-full"
>
Change Page
<ImageIcon className="w-4 h-4" />
</Button>
</div>
<PagePickerDialog

View File

@ -23,21 +23,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface Page {
id: string;
title: string;
slug: string;
content: any;
owner: string;
parent: string | null;
type: string | null;
tags: string[] | null;
is_public: boolean;
visible: boolean;
created_at: string;
updated_at: string;
}
import { Page } from "./types";
interface PageManagerProps {
userId: string;

View File

@ -7,6 +7,7 @@ import { Search, FileText, Check } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { cn } from '@/lib/utils';
import { fetchUserPages } from '@/modules/pages/client-pages';
import { Page } from './types';
interface PagePickerDialogProps {
isOpen: boolean;
@ -16,16 +17,6 @@ interface PagePickerDialogProps {
forbiddenIds?: string[]; // IDs that cannot be selected (e.g. self)
}
interface Page {
id: string;
title: string;
slug: string;
is_public: boolean;
visible: boolean;
updated_at: string;
meta?: any;
}
export const PagePickerDialog: React.FC<PagePickerDialogProps> = ({
isOpen,
onClose,

View File

@ -28,42 +28,7 @@ import { SEO } from "@/components/SEO";
const UserPageEdit = lazy(() => import("./editor/UserPageEdit"));
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;
}
import { Page, UserProfile } from "./types";
interface UserPageProps {
userId?: string;
@ -336,7 +301,7 @@ const UserPageContent = ({ userId: propUserId, slug: propSlug, embedded = false,
{/* Right Content - Independent scroll */}
<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="h-full overflow-y-auto scrollbar-custom bg-background/30 dark:bg-black/50 backdrop-blur-sm">
<div className="container mx-auto p-4 md:p-8 max-w-5xl">
{/* Mobile TOC */}
@ -357,7 +322,7 @@ const UserPageContent = ({ userId: propUserId, slug: propSlug, embedded = false,
/>
{/* Content Body */}
<div>
<div className="">
{page.content && typeof page.content === 'string' ? (
<div className="prose prose-lg dark:prose-invert max-w-none pb-12">
<MarkdownRenderer content={page.content} variables={contextVariables} />

View File

@ -0,0 +1,146 @@
import React, { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { T } from '@/i18n';
import { AIPageGenerator } from '@/modules/pages/AIPageGenerator';
import { runTools } from '@/lib/openai';
import { toast } from 'sonner';
import { usePromptHistory } from '@/hooks/usePromptHistory';
import { LayoutContainer } from '@/modules/layout/LayoutManager';
interface AILayoutWizardProps {
isOpen: boolean;
onClose: () => void;
onContainersGenerated: (containers: LayoutContainer[]) => void;
/** Merged page / category / user variables — flat key→value pairs */
contextVariables?: Record<string, string>;
/** Minimal page context forwarded to the LLM for awareness */
pageContext?: { title?: string; slug?: string; meta?: Record<string, any> };
}
/**
* Build added context that gets prepended to the user prompt so the LLM
* knows which template variables exist and what the page is about.
*/
function buildImplicitContext(
contextVariables?: Record<string, string>,
pageContext?: AILayoutWizardProps['pageContext'],
): string {
const parts: string[] = [];
// ── Page metadata ──
if (pageContext) {
const meta: string[] = [];
if (pageContext.title) meta.push(`Title: "${pageContext.title}"`);
if (pageContext.slug) meta.push(`Slug: "${pageContext.slug}"`);
if (pageContext.meta?.description) meta.push(`Description: "${pageContext.meta.description}"`);
if (meta.length > 0) {
parts.push(`## Current page\n${meta.join('\n')}`);
}
}
// ── Template variables with current values ──
if (contextVariables && Object.keys(contextVariables).length > 0) {
parts.push(
`## Available template variables\nYou may reference these with ${name} syntax inside widget text (HTML Widget, Markdown Widget).\nCurrent values:\n\`\`\`json\n${JSON.stringify(contextVariables, null, 2)}\n\`\`\`\nUse the actual values above to produce realistic, data-driven content.`
);
}
return parts.length > 0
? parts.join('\n\n') + '\n\n---\n\n'
: '';
}
export const AILayoutWizard: React.FC<AILayoutWizardProps> = ({
isOpen,
onClose,
onContainersGenerated,
contextVariables,
pageContext,
}) => {
const [isGenerating, setIsGenerating] = useState(false);
const {
prompt,
setPrompt,
promptHistory,
historyIndex,
navigateHistory,
addPromptToHistory,
} = usePromptHistory();
useEffect(() => {
if (isOpen) setPrompt('');
}, [isOpen, setPrompt]);
const handleGenerate = async () => {
if (!prompt.trim()) return;
setIsGenerating(true);
addPromptToHistory(prompt);
try {
// Prepend implicit context (variables, page info) to the user prompt
const enrichedPrompt = buildImplicitContext(contextVariables, pageContext) + prompt;
const result = await runTools({
prompt: enrichedPrompt,
preset: 'layout-generator',
});
if (!result.success) {
toast.error(result.error || 'AI generation failed.');
return;
}
// Extract containers from ALL create_widgets tool calls (LLM may call it multiple times)
const allContainers = result.toolCalls
.filter((tc: any) => tc.function?.name === 'create_widgets')
.flatMap((tc: any) => {
const out = tc.function?.output;
return out?.success && Array.isArray(out.containers) ? out.containers : [];
});
if (allContainers.length > 0) {
console.log('allContainers', allContainers);
onContainersGenerated(allContainers);
onClose();
toast.success(`Generated ${allContainers.length} container(s)`);
} else {
toast.error('AI did not produce any layout containers.');
}
} catch (error: any) {
console.error('[AILayoutWizard] Generation error:', error);
toast.error('An error occurred during generation.');
} finally {
setIsGenerating(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle><T>AI Layout Assistant</T></DialogTitle>
<DialogDescription>
<T>Describe the layout you want e.g. "Product page with hero image, 2-column features grid, and a reviews section"</T>
</DialogDescription>
</DialogHeader>
<div className="py-4">
<AIPageGenerator
prompt={prompt}
onPromptChange={setPrompt}
onGenerate={handleGenerate}
isGenerating={isGenerating}
generationStatus={isGenerating ? 'generating' : 'idle'}
onCancel={() => setIsGenerating(false)}
promptHistory={promptHistory}
historyIndex={historyIndex}
onNavigateHistory={navigateHistory}
disabled={isGenerating}
/>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,265 @@
import React, { useMemo, useState, useCallback } from 'react';
import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
} from '@/components/ui/command';
import { useSelection } from '@/modules/layout/SelectionContext';
import {
Undo2, Redo2, Copy, ClipboardPaste, Trash2,
Grid, Eye, EyeOff, ListTree, Database,
Save, Download, Upload, LayoutTemplate, FilePlus,
Mail, Send, GitMerge, FolderTree, Settings, Sparkles
} from 'lucide-react';
const RECENT_COMMANDS_KEY = 'editor-recent-commands';
const MAX_RECENT = 5;
function getRecentIds(): string[] {
try {
const stored = localStorage.getItem(RECENT_COMMANDS_KEY);
return stored ? JSON.parse(stored) : [];
} catch { return []; }
}
function pushRecentId(id: string) {
const recent = getRecentIds().filter(r => r !== id);
recent.unshift(id);
localStorage.setItem(RECENT_COMMANDS_KEY, JSON.stringify(recent.slice(0, MAX_RECENT)));
}
export interface CommandPickerCommand {
id: string;
label: string;
icon?: React.ComponentType<{ className?: string }>;
group: string;
shortcut?: string;
disabled?: boolean;
handler: () => void;
}
export interface CommandPickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
commands: CommandPickerCommand[];
}
export const CommandPicker: React.FC<CommandPickerProps> = ({
open,
onOpenChange,
commands,
}) => {
const [recentIds, setRecentIds] = useState<string[]>(getRecentIds);
// Build recent commands list from current commands
const recentCommands = useMemo(() => {
return recentIds
.map(id => commands.find(c => c.id === id))
.filter((c): c is CommandPickerCommand => !!c);
}, [recentIds, commands]);
// Group remaining commands
const groups = useMemo(() => {
const map = new Map<string, CommandPickerCommand[]>();
for (const cmd of commands) {
const list = map.get(cmd.group) || [];
list.push(cmd);
map.set(cmd.group, list);
}
return map;
}, [commands]);
const handleSelect = useCallback((commandId: string) => {
const cmd = commands.find(c => c.id === commandId);
if (cmd && !cmd.disabled) {
pushRecentId(cmd.id);
setRecentIds(getRecentIds());
onOpenChange(false);
requestAnimationFrame(() => cmd.handler());
}
}, [commands, onOpenChange]);
return (
<CommandDialog open={open} onOpenChange={onOpenChange}>
<CommandInput placeholder="Type a command..." />
<CommandList>
<CommandEmpty>No commands found.</CommandEmpty>
{/* Recent group always first */}
{recentCommands.length > 0 && (
<CommandGroup heading="Recent">
{recentCommands.map(cmd => (
<CommandItem
key={`recent:${cmd.id}`}
value={`recent:${cmd.id}`}
keywords={[cmd.label, cmd.group]}
onSelect={() => handleSelect(cmd.id)}
disabled={cmd.disabled}
className="gap-2"
>
{cmd.icon && <cmd.icon className="h-4 w-4 shrink-0 opacity-70" />}
<span>{cmd.label}</span>
{cmd.shortcut && (
<CommandShortcut>{cmd.shortcut}</CommandShortcut>
)}
</CommandItem>
))}
</CommandGroup>
)}
{Array.from(groups.entries()).map(([group, cmds]) => (
<CommandGroup key={group} heading={group}>
{cmds.map(cmd => (
<CommandItem
key={cmd.id}
value={cmd.id}
onSelect={handleSelect}
disabled={cmd.disabled}
className="gap-2"
>
{cmd.icon && <cmd.icon className="h-4 w-4 shrink-0 opacity-70" />}
<span>{cmd.label}</span>
{cmd.shortcut && (
<CommandShortcut>{cmd.shortcut}</CommandShortcut>
)}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
);
};
// ── Hook to build the static command list with context-aware enable/disable ──
import { widgetRegistry } from '@/lib/widgetRegistry';
import { useWidgetSnippets, WidgetSnippetData } from '@/modules/layout/useWidgetSnippets';
import { BookmarkCheck, Component } from 'lucide-react';
interface UseEditorCommandsParams {
onUndo: () => void;
onRedo: () => void;
canUndo: boolean;
canRedo: boolean;
onCopy: () => void;
onPaste: () => void;
onSave: () => void;
onAddContainer: () => void;
onAddWidget: (widgetId: string, initialProps?: Record<string, any>) => void;
onTogglePreview: () => void;
onToggleHierarchy: () => void;
onToggleTypeFields?: () => void;
hasTypeFields?: boolean;
onExportLayout: () => void;
onImportLayout: () => void;
onSaveAsNewTemplate?: () => void;
onNewLayout?: () => void;
onToggleEmailPreview?: () => void;
onSendEmail?: () => void;
onDeleteWidget?: () => void;
// Page management
pageVisible?: boolean;
pagePublic?: boolean;
onToggleVisibility?: () => void;
onTogglePublic?: () => void;
onSelectParent?: () => void;
onManageCategories?: () => void;
onAIAssistant?: () => void;
}
export function useEditorCommands(params: UseEditorCommandsParams): CommandPickerCommand[] {
const { selectedWidgetIds, selectedContainerId, hasClipboard } = useSelection();
const hasSelection = selectedWidgetIds.size > 0 || !!selectedContainerId;
const hasWidgetSelection = selectedWidgetIds.size > 0;
const { snippets } = useWidgetSnippets();
return useMemo(() => {
const commands: CommandPickerCommand[] = [
// ── Edit ──
{ id: 'edit:undo', label: 'Undo', icon: Undo2, group: 'Edit', shortcut: 'Ctrl+Z', disabled: !params.canUndo, handler: params.onUndo },
{ id: 'edit:redo', label: 'Redo', icon: Redo2, group: 'Edit', shortcut: 'Ctrl+Y', disabled: !params.canRedo, handler: params.onRedo },
{ id: 'edit:copy', label: 'Copy', icon: Copy, group: 'Edit', shortcut: 'Ctrl+C', disabled: !hasSelection, handler: params.onCopy },
{ id: 'edit:paste', label: 'Paste', icon: ClipboardPaste, group: 'Edit', shortcut: 'Ctrl+V', disabled: !hasClipboard, handler: params.onPaste },
// ── Insert ──
{ id: 'insert:container', label: 'Add Container', icon: Grid, group: 'Insert', handler: params.onAddContainer },
...(params.onDeleteWidget ? [{ id: 'insert:delete', label: 'Delete Widget', icon: Trash2, group: 'Insert', disabled: !hasWidgetSelection, handler: params.onDeleteWidget }] : []),
// ── View ──
{ id: 'view:preview', label: 'Toggle Preview', icon: Eye, group: 'View', shortcut: 'Ctrl+Shift+V', handler: params.onTogglePreview },
{ id: 'view:hierarchy', label: 'Toggle Hierarchy', icon: ListTree, group: 'View', handler: params.onToggleHierarchy },
...(params.onToggleTypeFields ? [{ id: 'view:fields', label: 'Toggle Fields', icon: Database, group: 'View', disabled: !params.hasTypeFields, handler: params.onToggleTypeFields }] : []),
// ── File ──
{ id: 'file:save', label: 'Save', icon: Save, group: 'File', shortcut: 'Ctrl+S', handler: params.onSave },
{ id: 'file:export', label: 'Export Layout', icon: Download, group: 'File', handler: params.onExportLayout },
{ id: 'file:import', label: 'Import Layout', icon: Upload, group: 'File', handler: params.onImportLayout },
// ── Layout ──
...(params.onNewLayout ? [{ id: 'layout:new', label: 'New Layout', icon: FilePlus, group: 'Layout', handler: params.onNewLayout }] : []),
...(params.onSaveAsNewTemplate ? [{ id: 'layout:save-as', label: 'Save as Template', icon: LayoutTemplate, group: 'Layout', handler: params.onSaveAsNewTemplate }] : []),
// ── Email ──
...(params.onToggleEmailPreview ? [{ id: 'email:preview', label: 'Email Preview', icon: Mail, group: 'Email', handler: params.onToggleEmailPreview }] : []),
...(params.onSendEmail ? [{ id: 'email:send', label: 'Send Email', icon: Send, group: 'Email', handler: params.onSendEmail }] : []),
// ── Page ──
...(params.onToggleVisibility ? [{ id: 'page:visibility', label: params.pageVisible ? 'Hide Page' : 'Show Page', icon: params.pageVisible ? EyeOff : Eye, group: 'Page', handler: params.onToggleVisibility }] : []),
...(params.onTogglePublic ? [{ id: 'page:public', label: params.pagePublic ? 'Make Private' : 'Make Public', icon: params.pagePublic ? Settings : GitMerge, group: 'Page', handler: params.onTogglePublic }] : []),
...(params.onSelectParent ? [{ id: 'page:parent', label: 'Select Parent', icon: FolderTree, group: 'Page', handler: params.onSelectParent }] : []),
...(params.onManageCategories ? [{ id: 'page:categories', label: 'Manage Categories', icon: FolderTree, group: 'Page', handler: params.onManageCategories }] : []),
// ── AI ──
...(params.onAIAssistant ? [{ id: 'ai:assistant', label: 'AI Layout Assistant', icon: Sparkles, group: 'AI', handler: params.onAIAssistant }] : []),
];
// ── Add Widgets (from registry) ──
const allWidgets = widgetRegistry.getAll()
.filter(w => w.metadata.category !== 'hidden')
.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name));
for (const widget of allWidgets) {
commands.push({
id: `add-widget:${widget.metadata.id}`,
label: `Add ${widget.metadata.name}`,
icon: widget.metadata.icon || Component,
group: 'Add Widget',
handler: () => params.onAddWidget(widget.metadata.id),
});
}
// ── Snippets ──
for (const snippet of snippets) {
const data = snippet.layout_json as unknown as WidgetSnippetData;
if (!data?.widgetId) continue;
const widgetDef = widgetRegistry.get(data.widgetId);
commands.push({
id: `snippet:${snippet.id}`,
label: snippet.name,
icon: widgetDef?.metadata.icon || BookmarkCheck,
group: 'Snippets',
handler: () => params.onAddWidget(data.widgetId, data.props),
});
}
return commands;
}, [
params.canUndo, params.canRedo, hasSelection, hasWidgetSelection, hasClipboard,
params.onUndo, params.onRedo, params.onCopy, params.onPaste,
params.onAddContainer, params.onAddWidget, params.onDeleteWidget,
params.onTogglePreview, params.onToggleHierarchy, params.onToggleTypeFields, params.hasTypeFields,
params.onSave, params.onExportLayout, params.onImportLayout,
params.onSaveAsNewTemplate, params.onNewLayout,
params.onToggleEmailPreview, params.onSendEmail,
params.pageVisible, params.pagePublic,
params.onToggleVisibility, params.onTogglePublic, params.onSelectParent, params.onManageCategories,
params.onAIAssistant, snippets,
]);
}

View File

@ -0,0 +1,120 @@
import { lazy, Suspense } from "react";
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { T } from "@/i18n";
import { Sidebar } from "@/components/sidebar/Sidebar";
import { TableOfContents } from "@/components/sidebar/TableOfContents";
import { MarkdownHeading } from "@/lib/toc";
import { Page } from "../types";
const HierarchyTree = lazy(() => import("@/components/sidebar/HierarchyTree").then(module => ({ default: module.HierarchyTree })));
interface EditorLeftSidebarProps {
isSidebarCollapsed: boolean;
onToggleSidebar: () => void;
childPages: { id: string; title: string; slug: string }[];
headings: MarkdownHeading[];
orgSlug?: string;
userId: string;
showHierarchy: boolean;
currentLayout: any;
selectedWidgetId: string | null;
selectedContainerId: string | null;
isPreview: boolean;
page: Page;
showEmailPreview: boolean;
onSelectWidget: (id: string | null) => void;
onSelectContainer: (id: string | null) => void;
onOpenSettings: (id: string, type: 'widget' | 'container', layoutId: string) => void;
setEditingWidgetId: (id: string | null) => void;
setSelectedPageId: (id: string) => void;
}
export const EditorLeftSidebar = ({
isSidebarCollapsed,
onToggleSidebar,
childPages,
headings,
orgSlug,
userId,
showHierarchy,
currentLayout,
selectedWidgetId,
selectedContainerId,
isPreview,
page,
showEmailPreview,
onSelectWidget,
onSelectContainer,
onOpenSettings,
setEditingWidgetId,
setSelectedPageId,
}: EditorLeftSidebarProps) => {
if (showEmailPreview) return null;
if (headings.length === 0 && childPages.length === 0 && !showHierarchy) return null;
return (
<Sidebar className={`${isSidebarCollapsed ? 'w-12' : 'w-[300px]'} border-r 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 z-10`}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
onClick={onToggleSidebar}
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">
{showHierarchy && currentLayout && (
<Suspense fallback={<div className="p-4 text-xs text-muted-foreground">Loading hierarchy...</div>}>
<HierarchyTree
containers={currentLayout.containers}
selectedWidgetId={selectedWidgetId}
selectedContainerId={selectedContainerId}
onSelectWidget={(id) => {
if (isPreview) return;
onSelectWidget(id);
setSelectedPageId(`page-${page.id}`);
}}
onSelectContainer={(id) => {
if (isPreview) return;
onSelectContainer(id);
}}
onSettingsClick={onOpenSettings}
layoutId={`page-${page.id}`}
/>
</Suspense>
)}
{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"
/>
)}
</div>
)}
</Sidebar>
);
};

View File

@ -0,0 +1,72 @@
import { lazy, Suspense } from "react";
import { ResizableHandle, ResizablePanel } from "@/components/ui/resizable";
import { Page } from "../types";
const WidgetPropertyPanel = lazy(() => import("@/components/widgets/WidgetPropertyPanel").then(module => ({ default: module.WidgetPropertyPanel })));
const ContainerPropertyPanel = lazy(() => import("@/components/containers/ContainerPropertyPanel").then(module => ({ default: module.ContainerPropertyPanel })));
const UserPageTypeFields = lazy(() => import("./UserPageTypeFields").then(module => ({ default: module.UserPageTypeFields })));
interface EditorRightPanelProps {
selectedWidgetId: string | null;
selectedContainerId: string | null;
showTypeFields: boolean;
page: Page;
selectedPageId: string | null;
assignedTypes: any[];
onPageUpdate: (updatedPage: Page) => void;
contextVariables?: Record<string, any>;
setSelectedWidgetId: (id: string | null) => void;
}
export const EditorRightPanel = ({
selectedWidgetId,
selectedContainerId,
showTypeFields,
page,
selectedPageId,
assignedTypes,
onPageUpdate,
contextVariables,
setSelectedWidgetId,
}: EditorRightPanelProps) => {
if (!selectedWidgetId && !selectedContainerId && !showTypeFields) return null;
return (
<>
<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 ? (
<Suspense fallback={<div className="h-full flex items-center justify-center text-muted-foreground">Loading settings...</div>}>
<WidgetPropertyPanel
pageId={selectedPageId || `page-${page.id}`}
selectedWidgetId={selectedWidgetId}
onWidgetRenamed={setSelectedWidgetId}
contextVariables={contextVariables}
/>
</Suspense>
) : selectedContainerId ? (
<Suspense fallback={<div className="h-full flex items-center justify-center text-muted-foreground">Loading settings...</div>}>
<ContainerPropertyPanel
pageId={selectedPageId || `page-${page.id}`}
selectedContainerId={selectedContainerId}
/>
</Suspense>
) : showTypeFields ? (
<div className="h-full overflow-y-auto p-4">
<Suspense fallback={<div className="h-full flex items-center justify-center text-muted-foreground">Loading types...</div>}>
<UserPageTypeFields
pageId={page.id}
pageMeta={page.meta}
assignedTypes={assignedTypes}
isEditMode={true}
onMetaUpdate={(newMeta) => onPageUpdate({ ...page, meta: newMeta })}
/>
</Suspense>
</div>
) : null}
</div>
</ResizablePanel>
</>
);
};

View File

@ -0,0 +1,81 @@
import { RefObject } from "react";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Monitor, Smartphone } from "lucide-react";
import { Page } from "../types";
interface EmailPreviewPanelProps {
page: Page;
orgSlug?: string;
previewMode: 'desktop' | 'mobile';
onSetPreviewMode: (mode: 'desktop' | 'mobile') => void;
iframeRef: RefObject<HTMLIFrameElement>;
authToken: string | null;
}
export const EmailPreviewPanel = ({
page,
orgSlug,
previewMode,
onSetPreviewMode,
iframeRef,
authToken,
}: EmailPreviewPanelProps) => {
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || 'http://localhost:3333';
const previewPath = orgSlug
? `/org/${orgSlug}/user/${page.owner}/pages/${page.slug}/email-preview`
: `/user/${page.owner}/pages/${page.slug}/email-preview`;
return (
<div className="flex-1 flex flex-col bg-gray-100 dark:bg-gray-900 border rounded-md shadow-sm overflow-hidden">
{/* Preview Toolbar */}
<div className="flex items-center justify-center gap-2 p-2 border-b bg-background">
<Button
variant="ghost"
size="sm"
onClick={() => {
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.history.back();
}
}}
title="Go Back"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<div className="h-4 w-px bg-border mx-2" />
<Button
variant={previewMode === 'desktop' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => onSetPreviewMode('desktop')}
title="Desktop View"
>
<Monitor className="h-4 w-4 mr-2" />
Desktop
</Button>
<Button
variant={previewMode === 'mobile' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => onSetPreviewMode('mobile')}
title="Mobile View"
>
<Smartphone className="h-4 w-4 mr-2" />
Mobile
</Button>
</div>
{/* Preview Container */}
<div className="flex-1 overflow-auto flex justify-center p-4 md:p-8">
<div
className={`transition-all duration-300 bg-white shadow-lg overflow-hidden ${previewMode === 'mobile' ? 'w-[375px] h-[667px] rounded-3xl border-8 border-gray-800' : 'w-full h-full rounded-md'}`}
>
<iframe
ref={iframeRef as any}
src={`${serverUrl}${previewPath}?token=${authToken || ''}`}
className="w-full h-full border-0"
title="Email Preview"
/>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,48 @@
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { T } from "@/i18n";
interface SendEmailDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
emailRecipient: string;
onEmailRecipientChange: (value: string) => void;
onSend: () => void;
isSending: boolean;
}
export const SendEmailDialog = ({
open,
onOpenChange,
emailRecipient,
onEmailRecipientChange,
onSend,
isSending,
}: SendEmailDialogProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle><T>Send Email Preview</T></DialogTitle>
</DialogHeader>
<div className="py-4">
<label className="block text-sm font-medium mb-2"><T>Recipient Email</T></label>
<input
type="email"
className="w-full p-2 border rounded-md bg-background"
placeholder="user@example.com"
value={emailRecipient}
onChange={(e) => onEmailRecipientChange(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onSend()}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}><T>Cancel</T></Button>
<Button onClick={onSend} disabled={isSending}>
{isSending ? <T>Sending...</T> : <T>Send</T>}
</Button>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -15,44 +15,7 @@ import {
import { Separator } from "@/components/ui/separator";
import { useLayout } from "@/modules/layout/LayoutContext";
import { UpdatePageMetaCommand } from "@/modules/layout/commands";
// Interfaces mostly matching UserPage.tsx
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?: {
id: string;
name: string;
slug: string;
}[];
}
interface UserProfile {
id: string;
username: string | null;
display_name: string | null;
avatar_url: string | null;
}
import { Page, UserProfile } from "../types";
import { Database } from '@/integrations/supabase/types';
import { updatePage, updatePageMeta } from '../client-pages';
@ -155,46 +118,6 @@ export const UserPageDetails: React.FC<UserPageDetailsProps> = ({
}
};
const handleSaveSlug = async () => {
if (!page || !slugValue.trim()) {
toast.error(translate('Slug cannot be empty'));
return;
}
const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
if (!slugRegex.test(slugValue)) {
setSlugError('Slug must be lowercase, alphanumeric, and use hyphens only');
return;
}
setSavingField('slug');
setSlugError(null);
const hasCollision = await checkSlugCollision(slugValue);
if (hasCollision) {
setSlugError('This slug is already used by another page');
setSavingField(null);
return;
}
try {
await updatePage(page.id, { slug: slugValue.trim() });
onPageUpdate({ ...page, slug: slugValue.trim() });
setEditingSlug(false);
toast.success(translate('Slug updated'));
const newPath = orgSlug
? `/org/${orgSlug}/user/${userId}/pages/${slugValue}`
: `/user/${userId}/pages/${slugValue}`;
navigate(newPath, { replace: true });
} catch (error) {
console.error('Error updating slug:', error);
toast.error(translate('Failed to update slug'));
} finally {
setSavingField(null);
}
};
const handleSaveTags = async () => {
if (!page) return;
@ -229,7 +152,7 @@ export const UserPageDetails: React.FC<UserPageDetailsProps> = ({
};
return (
<div className="">
<div className="dark:bg-slate-900/50 rounded-lg">
<div className="flex items-start gap-4">
<div className="flex-1">
{/* Parent Page Eyebrow */}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,116 @@
import { useCallback } from "react";
import { toast } from "sonner";
import { translate } from "@/i18n";
import { useLayout } from "@/modules/layout/LayoutContext";
import { useSelection } from "@/modules/layout/SelectionContext";
import { PasteWidgetsCommand, AddContainerCommand } from "@/modules/layout/commands";
import { WidgetInstance, LayoutContainer as LayoutContainerType } from "@/modules/layout/LayoutManager";
interface UseClipboardActionsParams {
pageId: string | undefined;
selectedPageId: string | null;
selectedContainerId: string | null;
}
export function useClipboardActions({
pageId,
selectedPageId,
selectedContainerId,
}: UseClipboardActionsParams) {
const { getLoadedPageLayout, executeCommand } = useLayout();
const { selectedWidgetIds, clipboard, copyToClipboard } = useSelection();
const handleCopy = useCallback(() => {
if (!pageId) return;
const effectivePageId = selectedPageId || pageId;
const layout = getLoadedPageLayout(effectivePageId);
if (!layout) return;
const findWidgets = (containers: LayoutContainerType[], ids: Set<string>): WidgetInstance[] => {
const result: WidgetInstance[] = [];
for (const c of containers) {
for (const w of c.widgets) {
if (ids.has(w.id)) result.push(w);
}
if (c.children) result.push(...findWidgets(c.children, ids));
}
return result;
};
const findContainer = (containers: LayoutContainerType[], id: string): LayoutContainerType | null => {
for (const c of containers) {
if (c.id === id) return c;
if (c.children) {
const found = findContainer(c.children, id);
if (found) return found;
}
}
return null;
};
if (selectedWidgetIds.size > 0) {
const widgets = findWidgets(layout.containers, selectedWidgetIds);
if (widgets.length === 0) {
toast.error(translate('Nothing selected to copy'));
return;
}
copyToClipboard({ widgets, containers: [] });
toast.success(translate(`Copied ${widgets.length} widget(s)`));
} else if (selectedContainerId) {
const container = findContainer(layout.containers, selectedContainerId);
if (!container) {
toast.error(translate('Container not found'));
return;
}
copyToClipboard({ widgets: [], containers: [container] });
toast.success(translate(`Copied container with ${container.widgets.length} widget(s)`));
} else {
toast.error(translate('Nothing selected to copy'));
}
}, [pageId, selectedPageId, selectedWidgetIds, selectedContainerId, getLoadedPageLayout, copyToClipboard]);
const handlePaste = useCallback(async () => {
if (!pageId || !clipboard) return;
const effectivePageId = selectedPageId || pageId;
const layout = getLoadedPageLayout(effectivePageId);
if (!layout) return;
try {
if (clipboard.containers.length > 0) {
for (const container of clipboard.containers) {
const suffix = crypto.randomUUID().slice(0, 6);
const cloneContainer = (c: LayoutContainerType): LayoutContainerType => ({
...JSON.parse(JSON.stringify(c)),
id: `${c.id.replace(/-copy-[a-f0-9]+$/, '')}-copy-${suffix}`,
widgets: c.widgets.map(w => ({
...JSON.parse(JSON.stringify(w)),
id: `${w.id.replace(/-copy-[a-f0-9]+$/, '')}-copy-${crypto.randomUUID().slice(0, 6)}`
})),
children: c.children ? c.children.map(cloneContainer) : []
});
const newContainer = cloneContainer(container);
const cmd = new AddContainerCommand(effectivePageId, newContainer);
await executeCommand(cmd);
}
toast.success(translate(`Pasted ${clipboard.containers.length} container(s)`));
} else if (clipboard.widgets.length > 0) {
let targetContainerId = selectedContainerId;
if (!targetContainerId && layout.containers.length > 0) {
targetContainerId = layout.containers[0].id;
}
if (!targetContainerId) {
toast.error(translate('No container to paste into'));
return;
}
const cmd = new PasteWidgetsCommand(effectivePageId, targetContainerId, clipboard.widgets);
await executeCommand(cmd);
toast.success(translate(`Pasted ${clipboard.widgets.length} widget(s)`));
}
} catch (e) {
console.error('Paste failed', e);
toast.error(translate('Failed to paste'));
}
}, [pageId, selectedPageId, clipboard, selectedContainerId, getLoadedPageLayout, executeCommand]);
return { handleCopy, handlePaste };
}

View File

@ -0,0 +1,92 @@
import { useEffect } from "react";
interface UseEditorKeyboardShortcutsParams {
onSave: () => void;
onUndo: () => void;
onRedo: () => void;
canUndo: boolean;
canRedo: boolean;
onCopy: () => void;
onPaste: () => void;
onTogglePreview: () => void;
selectedWidgetIds: Set<string>;
onCommandPicker?: () => void;
}
export function useEditorKeyboardShortcuts({
onSave,
onUndo,
onRedo,
canUndo,
canRedo,
onCopy,
onPaste,
onTogglePreview,
selectedWidgetIds,
onCommandPicker,
}: UseEditorKeyboardShortcutsParams) {
// Undo/Redo shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
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) onRedo();
} else {
if (canUndo) onUndo();
}
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') {
e.preventDefault();
if (canRedo) onRedo();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onUndo, onRedo, canUndo, canRedo]);
// Save, Preview, Copy, Paste shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isInput = ['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName);
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
onSave();
}
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'v') {
if (!isInput) {
e.preventDefault();
onTogglePreview();
}
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
if (!isInput) {
e.preventDefault();
onPaste();
}
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
if (!isInput && selectedWidgetIds.size > 0) {
e.preventDefault();
onCopy();
}
}
// Ctrl+Space → Command Picker
if ((e.ctrlKey || e.metaKey) && e.key === ' ') {
e.preventDefault();
onCommandPicker?.();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onSave, onCopy, onPaste, onTogglePreview, selectedWidgetIds, onCommandPicker]);
}

View File

@ -0,0 +1,86 @@
import { useState, useEffect, useRef } from "react";
import { toast } from "sonner";
import { translate } from "@/i18n";
interface UseEmailActionsParams {
page: { owner: string; slug: string };
orgSlug?: string;
}
export function useEmailActions({ page, orgSlug }: UseEmailActionsParams) {
const [showEmailPreview, setShowEmailPreview] = useState(false);
const [showSendEmailDialog, setShowSendEmailDialog] = useState(false);
const [emailRecipient, setEmailRecipient] = useState('cgoflyn@gmail.com');
const [isSendingEmail, setIsSendingEmail] = useState(false);
const [authToken, setAuthToken] = useState<string | null>(null);
const [previewMode, setPreviewMode] = useState<'desktop' | 'mobile'>('desktop');
const iframeRef = useRef<HTMLIFrameElement>(null);
useEffect(() => {
import("@/integrations/supabase/client").then(({ supabase }) => {
supabase.auth.getSession().then(({ data: { session } }) => {
setAuthToken(session?.access_token || null);
});
});
}, []);
const handleSendEmail = async () => {
if (!emailRecipient) {
toast.error(translate("Email is required"));
return;
}
setIsSendingEmail(true);
try {
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || 'http://localhost:3333';
const endpoint = orgSlug
? `${serverUrl}/org/${orgSlug}/user/${page.owner}/pages/${page.slug}/email-send`
: `${serverUrl}/user/${page.owner}/pages/${page.slug}/email-send`;
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: emailRecipient })
});
if (!res.ok) {
const text = await res.text();
let errorMessage = text;
try {
const json = JSON.parse(text);
if (json && json.error) errorMessage = json.error;
} catch (e) { /* Not JSON */ }
throw new Error(errorMessage || 'Failed to send email');
}
const data = await res.json();
if (data.success) {
toast.success(translate("Email sent successfully!"));
setShowSendEmailDialog(false);
setEmailRecipient('cgoflyn@gmail.com');
} else {
throw new Error(data.error || 'Failed to send email');
}
} catch (err: any) {
console.error("Email send failed", err);
toast.error(translate("Failed to send email: ") + err.message);
} finally {
setIsSendingEmail(false);
}
};
return {
showEmailPreview,
setShowEmailPreview,
showSendEmailDialog,
setShowSendEmailDialog,
emailRecipient,
setEmailRecipient,
isSendingEmail,
authToken,
previewMode,
setPreviewMode,
iframeRef,
handleSendEmail,
};
}

View File

@ -0,0 +1,60 @@
import { useCallback } from "react";
import { toast } from "sonner";
import { useLayout } from "@/modules/layout/LayoutContext";
interface UseLayoutIOParams {
pageId: string | undefined;
pageTitle: string;
onClearSelection: () => void;
}
export function useLayoutIO({ pageId, pageTitle, onClearSelection }: UseLayoutIOParams) {
const { exportPageLayout, importPageLayout } = useLayout();
const handleExportLayout = useCallback(async () => {
if (!pageId) return;
try {
const jsonData = await exportPageLayout(pageId);
const blob = new Blob([jsonData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${pageTitle.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");
}
}, [pageId, pageTitle, exportPageLayout]);
const handleImportLayout = useCallback(() => {
if (!pageId) 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(pageId, jsonData);
onClearSelection();
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();
}, [pageId, importPageLayout, onClearSelection]);
return { handleExportLayout, handleImportLayout };
}

View File

@ -0,0 +1,172 @@
import { useState, useEffect, useCallback } from "react";
import { toast } from "sonner";
import { translate } from "@/i18n";
import { useLayout } from "@/modules/layout/LayoutContext";
import { useLayouts } from "@/modules/layout/useLayouts";
import { createLayout, updateLayout, deleteLayout } from "@/modules/layout/client-layouts";
import { Database } from "@/integrations/supabase/types";
type Layout = Database['public']['Tables']['layouts']['Row'];
interface UseTemplateManagerParams {
pageId: string | undefined;
isOwner: boolean;
getLoadedPageLayout: ReturnType<typeof useLayout>['getLoadedPageLayout'];
importPageLayout: ReturnType<typeof useLayout>['importPageLayout'];
setConfirmation: (state: {
open: boolean;
title: string;
description: string;
onConfirm: () => void;
confirmLabel?: string;
variant?: "default" | "destructive";
}) => void;
}
export function useTemplateManager({
pageId,
isOwner,
getLoadedPageLayout,
importPageLayout,
setConfirmation,
}: UseTemplateManagerParams) {
const [templates, setTemplates] = useState<Layout[]>([]);
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(null);
const [showSaveTemplateDialog, setShowSaveTemplateDialog] = useState(false);
const { getLayouts } = useLayouts();
useEffect(() => {
if (isOwner) {
loadTemplates();
}
}, [isOwner]);
const loadTemplates = async () => {
const { data } = await getLayouts({ type: 'canvas' });
if (data) {
setTemplates(data);
}
};
const handleLoadTemplate = useCallback(async (template: Layout) => {
if (!pageId) return;
try {
const layoutJsonString = JSON.stringify(template.layout_json);
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");
}
}, [pageId, importPageLayout]);
const handleSaveToTemplate = useCallback(async () => {
if (!activeTemplateId || !pageId) return;
try {
const layout = getLoadedPageLayout(pageId);
if (!layout) return;
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");
}
}, [activeTemplateId, pageId, getLoadedPageLayout]);
const handleSaveAsNewTemplate = useCallback(() => {
setShowSaveTemplateDialog(true);
}, []);
const onSaveTemplate = useCallback(async (name: string) => {
if (!pageId || !name) return;
try {
const layout = getLoadedPageLayout(pageId);
if (!layout) return;
const newLayout = await createLayout({
name,
layout_json: layout,
type: 'canvas',
visibility: 'private'
});
toast.success("Template created successfully");
setActiveTemplateId(newLayout.id);
loadTemplates();
} catch (e) {
console.error("Failed to create template", e);
toast.error("Failed to create template");
}
}, [pageId, getLoadedPageLayout]);
const handleDeleteTemplate = useCallback((template: Layout) => {
setConfirmation({
open: true,
title: translate("Delete Template"),
description: translate("Are you sure you want to delete the template") + ` "${template.name}"?`,
confirmLabel: translate("Delete"),
variant: "destructive",
onConfirm: async () => {
try {
await deleteLayout(template.id);
toast.success(translate("Template deleted"));
if (activeTemplateId === template.id) {
setActiveTemplateId(null);
}
loadTemplates();
} catch (e) {
console.error("Failed to delete template", e);
toast.error(translate("Failed to delete template"));
}
setConfirmation(prev => ({ ...prev, open: false }) as any);
}
});
}, [activeTemplateId, setConfirmation]);
const handleNewLayout = useCallback(() => {
if (!pageId) return;
setConfirmation({
open: true,
title: translate("Clear Layout"),
description: translate("Are you sure you want to clear the layout?"),
confirmLabel: translate("Clear"),
variant: "destructive",
onConfirm: async () => {
try {
const emptyLayout = {
id: pageId,
containers: [],
name: 'Empty Layout',
createdAt: Date.now(),
updatedAt: Date.now()
};
const jsonString = JSON.stringify(emptyLayout);
await importPageLayout(pageId, jsonString);
setActiveTemplateId(null);
toast.success("Layout cleared");
} catch (e) {
console.error("Failed to clear layout", e);
toast.error("Failed to clear layout");
}
setConfirmation(prev => ({ ...prev, open: false }) as any);
}
});
}, [pageId, importPageLayout, setConfirmation]);
return {
templates,
activeTemplateId,
showSaveTemplateDialog,
setShowSaveTemplateDialog,
handleLoadTemplate,
handleSaveToTemplate,
handleSaveAsNewTemplate,
onSaveTemplate,
handleDeleteTemplate,
handleNewLayout,
};
}

View File

@ -32,7 +32,9 @@ import {
Mail,
Send,
MoreHorizontal,
Plus
Plus,
BookmarkCheck,
Sparkles
} from "lucide-react";
import {
AlertDialog,
@ -56,6 +58,7 @@ import { Database as DatabaseType } from '@/integrations/supabase/types';
import { CategoryManager } from "@/components/widgets/CategoryManager";
import { VariablesEditor } from "@/components/variables/VariablesEditor";
import { widgetRegistry } from "@/lib/widgetRegistry";
import { useWidgetSnippets, WidgetSnippetData } from '@/modules/layout/useWidgetSnippets';
import { PagePickerDialog } from "../../PagePickerDialog";
import { PageCreationWizard } from "@/components/widgets/PageCreationWizard";
import { useLayout } from "@/modules/layout/LayoutContext";
@ -69,31 +72,35 @@ const { UpdatePageParentCommand, UpdatePageMetaCommand } = PageCommands;
type Layout = DatabaseType['public']['Tables']['layouts']['Row'];
interface Page {
id: string;
title: string;
content: any;
visible: boolean;
is_public: boolean;
owner: string;
slug: string;
parent: string | null;
parent_page?: {
title: string;
slug: string;
} | null;
meta?: any;
categories?: {
id: string;
name: string;
slug: string;
}[];
category_paths?: {
id: string;
name: string;
slug: string;
}[][];
}
import { Page } from "../../types";
// Snippets sub-component (uses hook, must be its own component)
const SnippetsRibbonGroup: React.FC<{
onToggleWidget?: (widgetId: string, initialProps?: Record<string, any>) => void;
}> = ({ onToggleWidget }) => {
const { snippets, loading } = useWidgetSnippets();
if (loading || snippets.length === 0) return null;
return (
<RibbonGroup label="Snippets">
<CompactFlowGroup
maxColumns={4}
actions={snippets.map(snippet => {
const data = snippet.layout_json as unknown as WidgetSnippetData;
const widgetDef = data?.widgetId ? widgetRegistry.get(data.widgetId) : null;
return {
id: snippet.id,
icon: widgetDef?.metadata.icon || BookmarkCheck,
label: snippet.name,
onClick: () => onToggleWidget?.(data.widgetId, data.props),
iconColor: "text-amber-600 dark:text-amber-400"
};
})}
/>
</RibbonGroup>
);
};
interface PageRibbonBarProps {
page: Page;
@ -104,7 +111,7 @@ interface PageRibbonBarProps {
onMetaUpdated?: () => void;
templates?: Layout[];
onLoadTemplate?: (template: Layout) => void;
onToggleWidget?: (widgetId: string) => void;
onToggleWidget?: (widgetId: string, initialProps?: Record<string, any>) => void;
onAddContainer?: () => void;
className?: string;
onUndo?: () => void;
@ -136,6 +143,7 @@ interface PageRibbonBarProps {
onCopy?: () => void;
onPaste?: () => void;
hasClipboard?: boolean;
onAIAssistant?: () => void;
}
// Ribbon UI Components
@ -339,7 +347,8 @@ export const PageRibbonBar = ({
onDeleteTemplate,
onCopy,
onPaste,
hasClipboard = false
hasClipboard = false,
onAIAssistant
}: PageRibbonBarProps) => {
const { executeCommand, loadPageLayout, clearHistory } = useLayout();
const navigate = useNavigate();
@ -623,6 +632,12 @@ export const PageRibbonBar = ({
active={showHierarchy}
iconColor="text-indigo-500"
/>
<RibbonItemSmall
icon={Sparkles}
label="AI Layout"
onClick={onAIAssistant}
iconColor="text-amber-500 dark:text-amber-400"
/>
</div>
</RibbonGroup>
<RibbonGroup label="History">
@ -873,6 +888,8 @@ export const PageRibbonBar = ({
</RibbonGroup>
)}
<SnippetsRibbonGroup onToggleWidget={onToggleWidget} />
{advancedWidgets.length > 0 && (
<RibbonGroup label="Advanced">
<CompactFlowGroup

View File

@ -0,0 +1,37 @@
export 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;
}[];
}
export interface UserProfile {
id: string;
username: string | null;
display_name: string | null;
avatar_url: string | null;
}