diff --git a/packages/ui/src/components/ui/button.tsx b/packages/ui/src/components/ui/button.tsx index cdedd4f4..151fe690 100644 --- a/packages/ui/src/components/ui/button.tsx +++ b/packages/ui/src/components/ui/button.tsx @@ -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, - VariantProps { + VariantProps { asChild?: boolean; } diff --git a/packages/ui/src/components/widgets/WidgetPropertiesForm.tsx b/packages/ui/src/components/widgets/WidgetPropertiesForm.tsx index c23e1c2a..265a3974 100644 --- a/packages/ui/src/components/widgets/WidgetPropertiesForm.tsx +++ b/packages/ui/src/components/widgets/WidgetPropertiesForm.tsx @@ -265,15 +265,15 @@ export const WidgetPropertiesForm: React.FC = ({ @@ -281,7 +281,7 @@ export const WidgetPropertiesForm: React.FC = ({ 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 && ( diff --git a/packages/ui/src/components/widgets/WidgetPropertyPanel.tsx b/packages/ui/src/components/widgets/WidgetPropertyPanel.tsx index 31cd14d7..66613f08 100644 --- a/packages/ui/src/components/widgets/WidgetPropertyPanel.tsx +++ b/packages/ui/src/components/widgets/WidgetPropertyPanel.tsx @@ -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 = ({ 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 = ({ } const handleSettingsChange = (newSettings: Record) => { - // Live update updateWidgetProps(pageId, widget.id, newSettings).catch(console.error); }; @@ -69,18 +77,78 @@ export const WidgetPropertyPanel: React.FC = ({ } }; + 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 ( -
-
-

- {widgetDefinition.metadata.name} -

-

- Properties -

+
+
+
+
+

+ {widgetDefinition.metadata.name} +

+

+ Properties +

+
+ {!showSaveInput && ( + + )} +
+ {showSaveInput && ( +
+ 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(''); + } + }} + /> + +
+ )}
-
+
{widgetDefinition.metadata.configSchema ? ( 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 RunnableToolFunctionWithParse[]> = { 'generate-only': (apiKey) => [ @@ -69,6 +71,9 @@ const PRESET_TOOLS: Record 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.`, } }); diff --git a/packages/ui/src/lib/registerWidgets.ts b/packages/ui/src/lib/registerWidgets.ts index 1d9f934a..0fc0d5c5 100644 --- a/packages/ui/src/lib/registerWidgets.ts +++ b/packages/ui/src/lib/registerWidgets.ts @@ -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({ + 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'] + } + }); + } diff --git a/packages/ui/src/lib/tools-layout.ts b/packages/ui/src/lib/tools-layout.ts new file mode 100644 index 00000000..1682db19 --- /dev/null +++ b/packages/ui/src/lib/tools-layout.ts @@ -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 = z.lazy(() => + z.object({ + columns: z.number().min(1).max(4).default(1).describe('CSS grid columns (1–4)'), + 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; + +// ── 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 }; + } + }, + }); diff --git a/packages/ui/src/modules/layout/SelectionContext.tsx b/packages/ui/src/modules/layout/SelectionContext.tsx index aed1ffbf..ec9462d4 100644 --- a/packages/ui/src/modules/layout/SelectionContext.tsx +++ b/packages/ui/src/modules/layout/SelectionContext.tsx @@ -27,7 +27,6 @@ export const SelectionProvider: React.FC<{ children: ReactNode }> = ({ children const [clipboard, setClipboard] = useState(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 }, []); diff --git a/packages/ui/src/modules/layout/WidgetPalette.tsx b/packages/ui/src/modules/layout/WidgetPalette.tsx index 93c9884a..0b628557 100644 --- a/packages/ui/src/modules/layout/WidgetPalette.tsx +++ b/packages/ui/src/modules/layout/WidgetPalette.tsx @@ -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) => void; } export const WidgetPalette: React.FC = ({ @@ -20,6 +24,12 @@ export const WidgetPalette: React.FC = ({ }) => { const [searchQuery, setSearchQuery] = useState(''); const [selectedCategory, setSelectedCategory] = useState('all'); + const [activeTab, setActiveTab] = useState<'registry' | 'saved'>('registry'); + + // Snippet management + const { snippets, loading: snippetsLoading, cloneSnippet, removeSnippet, updateSnippetName } = useWidgetSnippets(); + const [editingSnippetId, setEditingSnippetId] = useState(null); + const [editingName, setEditingName] = useState(''); // Handle Escape key useEffect(() => { @@ -31,12 +41,9 @@ export const WidgetPalette: React.FC = ({ 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 = ({ 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 = ({ ? 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 = (
= ({ - + + {/* Tabs: Registry / Saved */} +
+ + +
+ {/* Search */}
setSearchQuery(e.target.value)} className="pl-8 glass-input" />
- {/* Categories */} -
- {categories.map(category => ( - - ))} -
+ {activeTab === 'registry' && ( + <> + {/* Categories */} +
+ {dynamicCategories.map(category => ( + + ))} +
- {/* Widget List */} -
- {widgets.map(widget => ( -
{ - onWidgetAdd(widget.metadata.id); - onClose(); - }} - > -
- {widget.metadata.icon && ( -
- {React.createElement(widget.metadata.icon, {})} -
- )} -
-
{widget.metadata.name}
-
- {widget.metadata.description} + {/* Widget List */} +
+ {widgets.map(widget => ( +
{ + onWidgetAdd(widget.metadata.id); + onClose(); + }} + > +
+ {widget.metadata.icon && ( +
+ {React.createElement(widget.metadata.icon, {})} +
+ )} +
+
{widget.metadata.name}
+
+ {widget.metadata.description} +
+
+ +
+ ))} + + {widgets.length === 0 && ( +
+ No widgets found +
+ )} +
+ + )} + + {activeTab === 'saved' && ( +
+ {snippetsLoading ? ( +
+ + Loading...
+ ) : filteredSnippets.length === 0 ? ( +
+
No saved widgets yet
+
Select a widget in the editor and use "Save as Widget" to create one.
+
+ ) : ( + filteredSnippets.map(snippet => { + const snippetData = snippet.layout_json as unknown as WidgetSnippetData; + const widgetDef = snippetData?.widgetId ? widgetRegistry.get(snippetData.widgetId) : null; - -
- ))} + return ( +
handleSnippetClick(snippet)} + > +
+ {widgetDef?.metadata.icon && ( +
+ {React.createElement(widgetDef.metadata.icon, {})} +
+ )} +
+ {editingSnippetId === snippet.id ? ( +
e.stopPropagation()}> + 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(''); + } + }} + /> + +
+ ) : ( + <> +
{snippet.name}
+
+ {widgetDef?.metadata.name || snippetData?.widgetId || 'Unknown widget'} +
+ + )} +
+
- {widgets.length === 0 && ( -
- No widgets found -
- )} -
+ {/* Action buttons */} +
+ + + +
+
+ ); + }) + )} +
+ )}
); - // Render in portal to ensure it's at the top level return createPortal(modalContent, document.body); }; diff --git a/packages/ui/src/modules/layout/commands.ts b/packages/ui/src/modules/layout/commands.ts index 650e23a2..5e3108c8 100644 --- a/packages/ui/src/modules/layout/commands.ts +++ b/packages/ui/src/modules/layout/commands.ts @@ -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 { + 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 { + 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; diff --git a/packages/ui/src/modules/layout/useWidgetSnippets.ts b/packages/ui/src/modules/layout/useWidgetSnippets.ts new file mode 100644 index 00000000..4329973a --- /dev/null +++ b/packages/ui/src/modules/layout/useWidgetSnippets.ts @@ -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; +} + +export function useWidgetSnippets() { + const [snippets, setSnippets] = useState([]); + 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, + }; +} diff --git a/packages/ui/src/modules/pages/FileBrowserWidget.tsx b/packages/ui/src/modules/pages/FileBrowserWidget.tsx new file mode 100644 index 00000000..08ccf139 --- /dev/null +++ b/packages/ui/src/modules/pages/FileBrowserWidget.tsx @@ -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; 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 ; +} + +// ── 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 {node.name}; + } + if (cat === 'video') { + return ( +
+
+ ); + } + return ; +} + +// ── 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 = (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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [viewMode, setViewMode] = useState<'list' | 'thumbs'>(initialViewMode); + const [sortBy, setSortBy] = useState(initialSort); + const [sortAsc, setSortAsc] = useState(true); + const [focusIdx, setFocusIdx] = useState(-1); + const [selected, setSelected] = useState(null); + const listRef = useRef(null); + const containerRef = useRef(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 = { name: , ext: , date: , type: }; + + // ── 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(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 ( +
+ + + {/* ═══ Toolbar ═══════════════════════════════════ */} + {showToolbar && ( +
+ {/* Navigation */} + + + {/* Breadcrumbs */} +
+ {breadcrumbs.map((c, i) => ( + + {i > 0 && } + + + ))} +
+ +
+ + {/* File actions — shown when a file is selected */} + {selectedFile && (<> + + +
+ )} + + {/* Sort */} + + + {/* Zoom */} + + + + {/* View mode */} + +
+ )} + + {/* ═══ Content ═══════════════════════════════════ */} + {loading ? ( +
+ + Loading… +
+ ) : error ? ( +
+ {error} +
+ ) : itemCount === 0 ? ( +
+ Empty directory +
+ ) : ( +
+ {/* ── List view ──────────────────────── */} + {viewMode === 'list' ? ( +
+ {canGoUp && ( +
{ 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', + }}> + + .. +
+ )} + {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 ( +
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', + }}> + + {node.name} + {!isDir && ( + + {formatSize(node.size)} + + )} + {mode === 'advanced' && node.mtime && ( + + {formatDate(node.mtime)} + + )} +
+ ); + })} +
+ ) : ( + /* ── Thumb view ─────────────────────── */ +
+ {canGoUp && ( +
{ 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', + }}> + + .. +
+ )} + {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 ( +
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 ? : } + + {node.name} + +
+ ); + })} +
+ )} + + {/* ── Detail panel (advanced, desktop only) ── */} + {mode === 'advanced' && selectedFile && ( +
+
+ + Details +
+ + {getMimeCategory(selectedFile) === 'image' && ( + {selectedFile.name} + )} + {getMimeCategory(selectedFile) === 'video' && ( +
+ )} +
+ )} + + {/* ═══ Status bar ════════════════════════════════ */} +
+ {sorted.length} item{sorted.length !== 1 ? 's' : ''}{selectedFile ? ` · ${selectedFile.name}` : ''} + {mount}:{currentPath || '/'} +
+ + {/* ═══ Lightbox ═════════════════════════════════ */} + {lightboxNode && ( +
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 */} + + + {/* Prev */} + {lightboxIdx > 0 && ( + + )} + + {/* Media */} + {lightboxIsVideo ? ( +
+ )} +
+ ); +}; + +export default FileBrowserWidget; diff --git a/packages/ui/src/modules/pages/PageActions.tsx b/packages/ui/src/modules/pages/PageActions.tsx index 67b2fc91..9b1dc439 100644 --- a/packages/ui/src/modules/pages/PageActions.tsx +++ b/packages/ui/src/modules/pages/PageActions.tsx @@ -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; diff --git a/packages/ui/src/modules/pages/PageCard.tsx b/packages/ui/src/modules/pages/PageCard.tsx index 278a9835..6c75f45f 100644 --- a/packages/ui/src/modules/pages/PageCard.tsx +++ b/packages/ui/src/modules/pages/PageCard.tsx @@ -133,9 +133,9 @@ const PageCard: React.FC = ({
{showContent && ( -
+
-

{title}

+

{title}

{description && ( diff --git a/packages/ui/src/modules/pages/PageCardWidget.tsx b/packages/ui/src/modules/pages/PageCardWidget.tsx index 0bd2b2c3..69aa3146 100644 --- a/packages/ui/src/modules/pages/PageCardWidget.tsx +++ b/packages/ui/src/modules/pages/PageCardWidget.tsx @@ -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 = ({ onClick={() => setShowPagePicker(true)} size="sm" variant="secondary" + className="rounded-full" > - Change Page +
= ({ isOpen, onClose, diff --git a/packages/ui/src/modules/pages/UserPage.tsx b/packages/ui/src/modules/pages/UserPage.tsx index 80802c7d..05134827 100644 --- a/packages/ui/src/modules/pages/UserPage.tsx +++ b/packages/ui/src/modules/pages/UserPage.tsx @@ -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 */} -
+
{/* Mobile TOC */} @@ -357,7 +322,7 @@ const UserPageContent = ({ userId: propUserId, slug: propSlug, embedded = false, /> {/* Content Body */} -
+
{page.content && typeof page.content === 'string' ? (
diff --git a/packages/ui/src/modules/pages/editor/AILayoutWizard.tsx b/packages/ui/src/modules/pages/editor/AILayoutWizard.tsx new file mode 100644 index 00000000..6748ed4b --- /dev/null +++ b/packages/ui/src/modules/pages/editor/AILayoutWizard.tsx @@ -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; + /** Minimal page context forwarded to the LLM for awareness */ + pageContext?: { title?: string; slug?: string; meta?: Record }; +} + +/** + * 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, + 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 = ({ + 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 ( + !open && onClose()}> + + + AI Layout Assistant + + Describe the layout you want — e.g. "Product page with hero image, 2-column features grid, and a reviews section" + + +
+ setIsGenerating(false)} + promptHistory={promptHistory} + historyIndex={historyIndex} + onNavigateHistory={navigateHistory} + disabled={isGenerating} + /> +
+
+
+ ); +}; diff --git a/packages/ui/src/modules/pages/editor/CommandPicker.tsx b/packages/ui/src/modules/pages/editor/CommandPicker.tsx new file mode 100644 index 00000000..c0e82687 --- /dev/null +++ b/packages/ui/src/modules/pages/editor/CommandPicker.tsx @@ -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 = ({ + open, + onOpenChange, + commands, +}) => { + const [recentIds, setRecentIds] = useState(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(); + 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 ( + + + + No commands found. + + {/* Recent group always first */} + {recentCommands.length > 0 && ( + + {recentCommands.map(cmd => ( + handleSelect(cmd.id)} + disabled={cmd.disabled} + className="gap-2" + > + {cmd.icon && } + {cmd.label} + {cmd.shortcut && ( + {cmd.shortcut} + )} + + ))} + + )} + + {Array.from(groups.entries()).map(([group, cmds]) => ( + + {cmds.map(cmd => ( + + {cmd.icon && } + {cmd.label} + {cmd.shortcut && ( + {cmd.shortcut} + )} + + ))} + + ))} + + + ); +}; + +// ── 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) => 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, + ]); +} + diff --git a/packages/ui/src/modules/pages/editor/EditorLeftSidebar.tsx b/packages/ui/src/modules/pages/editor/EditorLeftSidebar.tsx new file mode 100644 index 00000000..0b914138 --- /dev/null +++ b/packages/ui/src/modules/pages/editor/EditorLeftSidebar.tsx @@ -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 ( +
+ )} + + ); +}; diff --git a/packages/ui/src/modules/pages/editor/EditorRightPanel.tsx b/packages/ui/src/modules/pages/editor/EditorRightPanel.tsx new file mode 100644 index 00000000..0694a35b --- /dev/null +++ b/packages/ui/src/modules/pages/editor/EditorRightPanel.tsx @@ -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; + 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 ( + <> + + +
+ {selectedWidgetId ? ( + Loading settings...
}> + + + ) : selectedContainerId ? ( + Loading settings...
}> + + + ) : showTypeFields ? ( +
+ Loading types...
}> + onPageUpdate({ ...page, meta: newMeta })} + /> + +
+ ) : null} +
+ + + ); +}; diff --git a/packages/ui/src/modules/pages/editor/EmailPreviewPanel.tsx b/packages/ui/src/modules/pages/editor/EmailPreviewPanel.tsx new file mode 100644 index 00000000..797cea11 --- /dev/null +++ b/packages/ui/src/modules/pages/editor/EmailPreviewPanel.tsx @@ -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; + 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 ( +
+ {/* Preview Toolbar */} +
+ +
+ + +
+ + {/* Preview Container */} +
+
+