vfs | filebrowser | layout aid
This commit is contained in:
parent
320a9db409
commit
0d30aa1c87
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.`,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -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']
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
188
packages/ui/src/lib/tools-layout.ts
Normal file
188
packages/ui/src/lib/tools-layout.ts
Normal 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 (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<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 };
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -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
|
||||
}, []);
|
||||
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
128
packages/ui/src/modules/layout/useWidgetSnippets.ts
Normal file
128
packages/ui/src/modules/layout/useWidgetSnippets.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
737
packages/ui/src/modules/pages/FileBrowserWidget.tsx
Normal file
737
packages/ui/src/modules/pages/FileBrowserWidget.tsx
Normal 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;
|
||||
@ -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;
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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} />
|
||||
|
||||
146
packages/ui/src/modules/pages/editor/AILayoutWizard.tsx
Normal file
146
packages/ui/src/modules/pages/editor/AILayoutWizard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
265
packages/ui/src/modules/pages/editor/CommandPicker.tsx
Normal file
265
packages/ui/src/modules/pages/editor/CommandPicker.tsx
Normal 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,
|
||||
]);
|
||||
}
|
||||
|
||||
120
packages/ui/src/modules/pages/editor/EditorLeftSidebar.tsx
Normal file
120
packages/ui/src/modules/pages/editor/EditorLeftSidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
72
packages/ui/src/modules/pages/editor/EditorRightPanel.tsx
Normal file
72
packages/ui/src/modules/pages/editor/EditorRightPanel.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
81
packages/ui/src/modules/pages/editor/EmailPreviewPanel.tsx
Normal file
81
packages/ui/src/modules/pages/editor/EmailPreviewPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
48
packages/ui/src/modules/pages/editor/SendEmailDialog.tsx
Normal file
48
packages/ui/src/modules/pages/editor/SendEmailDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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
@ -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 };
|
||||
}
|
||||
@ -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]);
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
60
packages/ui/src/modules/pages/editor/hooks/useLayoutIO.ts
Normal file
60
packages/ui/src/modules/pages/editor/hooks/useLayoutIO.ts
Normal 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 };
|
||||
}
|
||||
172
packages/ui/src/modules/pages/editor/hooks/useTemplateManager.ts
Normal file
172
packages/ui/src/modules/pages/editor/hooks/useTemplateManager.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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
|
||||
|
||||
37
packages/ui/src/modules/pages/types.ts
Normal file
37
packages/ui/src/modules/pages/types.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user