This commit is contained in:
lovebird 2026-04-09 23:40:49 +02:00
parent 49e75ffd30
commit f60e171051
26 changed files with 143 additions and 918 deletions

View File

@ -0,0 +1,96 @@
export interface UserIdentity {
id: string
user_id: string
identity_data?: {
[key: string]: any
}
identity_id: string
provider: string
created_at?: string
last_sign_in_at?: string
updated_at?: string
}
const FactorTypes = ['totp', 'phone', 'webauthn'] as const
/**
* Type of factor. `totp` and `phone` supported with this version
*/
export type FactorType = (typeof FactorTypes)[number]
const FactorVerificationStatuses = ['verified', 'unverified'] as const
/**
* The verification status of the factor, default is `unverified` after `.enroll()`, then `verified` after the user verifies it with `.verify()`
*/
type FactorVerificationStatus = (typeof FactorVerificationStatuses)[number]
export type Factor<
Type extends FactorType = FactorType,
Status extends FactorVerificationStatus = (typeof FactorVerificationStatuses)[number],
> = {
/** ID of the factor. */
id: string
/** Friendly name of the factor, useful to disambiguate between multiple factors. */
friendly_name?: string
/**
* Type of factor. `totp` and `phone` supported with this version
*/
factor_type: Type
/**
* The verification status of the factor, default is `unverified` after `.enroll()`, then `verified` after the user verifies it with `.verify()`
*/
status: Status
created_at: string
updated_at: string
}
export interface UserAppMetadata {
/**
* The first provider that the user used to sign up with.
*/
provider?: string
/**
* A list of all providers that the user has linked to their account.
*/
providers?: string[]
[key: string]: any
}
export interface UserMetadata {
[key: string]: any
}
export interface User {
id: string
app_metadata: UserAppMetadata
user_metadata: UserMetadata
aud: string
confirmation_sent_at?: string
recovery_sent_at?: string
email_change_sent_at?: string
new_email?: string
new_phone?: string
invited_at?: string
action_link?: string
email?: string
phone?: string
created_at: string
confirmed_at?: string
email_confirmed_at?: string
phone_confirmed_at?: string
last_sign_in_at?: string
role?: string
updated_at?: string
identities?: UserIdentity[]
is_anonymous?: boolean
is_sso_user?: boolean
factors?: (Factor<FactorType, 'verified'> | Factor<FactorType, 'unverified'>)[]
deleted_at?: string
}

View File

@ -1,3 +1,4 @@
export * from './commons.js';
export * from './ui/schemas.js';
export * from './ui/page-iterator.js';
export * from './competitors/schemas.js';

View File

@ -46,8 +46,6 @@ const enablePlaygrounds = import.meta.env.VITE_ENABLE_PLAYGROUNDS === 'true';
let PlaygroundEditor: any;
let PlaygroundEditorLLM: any;
let VideoPlayerPlayground: any;
let VideoFeedPlayground: any;
let VideoPlayerPlaygroundIntern: any;
let PlaygroundImages: any;
let PlaygroundImageEditor: any;
@ -77,7 +75,6 @@ if (enablePlaygrounds) {
PlaygroundImages = React.lazy(() => import("./pages/PlaygroundImages"));
PlaygroundImageEditor = React.lazy(() => import("./pages/PlaygroundImageEditor"));
VideoGenPlayground = React.lazy(() => import("./pages/VideoGenPlayground"));
PlaygroundCanvas = React.lazy(() => import("./modules/layout/PlaygroundCanvas"));
VariablePlayground = React.lazy(() => import("./components/variables/VariablesEditor").then(module => ({ default: module.VariablesEditor })));
I18nPlayground = React.lazy(() => import("./components/playground/I18nPlayground"));
PlaygroundChat = React.lazy(() => import("./pages/PlaygroundChat"));
@ -184,10 +181,7 @@ const AppWrapper = () => {
<>
<Route path="/playground/editor" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundEditor /></React.Suspense>} />
<Route path="/playground/editor-llm" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundEditorLLM /></React.Suspense>} />
<Route path="/playground/video-player" element={<React.Suspense fallback={<div>Loading...</div>}><VideoPlayerPlayground /></React.Suspense>} />
<Route path="/playground-video-player-intern" element={<React.Suspense fallback={<div>Loading...</div>}><VideoPlayerPlaygroundIntern /></React.Suspense>} />
<Route path="/video-feed" element={<React.Suspense fallback={<div>Loading...</div>}><VideoFeedPlayground /></React.Suspense>} />
<Route path="/video-feed/:id" element={<React.Suspense fallback={<div>Loading...</div>}><VideoFeedPlayground /></React.Suspense>} />
</>
)}

View File

@ -271,7 +271,7 @@ const TopNavigation = () => {
</Link>
</DropdownMenuItem>
{roles.includes("admin") && (
{roles && roles.includes("admin") && (
<>
<DropdownMenuItem asChild>
<Link to="/admin/users" className="flex items-center">

View File

@ -1,471 +0,0 @@
import { useState, useEffect } from 'react';
import { useLayout } from '@/modules/layout/LayoutContext';
import { toast } from 'sonner';
import { useWidgetLoader } from './useWidgetLoader.tsx';
import { useLayouts } from '@/modules/layout/useLayouts';
import { Database } from '@/integrations/supabase/types';
import { apiClient } from "@/lib/db";
type Layout = Database['public']['Tables']['layouts']['Row'];
type LayoutVisibility = Database['public']['Enums']['layout_visibility'];
export function usePlaygroundLogic() {
// UI State
const [viewMode, setViewMode] = useState<'design' | 'preview'>('design');
const [previewHtml, setPreviewHtml] = useState<string>('');
const [htmlSize, setHtmlSize] = useState<number>(0);
const [isAppReady, setIsAppReady] = useState(false);
const [isEditMode, setIsEditMode] = useState(true);
// Page State
const pageId = 'playground-canvas-demo';
const pageName = 'Playground Canvas';
const [layoutJson, setLayoutJson] = useState<string | null>(null);
// Layout Context
const {
loadedPages,
importPageLayout,
loadPageLayout
} = useLayout();
// Template State (now from Supabase)
const [templates, setTemplates] = useState<Layout[]>([]);
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [newTemplateName, setNewTemplateName] = useState('');
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false);
// Paste JSON State
const [isPasteDialogOpen, setIsPasteDialogOpen] = useState(false);
const [pasteJsonContent, setPasteJsonContent] = useState('');
const { loadWidgetBundle } = useWidgetLoader();
const { getLayouts, getLayout, createLayout, updateLayout, deleteLayout } = useLayouts();
const handleSave = async () => {
try {
} catch (e) {
console.error("Failed to save", e);
}
};
useEffect(() => {
refreshTemplates();
}, []);
const refreshTemplates = async () => {
setIsLoadingTemplates(true);
try {
const { data, error } = await getLayouts({ type: 'canvas' });
if (error) {
console.error('Failed to load layouts:', error);
toast.error('Failed to load layouts');
} else {
setTemplates(data || []);
}
} catch (e) {
console.error('Failed to refresh templates:', e);
} finally {
setIsLoadingTemplates(false);
}
};
// Initialization Effect
useEffect(() => {
const init = async () => {
let layout = loadedPages.get(pageId);
if (!layout) {
console.log('[Playground] Loading layout...');
try {
await loadPageLayout(pageId, pageName);
} catch (e) {
console.error("Failed to load layout", e);
}
}
};
init();
}, [pageId, pageName, loadPageLayout, loadedPages]);
// Restoration Effect
useEffect(() => {
const restore = async () => {
const layout = loadedPages.get(pageId);
if (layout) {
if (!isAppReady) {
let detectedRootTemplate: string | undefined;
if (layout.loadedBundles && layout.loadedBundles.length > 0) {
console.log('[Playground] Restoring bundles:', layout.loadedBundles);
for (const bundleUrl of layout.loadedBundles) {
try {
const result = await loadWidgetBundle(bundleUrl);
// Capture root template from the first bundle that has one
if (result.rootTemplateUrl && !detectedRootTemplate) {
detectedRootTemplate = result.rootTemplateUrl;
}
} catch (e) {
console.error("Failed to restore bundle", bundleUrl);
}
}
}
// Self-repair: If layout is missing rootTemplate but we found one, update it.
if (detectedRootTemplate && !layout.rootTemplate) {
console.log('[Playground] Backfilling rootTemplate:', detectedRootTemplate);
layout.rootTemplate = detectedRootTemplate;
layout.rootTemplate = detectedRootTemplate;
await handleSave();
}
setIsAppReady(true);
}
}
};
// If layout exists and we haven't marked ready, try restore
// We depend on loadedPages map or specific entry
if (loadedPages.get(pageId) && !isAppReady) {
restore();
}
}, [loadedPages, pageId, isAppReady, loadWidgetBundle]);
// Preview Generation Effect
// Preview Generation Effect
useEffect(() => {
const updatePreview = async () => {
const layout = loadedPages.get(pageId);
if (layout && layout.rootTemplate) {
try {
const { generateEmailHtml } = await import('@/lib/emailExporter');
let html = await generateEmailHtml(layout, layout.rootTemplate);
// Inject Preview-Only CSS to fix "font-size: 0" visibility issues
// This ensures the preview looks correct without modifying the actual email export structure
const previewFixStyles = `
<style>
.text-block .long-text { font-size: 16px; }
</style>
`;
if (html.includes('</head>')) {
html = html.replace('</head>', `${previewFixStyles}</head>`);
} else {
html = html.replace('<body>', `<head>${previewFixStyles}</head><body>`);
}
setPreviewHtml(html);
// Approximate size of the email body (excluding images, as per Gmail limit on HTML only)
const size = new Blob([html]).size;
setHtmlSize(size);
} catch (e) {
console.error("Preview generation failed", e);
// toast.error("Failed to generate preview"); // Suppress toast on auto-update to avoid spam
}
} else if (!layout?.rootTemplate) {
setPreviewHtml("<html><body><p>No root template found. Please load the email context first.</p></body></html>");
}
};
updatePreview();
}, [loadedPages, pageId]); // Removed viewMode dependency
const handleDumpJson = async () => {
if (!currentLayout) {
toast.error("No layout loaded");
return;
}
try {
// Use current state directly
const json = JSON.stringify(currentLayout, null, 2);
setLayoutJson(json);
await navigator.clipboard.writeText(json);
toast.success("JSON dumped to console, clipboard, and view");
console.log(json);
} catch (e) {
console.error("Failed to dump JSON", e);
toast.error("Failed to dump JSON");
}
};
const handleLoadTemplate = async (template: Layout) => {
try {
// Fetch fresh layout data to ensure we have the latest version
const { data, error } = await getLayout(template.id);
if (error || !data) {
console.error("Failed to fetch fresh layout", error);
toast.error("Failed to load latest version of layout");
return;
}
// layout_json is already a parsed object, convert to string for importPageLayout
const layoutJsonString = JSON.stringify(data.layout_json);
await importPageLayout(pageId, layoutJsonString);
toast.success(`Loaded layout: ${data.name || template.name}`);
setLayoutJson(null);
} catch (e) {
console.error("Failed to load layout", e);
toast.error("Failed to load layout");
}
};
const handleSaveTemplate = async () => {
if (!newTemplateName.trim()) {
toast.error("Please enter a layout name");
return;
}
if (!currentLayout) {
toast.error("No layout loaded to save");
return;
}
try {
const layoutObject = currentLayout;
const { data, error } = await createLayout({
name: newTemplateName.trim(),
layout_json: layoutObject as any,
type: 'canvas',
visibility: 'private' as LayoutVisibility,
meta: {}
});
if (error) {
console.error('Failed to save layout:', error);
toast.error('Failed to save layout');
return;
}
toast.success("Layout saved to database");
setIsSaveDialogOpen(false);
setNewTemplateName('');
await refreshTemplates();
} catch (e) {
console.error("Failed to save layout", e);
toast.error("Failed to save layout");
}
};
const handlePasteJson = async () => {
if (!pasteJsonContent.trim()) {
toast.error("Please enter JSON content");
return;
}
try {
JSON.parse(pasteJsonContent);
await importPageLayout(pageId, pasteJsonContent);
toast.success("Layout imported from JSON");
setIsPasteDialogOpen(false);
setPasteJsonContent('');
setLayoutJson(null);
} catch (e) {
console.error("Failed to import JSON", e);
toast.error("Invalid JSON format");
}
};
const handleDeleteTemplate = async (layoutId: string) => {
try {
const { error } = await deleteLayout(layoutId);
if (error) {
console.error('Failed to delete layout:', error);
toast.error('Failed to delete layout');
return;
}
toast.success('Layout deleted');
await refreshTemplates();
} catch (e) {
console.error('Failed to delete layout:', e);
toast.error('Failed to delete layout');
}
};
const handleToggleVisibility = async (layoutId: string, currentVisibility: LayoutVisibility) => {
try {
// Cycle through visibility options: private -> listed -> public -> private
const visibilityOrder: LayoutVisibility[] = ['private', 'listed', 'public'];
const currentIndex = visibilityOrder.indexOf(currentVisibility);
const newVisibility = visibilityOrder[(currentIndex + 1) % visibilityOrder.length];
const { error } = await updateLayout(layoutId, {
visibility: newVisibility
});
if (error) {
console.error('Failed to update visibility:', error);
toast.error('Failed to update visibility');
return;
}
toast.success(`Layout visibility: ${newVisibility}`);
await refreshTemplates();
} catch (e) {
console.error('Failed to toggle visibility:', e);
toast.error('Failed to toggle visibility');
}
};
const handleRenameLayout = async (layoutId: string, newName: string) => {
if (!newName.trim()) {
toast.error('Layout name cannot be empty');
return;
}
try {
const { error } = await updateLayout(layoutId, {
name: newName.trim()
});
if (error) {
console.error('Failed to rename layout:', error);
toast.error('Failed to rename layout');
return;
}
toast.success('Layout renamed');
await refreshTemplates();
} catch (e) {
console.error('Failed to rename layout:', e);
toast.error('Failed to rename layout');
}
};
const handleLoadContext = async () => {
const bundleUrl = '/widgets/email/library.json';
try {
const result = await loadWidgetBundle(bundleUrl);
const { count, rootTemplateUrl } = result;
toast.success(`Loaded ${count} email widgets`);
const currentLayout = loadedPages.get(pageId);
if (currentLayout) {
const bundles = new Set(currentLayout.loadedBundles || []);
let changed = false;
if (!bundles.has(bundleUrl)) {
bundles.add(bundleUrl);
currentLayout.loadedBundles = Array.from(bundles);
changed = true;
}
if (rootTemplateUrl && currentLayout.rootTemplate !== rootTemplateUrl) {
currentLayout.rootTemplate = rootTemplateUrl;
changed = true;
}
if (changed) {
if (changed) {
await handleSave();
toast.success("Context saved to layout");
}
}
}
} catch (e) {
console.error("Failed to load context", e);
toast.error("Failed to load email context");
}
};
const handleExportHtml = async () => {
const layout = loadedPages.get(pageId);
if (!layout) return;
if (!layout.rootTemplate) {
toast.error("No root template found. Please load a context first.");
return;
}
try {
const { generateEmailHtml } = await import('@/lib/emailExporter');
const html = await generateEmailHtml(layout, layout.rootTemplate);
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${layout.name.toLowerCase().replace(/\s+/g, '-')}.html`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("Exported HTML");
} catch (e) {
console.error("Failed to export HTML", e);
toast.error("Export failed");
}
};
const handleSendTestEmail = async () => {
const layout = loadedPages.get(pageId);
if (!layout) {
toast.error("Layout not loaded");
return;
}
if (!layout.rootTemplate) {
toast.error("No root template found. Please load a context first.");
return;
}
try {
toast.info("Generating email...");
const { generateEmailHtml } = await import('@/lib/emailExporter');
let html = await generateEmailHtml(layout, layout.rootTemplate);
const dummyId = import.meta.env.DEFAULT_POST_TEST_ID || '00000000-0000-0000-0000-000000000000';
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL;
toast.info("Sending test email...");
await apiClient(`/api/send/email/${dummyId}`, {
method: 'POST',
body: JSON.stringify({
html,
subject: `[Test] ${layout.name} - ${new Date().toLocaleTimeString()}`
})
});
toast.success("Test email sent!");
} catch (e) {
console.error("Failed to send test email", e);
toast.error("Failed to send test email");
}
};
const currentLayout = loadedPages.get(pageId);
return {
// State
currentLayout,
viewMode, setViewMode,
previewHtml,
htmlSize,
isAppReady,
isEditMode, setIsEditMode,
pageId,
pageName,
layoutJson,
templates,
isLoadingTemplates,
isSaveDialogOpen, setIsSaveDialogOpen,
newTemplateName, setNewTemplateName,
isPasteDialogOpen, setIsPasteDialogOpen,
pasteJsonContent, setPasteJsonContent,
// Handlers
handleDumpJson,
handleLoadTemplate,
handleSaveTemplate,
handleDeleteTemplate,
handleToggleVisibility,
handleRenameLayout,
handlePasteJson,
handleLoadContext,
handleExportHtml,
handleSendTestEmail,
importPageLayout // Expose importPageLayout for direct external updates
};
}

View File

@ -95,13 +95,11 @@ const getAuthToken = async (): Promise<string | null> => {
// Create OpenAI client
export const createOpenAIClient = async (apiKey?: string): Promise<OpenAI | null> => {
// We use the Supabase session token as the "apiKey" for the proxy
// If a legacy OpenAI key (sk-...) is passed, we ignore it and use the session token
let token = apiKey;
if (!token || token.startsWith('sk-')) {
if (token?.startsWith('sk-')) {
consoleLogger.warn('Legacy OpenAI key detected and ignored. Using Supabase session token for proxy.');
consoleLogger.warn('Legacy OpenAI key detected and ignored. Using Zitadel session token for proxy.');
}
token = (await getAuthToken()) || undefined;
}

View File

@ -234,7 +234,6 @@ export function useChatEngine(namespace = 'chat') {
if (!user) return null;
if (prov === 'openai') {
// Return null since the createOpenAIClient will automatically grab
// the Supabase session token instead of requiring raw OpenAI keys.
return null;
}
try {

View File

@ -4,7 +4,7 @@ import GenericCanvasView from './GenericCanvasView';
// Re-export the full props type so existing consumers keep working
export type { GenericCanvasEditProps as GenericCanvasProps } from './GenericCanvasEdit';
// Lazy-load the heavy edit component (WidgetPalette, upload utils, supabase, etc.)
// Lazy-load the heavy edit component (WidgetPalette, upload utils, etc.)
const GenericCanvasEdit = lazy(() => import('./GenericCanvasEdit'));
import type { GenericCanvasEditProps } from './GenericCanvasEdit';

View File

@ -1,260 +0,0 @@
import { toast } from 'sonner';
import React, { useEffect } from 'react';
import { GenericCanvas } from '@/modules/layout/GenericCanvas';
import { usePlaygroundLogic } from '@/hooks/usePlaygroundLogic.tsx';
import { useWebSocket } from '@/contexts/WS_Socket';
import { PlaygroundHeader } from '@/components/playground/PlaygroundHeader';
import { TemplateDialogs } from '@/components/playground/TemplateDialogs';
import { useSelection } from '@/hooks/useSelection';
import { SelectionHandler } from '@/modules/layout/SelectionHandler';
import { WidgetPropertyPanel } from '@/components/widgets/WidgetPropertyPanel';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { ScrollArea } from "@/components/ui/scroll-area";
import { LayoutProvider } from '@/modules/layout/LayoutContext';
const PlaygroundCanvasContent = () => {
const {
// State
viewMode, setViewMode,
previewHtml,
isAppReady,
isEditMode, setIsEditMode,
pageId,
pageName,
layoutJson,
templates,
isSaveDialogOpen, setIsSaveDialogOpen,
newTemplateName, setNewTemplateName,
isPasteDialogOpen, setIsPasteDialogOpen,
pasteJsonContent, setPasteJsonContent,
// Handlers
handleDumpJson,
handleLoadTemplate,
handleSaveTemplate,
handleDeleteTemplate,
handleToggleVisibility,
handleRenameLayout,
handlePasteJson,
handleLoadContext,
handleExportHtml,
handleSendTestEmail,
htmlSize,
currentLayout,
importPageLayout
} = usePlaygroundLogic();
const { connectToServer, isConnected, wsStatus, disconnectFromServer } = useWebSocket();
const { selectedWidgetId, selectWidget, clearSelection, moveSelection } = useSelection({ pageId });
// Log "playground ready" when widgets are loaded
useEffect(() => {
if (isAppReady && isConnected) {
// We need to access the socket directly or expose a send method.
// The context currently only exposes connection methods.
// We should update the context or modbusService to allow sending generic messages.
// But modbusService.sendCommand exists.
import('@/services/modbusService').then(module => {
module.default.sendCommand('log', {
name: 'playground-canvas',
level: 'info',
message: 'Playground ready, loaded widgets',
}).catch(err => console.error('Failed to send log:', err));
});
}
}, [isAppReady, isConnected]);
// Auto-log Layout JSON on change
useEffect(() => {
if (isConnected && currentLayout) {
import('@/services/modbusService').then(module => {
const service = module.default;
service.log({
name: 'canvas-page-latest',
message: currentLayout,
options: { mode: 'overwrite', format: 'json' }
}).catch(err => console.error('Failed to log layout json:', err));
});
}
}, [currentLayout, isConnected]);
// Auto-log Preview HTML on change
useEffect(() => {
if (isConnected && previewHtml) {
import('@/services/modbusService').then(module => {
const service = module.default;
service.log({
name: 'canvas-html-latest',
message: previewHtml,
options: { mode: 'overwrite', format: 'html' }
}).catch(err => console.error('Failed to log preview html:', err));
});
}
}, [previewHtml, isConnected]);
// Handle external layout updates (from file watcher)
// Handle external layout updates (from file watcher)
useEffect(() => {
if (isConnected) {
let unsubscribe: (() => void) | undefined;
let isCancelled = false;
import('@/services/modbusService').then(module => {
if (isCancelled) return;
const service = module.default;
unsubscribe = service.addMessageHandler('layout-update', (data) => {
console.log(`[Playground] Received layout update`, typeof data);
if (typeof data === 'string') {
// Handle Base64 content (HTML/MD)
try {
const decoded = atob(data);
if (decoded.trim().startsWith('<')) {
toast.info("Received HTML update (View in logs)");
}
} catch (e) {
console.error('Failed to decode base64 layout update', e);
}
} else {
// Handle JSON Object (Layout)
importPageLayout(pageId, JSON.stringify(data)).then(() => {
console.log('[Playground] External layout applied successfully');
toast.info(`Layout updated from watcher`);
}).catch(err => {
console.error('Failed to import external layout:', err);
toast.error(`Failed to update layout from watcher`);
});
}
});
});
return () => {
isCancelled = true;
if (unsubscribe) unsubscribe();
};
}
}, [isConnected, pageId, importPageLayout]);
return (
<div className="h-[calc(100vh-3.5rem)] bg-background flex flex-col overflow-hidden">
<PlaygroundHeader
viewMode={viewMode}
setViewMode={setViewMode}
handleExportHtml={handleExportHtml}
htmlSize={htmlSize}
onSendTestEmail={handleSendTestEmail}
templates={templates}
handleLoadTemplate={handleLoadTemplate}
handleDeleteTemplate={handleDeleteTemplate}
handleToggleVisibility={handleToggleVisibility}
handleRenameLayout={handleRenameLayout}
onSaveTemplateClick={() => setIsSaveDialogOpen(true)}
onPasteJsonClick={() => setIsPasteDialogOpen(true)}
handleDumpJson={handleDumpJson}
handleLoadContext={handleLoadContext}
isEditMode={isEditMode}
setIsEditMode={setIsEditMode}
/>
<div className="flex-1 overflow-y-auto p-0 bg-slate-50 dark:bg-slate-900/50">
{!isAppReady ? (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<p className="text-sm text-muted-foreground">Restoring Playground...</p>
</div>
</div>
) : (
<>
<div className={viewMode === 'design' ? 'h-full' : 'hidden'}>
<ResizablePanelGroup
direction="horizontal"
autoSaveId="playground-layout"
className="h-full"
>
<ResizablePanel defaultSize={75} minSize={50}>
<ScrollArea className="h-full">
<div className="p-8">
<SelectionHandler
onMoveSelection={moveSelection}
onClearSelection={clearSelection}
enabled={viewMode === 'design'}
/>
<GenericCanvas
pageId={pageId}
pageName={pageName}
isEditMode={isEditMode}
showControls={true}
selectedWidgetId={selectedWidgetId}
onSelectWidget={selectWidget}
/>
{layoutJson && (
<div className="mt-8 p-4 border rounded-lg bg-muted/50">
<h3 className="text-sm font-semibold mb-2">Layout JSON</h3>
<pre className="text-xs font-mono overflow-auto max-h-[400px] whitespace-pre-wrap break-all bg-background p-4 rounded border">
{layoutJson}
</pre>
</div>
)}
</div>
</ScrollArea>
</ResizablePanel>
{/* Property Panel - Desktop Only (Hidden on mobile via CSS) */}
{selectedWidgetId && isEditMode && (
<>
<ResizableHandle withHandle className="hidden md:flex" />
<ResizablePanel
defaultSize={25}
minSize={15}
maxSize={40}
className="hidden md:block"
// Ensure panel state is remembered or defaults gracefully
id="property-panel"
order={2}
>
<WidgetPropertyPanel
pageId={pageId}
selectedWidgetId={selectedWidgetId}
onWidgetRenamed={selectWidget}
/>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</div>
<div className={viewMode === 'preview' ? 'block h-full w-full' : 'hidden'}>
<iframe
title="Email Preview"
srcDoc={previewHtml}
className="w-full h-full border-0 bg-white"
/>
</div>
</>
)}
</div>
<TemplateDialogs
isSaveDialogOpen={isSaveDialogOpen}
setIsSaveDialogOpen={setIsSaveDialogOpen}
newTemplateName={newTemplateName}
setNewTemplateName={setNewTemplateName}
handleSaveTemplate={handleSaveTemplate}
isPasteDialogOpen={isPasteDialogOpen}
setIsPasteDialogOpen={setIsPasteDialogOpen}
pasteJsonContent={pasteJsonContent}
setPasteJsonContent={setPasteJsonContent}
handlePasteJson={handlePasteJson}
/>
</div>
);
};
const PlaygroundCanvas = () => (
<LayoutProvider>
<PlaygroundCanvasContent />
</LayoutProvider>
);
export default PlaygroundCanvas;

View File

@ -7,9 +7,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
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'];
type Layout = any; // TODO: replace with actual type
interface WidgetPaletteProps {
isVisible: boolean;

View File

@ -1,138 +1,39 @@
import { SupabaseClient } from "@supabase/supabase-js";
import { fetchWithDeduplication, getAuthToken } from "@/lib/db";
import { apiClient, fetchWithDeduplication } from "@/lib/db";
export const createLayout = async (layoutData: any, client?: SupabaseClient) => {
const token = await getAuthToken();
const headers: HeadersInit = {
'Content-Type': 'application/json'
};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/api/layouts`, {
method: 'POST',
headers,
body: JSON.stringify(layoutData)
});
if (!res.ok) {
throw new Error(`Failed to create layout: ${res.statusText}`);
}
return await res.json();
export const createLayout = async (layoutData: any) => {
return apiClient(`/api/layouts`, { method: 'POST', body: JSON.stringify(layoutData) });
};
export const getLayout = async (layoutId: string, client?: SupabaseClient) => {
const key = `layout-${layoutId}`;
return fetchWithDeduplication(key, async () => {
const token = await getAuthToken();
const headers: HeadersInit = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/api/layouts/${layoutId}`, {
method: 'GET',
headers
});
if (!res.ok) {
throw new Error(`Failed to fetch layout: ${res.statusText}`);
}
// Wrap in object to match Supabase response format { data, error }
const data = await res.json();
return { data, error: null };
});
export const getLayout = async (layoutId: string) => {
return fetchWithDeduplication(`layout-${layoutId}`, () =>
apiClient(`/api/layouts/${layoutId}`)
);
};
export const getLayouts = async (filters?: { type?: string, visibility?: string, limit?: number, offset?: number }, client?: SupabaseClient) => {
export const getLayouts = async (filters?: { type?: string, visibility?: string, limit?: number, offset?: number }) => {
const params = new URLSearchParams();
if (filters?.type) params.append('type', filters.type);
if (filters?.visibility) params.append('visibility', filters.visibility);
if (filters?.limit) params.append('limit', filters.limit.toString());
if (filters?.offset) params.append('offset', filters.offset.toString());
const key = `layouts-${params.toString()}`;
return fetchWithDeduplication(key, async () => {
const token = await getAuthToken();
const headers: HeadersInit = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/api/layouts?${params.toString()}`, {
method: 'GET',
headers
});
if (!res.ok) {
throw new Error(`Failed to fetch layouts: ${res.statusText}`);
}
// Wrap in object to match Supabase response format { data, error }
const data = await res.json();
return { data, error: null };
});
const qs = params.toString();
return fetchWithDeduplication(`layouts-${qs}`, () =>
apiClient(`/api/layouts${qs ? `?${qs}` : ''}`)
);
};
export const updateLayoutMeta = async (layoutId: string, metaUpdates: any) => {
// Fetch current layout to merge meta
const token = await getAuthToken();
const headers: HeadersInit = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
// Get current layout
const getRes = await fetch(`/api/layouts/${layoutId}`, { headers });
if (!getRes.ok) throw new Error(`Failed to fetch layout: ${getRes.statusText}`);
const layout = await getRes.json();
const currentMeta = (layout?.meta as any) || {};
const newMeta = { ...currentMeta, ...metaUpdates };
// Update layout with merged meta
const updateRes = await fetch(`/api/layouts/${layoutId}`, {
method: 'PATCH',
headers,
body: JSON.stringify({ meta: newMeta })
});
if (!updateRes.ok) throw new Error(`Failed to update layout meta: ${updateRes.statusText}`);
return await updateRes.json();
};
export const updateLayout = async (layoutId: string, layoutData: any, client?: SupabaseClient) => {
const token = await getAuthToken();
const headers: HeadersInit = {
'Content-Type': 'application/json'
};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/api/layouts/${layoutId}`, {
method: 'PATCH',
headers,
body: JSON.stringify(layoutData)
});
if (!res.ok) {
throw new Error(`Failed to update layout: ${res.statusText}`);
}
return await res.json();
const layout: any = await apiClient(`/api/layouts/${layoutId}`);
const newMeta = { ...(layout?.meta ?? {}), ...metaUpdates };
return apiClient(`/api/layouts/${layoutId}`, { method: 'PATCH', body: JSON.stringify({ meta: newMeta }) });
};
export const updateLayout = async (layoutId: string, layoutData: any) => {
return apiClient(`/api/layouts/${layoutId}`, { method: 'PATCH', body: JSON.stringify(layoutData) });
};
export const deleteLayout = async (layoutId: string, client?: SupabaseClient) => {
const token = await getAuthToken();
const headers: HeadersInit = {
'Content-Type': 'application/json'
};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/api/layouts/${layoutId}`, {
method: 'DELETE',
headers
});
if (!res.ok) {
throw new Error(`Failed to delete layout: ${res.statusText}`);
}
export const deleteLayout = async (layoutId: string) => {
await apiClient(`/api/layouts/${layoutId}`, { method: 'DELETE' });
return true;
};

View File

@ -1,10 +1,9 @@
import { Database } from '@/integrations/supabase/types';
import { getLayouts, getLayout, createLayout, updateLayout, deleteLayout } from './client-layouts';
type Layout = Database['public']['Tables']['layouts']['Row'];
type LayoutInsert = Database['public']['Tables']['layouts']['Insert'];
type LayoutUpdate = Database['public']['Tables']['layouts']['Update'];
type LayoutVisibility = Database['public']['Enums']['layout_visibility'];
import { getLayouts, getLayout, createLayout, updateLayout, deleteLayout } from './client-layouts';
type Layout = any; // TODO: replace with actual type
type LayoutInsert = any; // TODO: replace with actual type
type LayoutUpdate = any; // TODO: replace with actual type
type LayoutVisibility = any; // TODO: replace with actual type
export interface UseLayoutsReturn {
getLayouts: (filters?: {

View File

@ -2,9 +2,8 @@ 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'];
type Layout = any; // TODO: replace with actual type
const SNIPPET_TYPE = 'widget-snippet';

View File

@ -3,8 +3,7 @@ import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { T, translate } from "@/i18n";
import { cn } from "@/lib/utils";
import { Database } from '@/integrations/supabase/types';
import { Page } from "./types";
import { Eye, EyeOff, Edit3, Trash2, Share2, Link as LinkIcon, FileText, Download, FolderTree, FileJson, LayoutTemplate, ShoppingCart, ExternalLink, History } from "lucide-react";
import { useCartStore } from "@polymech/ecommerce";
import {
@ -22,9 +21,8 @@ import { updatePage, updatePageMeta, deletePage } from "./client-pages";
const CategoryManager = React.lazy(() => import("@/components/widgets/CategoryManager").then(module => ({ default: module.CategoryManager })));
const VersionManager = React.lazy(() => import("./VersionManager").then(module => ({ default: module.VersionManager })));
type Layout = Database['public']['Tables']['layouts']['Row'];
type Layout = any; // TODO: replace with actual type
import { Page } from "./types";
interface PageActionsProps {
page: Page;

View File

@ -1,9 +1,8 @@
import React, { lazy, Suspense } from 'react';
import { Page, UserProfile } from "../types";
import { Database } from '@/integrations/supabase/types';
import UserPageDetailsView from './UserPageDetailsView';
type Layout = Database['public']['Tables']['layouts']['Row'];
type Layout = any; // TODO: replace with actual type
export interface UserPageDetailsProps {
page: Page;

View File

@ -16,10 +16,9 @@ import { useLayout } from "@/modules/layout/LayoutContext";
import { UpdatePageMetaCommand } from "@/modules/layout/commands";
import { Page, UserProfile } from "../types";
import { Database } from '@/integrations/supabase/types';
import { updatePageMeta } from '../client-pages';
type Layout = Database['public']['Tables']['layouts']['Row'];
type Layout = any; // TODO: replace with actual type
export interface UserPageDetailsEditProps {
page: Page;

View File

@ -5,9 +5,8 @@ import { T } from "@/i18n";
import { FileText, Calendar, FolderTree, EyeOff } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { Page, UserProfile } from "../types";
import { Database } from '@/integrations/supabase/types';
type Layout = Database['public']['Tables']['layouts']['Row'];
type Layout = any; // TODO: replace with actual type
const PageActions = React.lazy(() => import("../PageActions").then(module => ({ default: module.PageActions })));

View File

@ -4,9 +4,8 @@ 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'];
type Layout = any; // TODO: replace with actual type
interface UseTemplateManagerParams {
pageId: string | undefined;

View File

@ -63,7 +63,6 @@ import {
} from "@/components/ui/dropdown-menu";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { T, translate } from "@/i18n";
import { Database as DatabaseType } from '@/integrations/supabase/types';
import { CategoryManager } from "@/components/widgets/CategoryManager";
import { VariablesEditor } from "@/components/variables/VariablesEditor";
import { mergePageVariables } from "@/lib/page-variables";
@ -77,12 +76,12 @@ import { ActionProvider } from "@/actions/ActionProvider";
import { useActions } from "@/actions/useActions";
import { UNDO_ACTION_ID, REDO_ACTION_ID, FINISH_ACTION_ID, CANCEL_ACTION_ID } from "@/actions/default-actions";
import { updatePage } from '../../client-pages';
import { Page } from "../../types";
const { UpdatePageParentCommand, UpdatePageMetaCommand } = PageCommands;
type Layout = DatabaseType['public']['Tables']['layouts']['Row'];
type Layout = any; // TODO: replace with actual type
import { Page } from "../../types";
// Snippets sub-component (uses hook, must be its own component)
const SnippetsRibbonGroup: React.FC<{

View File

@ -3,28 +3,7 @@ import { MediaItem } from "@/types";
import { fetchWithDeduplication, apiClient, getAuthHeaders } from "@/lib/db";
import { uploadImage } from "@/lib/uploadUtils";
import { FetchMediaOptions } from "@/utils/mediaUtils";
/*
{
"id": "widget-1772107599497-uw366gapl",
"order": 0,
"props": {
"postId": "863996a5-8400-450d-8189-046c64927e0e",
"imageFit": "contain",
"pictureId": "8662babe-6407-47eb-b068-3b335e144049",
"showTitle": true,
"variables": {},
"showAuthor": false,
"showFooter": true,
"showHeader": false,
"showActions": false,
"contentDisplay": "below",
"showDescription": false
},
"rowId": "row-1772107451319-24v5mwood",
"column": 1,
"widgetId": "photo-card"
}
*/
export const fetchVersions = async (mediaItem: PostMediaItem, userId?: string) => {
const key = `versions-${mediaItem.id}-${userId || 'anon'}`;
return fetchWithDeduplication(key, async () => {
@ -220,7 +199,6 @@ export const fetchMediaItemsByIds = async (
// Call server API endpoint using apiClient
const data = await apiClient<any[]>(`/api/media-items?${params.toString()}`);
// The server returns raw Supabase data, so we need to adapt it
const { adaptSupabasePicturesToMediaItems } = await import('@/modules/posts/views/adapters');
return adaptSupabasePicturesToMediaItems(data);
});

View File

@ -1,4 +1,3 @@
import { Database } from '@/integrations/supabase/types';
import {
MediaItem,
ImageMediaItem,
@ -10,7 +9,7 @@ import {
} from '@/types';
import { detectMediaType } from '@/lib/mediaRegistry';
type SupabasePicture = Database['public']['Tables']['pictures']['Row'];
type SupabasePicture = any; // TODO: replace with actual type
/**
* Adapter to convert Supabase picture row to MediaItem discriminated union

View File

@ -1,7 +1,7 @@
import React, { Suspense, lazy } from 'react';
import ImageLightbox from "@/components/ImageLightbox";
import { PostMediaItem } from "../types";
import { User } from "@supabase/supabase-js";
import { User } from "@polymech/shared";
// Lazy load the LLM-powered editor — only fetched when user is logged in
const SmartLightboxEditor = lazy(() => import('./SmartLightboxEditor'));

View File

@ -2,7 +2,7 @@ import React from 'react';
import ImageLightbox from "@/components/ImageLightbox";
import { usePostLLM } from "../llm";
import { PostMediaItem } from "../types";
import { User } from "@supabase/supabase-js";
import { User } from "@polymech/shared";
interface SmartLightboxEditorProps {
isOpen: boolean;

View File

@ -1,5 +1,5 @@
import { User } from '@supabase/supabase-js';
import { ImageFile, MediaItem } from "@/types";
import { User } from "@polymech/shared";
// PostMediaItem extends MediaItem with post-specific fields
export type PostMediaItem = MediaItem & {

View File

@ -6,7 +6,7 @@ import { MediaItem } from "@/types";
import { translate } from "@/i18n";
import { updateMediaPositions } from "./utils";
import { PostItem } from './types';
import { User } from '@supabase/supabase-js';
import { User } from "@polymech/shared";
import { deletePost, updatePostMeta } from '@/modules/posts/client-posts';
import { deletePictures, updatePicture } from '@/modules/posts/client-pictures';

View File

@ -1,5 +1,5 @@
import type { NavigateFunction } from 'react-router-dom';
import type { User } from '@supabase/supabase-js';
import type { User } from "@polymech/shared";
import type { INode } from '@/modules/storage/types';
import type { VfsPanelActionSpec } from '@/modules/storage/useRegisterVfsPanelActions';