diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx new file mode 100644 index 00000000..e8625f6d --- /dev/null +++ b/packages/ui/src/App.tsx @@ -0,0 +1,207 @@ +import React from "react"; +import { Toaster } from "@/components/ui/toaster"; +import { Toaster as Sonner } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom"; +import { AuthProvider, useAuth } from "@/hooks/useAuth"; +import { PostNavigationProvider } from "@/contexts/PostNavigationContext"; +import { OrganizationProvider } from "@/contexts/OrganizationContext"; +import { LogProvider, useLog } from "@/contexts/LogContext"; +import { LayoutProvider } from "@/contexts/LayoutContext"; +import { MediaRefreshProvider } from "@/contexts/MediaRefreshContext"; +import { ProfilesProvider } from "@/contexts/ProfilesContext"; +import { WebSocketProvider } from "@/contexts/WS_Socket"; +import { registerAllWidgets } from "@/lib/registerWidgets"; +import TopNavigation from "@/components/TopNavigation"; +import GlobalDragDrop from "@/components/GlobalDragDrop"; + +// Register all widgets on app boot +registerAllWidgets(); + +import Index from "./pages/Index"; +import Auth from "./pages/Auth"; +import Profile from "./pages/Profile"; +import Post from "./pages/Post"; +import UserProfile from "./pages/UserProfile"; +import UserCollections from "./pages/UserCollections"; +import Collections from "./pages/Collections"; +import NewCollection from "./pages/NewCollection"; +import UserPage from "./pages/UserPage"; +import NewPage from "./pages/NewPage"; +import TagPage from "./pages/TagPage"; +import SearchResults from "./pages/SearchResults"; +import Wizard from "./pages/Wizard"; +import NewPost from "./pages/NewPost"; + +import Organizations from "./pages/Organizations"; +const ProviderSettings = React.lazy(() => import("./pages/ProviderSettings")); +const PlaygroundEditor = React.lazy(() => import("./pages/PlaygroundEditor")); +const PlaygroundEditorLLM = React.lazy(() => import("./pages/PlaygroundEditorLLM")); +const VideoPlayerPlayground = React.lazy(() => import("./pages/VideoPlayerPlayground")); +const VideoFeedPlayground = React.lazy(() => import("./pages/VideoFeedPlayground")); +const VideoPlayerPlaygroundIntern = React.lazy(() => import("./pages/VideoPlayerPlaygroundIntern")); +const NotFound = React.lazy(() => import("./pages/NotFound")); +const AdminPage = React.lazy(() => import("./pages/AdminPage")); +const PlaygroundImages = React.lazy(() => import("./pages/PlaygroundImages")); +const PlaygroundImageEditor = React.lazy(() => import("./pages/PlaygroundImageEditor")); +const VideoGenPlayground = React.lazy(() => import("./pages/VideoGenPlayground")); +const PlaygroundCanvas = React.lazy(() => import("./pages/PlaygroundCanvas")); +import LogsPage from "./components/logging/LogsPage"; + +const queryClient = new QueryClient(); + +const VersionMap = React.lazy(() => import("./pages/VersionMap")); + +// +const AppWrapper = () => { + const location = useLocation(); + + const isFullScreenPage = location.pathname.startsWith('/video-feed'); + + const containerClassName = isFullScreenPage + ? "flex flex-col min-h-svh transition-colors duration-200 bg-background h-full" + : "mx-auto 2xl:max-w-7xl flex flex-col min-h-svh transition-colors duration-200 bg-background h-full"; + + return ( +
+ {!isFullScreenPage && } + + + {/* Top-level routes (no organization context) */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + Loading map...
}> + + + } /> + } /> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + + {/* Admin Routes */} + Loading...}>} /> + + {/* Organization-scoped routes */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + Loading map...}> + + + } /> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + + {/* Playground Routes */} + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + } /> + + {/* Logs */} + } /> + + {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} + Loading...}>} /> + + + ); +}; + +import { initFormatDetection } from '@/utils/formatDetection'; + +import { SWRConfig } from 'swr'; +import CacheTest from "./pages/CacheTest"; + +// ... (imports) + +import { FeedCacheProvider } from "@/contexts/FeedCacheContext"; + +// ... (imports) + +const App = () => { + React.useEffect(() => { + initFormatDetection(); + }, []); + + return ( + new Map() }}> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +// Update Routes in AppWrapper to include /test-cache/:id +// ... +// We need to inject the route inside AppWrapper component defined above +// Since ReplaceFileContent works on blocks, I'll target the Routes block in a separate call or try to merge. +// Merging is safer if I can target the App component specifically. +// But the Routes are in AppWrapper. +// Let's split this into two edits. + + +export default App; diff --git a/packages/ui/src/EmbedApp.tsx b/packages/ui/src/EmbedApp.tsx new file mode 100644 index 00000000..4d2ca524 --- /dev/null +++ b/packages/ui/src/EmbedApp.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { EmbedRenderer } from './pages/Post/renderers/EmbedRenderer'; +import UserPage from './pages/UserPage'; +import { Toaster } from "@/components/ui/sonner"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from 'react-router-dom'; +import { LayoutProvider } from './contexts/LayoutContext'; +import { AuthProvider } from '@/hooks/useAuth'; +import { LogProvider } from '@/contexts/LogContext'; + +interface EmbedAppProps { + initialState: any; +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + }, + }, +}); + +const EmbedApp: React.FC = ({ initialState }) => { + const { post, mediaItems, authorProfile, page } = initialState; + + if (!post && !mediaItems && !page) { + return ( +
+

Content not found or failed to load.

+
+ ); + } + + if (page) { + return ( + + + + + + + + + + + + + ); + } + + return ( + + + +
+ { }} + onViewModeChange={() => { }} + onExportMarkdown={() => { }} + onDeletePost={() => { }} + onDeletePicture={() => { }} + onLike={() => { }} + onEditPicture={() => { }} + onMediaSelect={() => { }} + onExpand={() => { + // Open post in new tab + window.open(`/post/${post?.id || mediaItems?.[0]?.id}`, '_blank'); + }} + onDownload={() => { }} + currentImageIndex={0} + videoPlaybackUrl="" + videoPosterUrl="" + versionImages={[]} + handlePrevImage={() => { }} + handleNavigate={(dir) => { }} + navigationData={null} + isEditMode={false} + localPost={null} + setLocalPost={() => { }} + localMediaItems={[]} + setLocalMediaItems={() => { }} + onMoveItem={() => { }} + onEditModeToggle={() => { }} + onSaveChanges={() => { }} + onYouTubeAdd={() => { }} + onTikTokAdd={() => { }} + onAIWizardOpen={() => { }} + onInlineUpload={async () => { }} + onRemoveFromPost={() => { }} + onGalleryPickerOpen={() => { }} + onUnlinkImage={() => { }} + /> + +
+
+
+
+ ); +}; + +export default EmbedApp; diff --git a/packages/ui/src/Logger.ts b/packages/ui/src/Logger.ts new file mode 100644 index 00000000..649cf0fb --- /dev/null +++ b/packages/ui/src/Logger.ts @@ -0,0 +1,181 @@ +import { toast } from 'sonner'; +import React from 'react'; + +export type LogLevel = 'info' | 'warn' | 'error' | 'success' | 'debug'; + +export interface LogEntry { + level: LogLevel; + message: string; + timestamp: number; + data?: any; +} + +export interface LoggerConfig { + enableConsole: boolean; + enableToaster: boolean; + deduplicationWindow: number; // ms +} + +class Logger { + private static instance: Logger; + private config: LoggerConfig; + private recentLogs: Map = new Map(); + + private constructor() { + this.config = { + enableConsole: true, + enableToaster: true, + deduplicationWindow: 5000, // 5 seconds + }; + } + + public static getInstance(): Logger { + if (!Logger.instance) { + Logger.instance = new Logger(); + } + return Logger.instance; + } + + public configure(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + private isDuplicate(message: string): boolean { + const now = Date.now(); + const lastLogTime = this.recentLogs.get(message); + + if (lastLogTime && (now - lastLogTime) < this.config.deduplicationWindow) { + return true; + } + + this.recentLogs.set(message, now); + + // Clean up old entries + for (const [msg, timestamp] of this.recentLogs.entries()) { + if (now - timestamp > this.config.deduplicationWindow) { + this.recentLogs.delete(msg); + } + } + + return false; + } + + private logToConsole(level: LogLevel, message: string, data?: any): void { + if (!this.config.enableConsole) return; + + const timestamp = new Date().toISOString(); + const prefix = `[${timestamp}] [${level.toUpperCase()}]`; + + switch (level) { + case 'error': + console.error(prefix, message, data || ''); + break; + case 'warn': + console.warn(prefix, message, data || ''); + break; + case 'debug': + console.debug(prefix, message, data || ''); + break; + case 'info': + case 'success': + default: + console.log(prefix, message, data || ''); + break; + } + } + + private logToToaster(level: LogLevel, message: string): void { + if (!this.config.enableToaster) return; + + const toastId = toast( + React.createElement( + 'div', + { + onClick: () => toast.dismiss(toastId), + style: { cursor: 'pointer', width: '100%' }, + }, + message + ) + ); + + switch (level) { + case 'error': + toast.error(message, { id: toastId }); + break; + case 'warn': + toast.warning(message, { id: toastId }); + break; + case 'success': + toast.success(message, { id: toastId }); + break; + case 'info': + toast.info(message, { id: toastId }); + break; + case 'debug': + // Don't show debug messages in toaster by default + toast.dismiss(toastId); + break; + } + } + + public log(level: LogLevel, message: string, data?: any): void { + if (this.isDuplicate(message)) { + return; + } + + this.logToConsole(level, message, data); + this.logToToaster(level, message); + } + + public info(message: string, data?: any): void { + this.log('info', message, data); + } + + public warn(message: string, data?: any): void { + this.log('warn', message, data); + } + + public error(message: string, data?: any): void { + this.log('error', message, data); + } + + public success(message: string, data?: any): void { + this.log('success', message, data); + } + + public debug(message: string, data?: any): void { + this.log('debug', message, data); + } + + // Convenience methods for common patterns + public logError(error: unknown, context?: string): void { + const message = context + ? `${context}: ${error instanceof Error ? error.message : String(error)}` + : error instanceof Error ? error.message : String(error); + + this.error(message, error instanceof Error ? error.stack : undefined); + } + + public logApiError(operation: string, error: unknown): void { + this.logError(error, `Error during ${operation}`); + } + + public logWebSocketError(operation: string, error: unknown): void { + this.logError(error, `WebSocket error during ${operation}`); + } + + // Method to temporarily disable toaster (useful for batch operations) + public withoutToaster(fn: () => T): T { + const originalToasterState = this.config.enableToaster; + this.config.enableToaster = false; + try { + return fn(); + } finally { + this.config.enableToaster = originalToasterState; + } + } +} + +// Export singleton instance +export const logger = Logger.getInstance(); +export default logger; \ No newline at end of file diff --git a/packages/ui/src/assets/hero-image.jpg b/packages/ui/src/assets/hero-image.jpg new file mode 100644 index 00000000..8c41440d Binary files /dev/null and b/packages/ui/src/assets/hero-image.jpg differ diff --git a/packages/ui/src/components/GlobalDragDrop.tsx b/packages/ui/src/components/GlobalDragDrop.tsx index 3a5bbe99..925f542f 100644 --- a/packages/ui/src/components/GlobalDragDrop.tsx +++ b/packages/ui/src/components/GlobalDragDrop.tsx @@ -16,8 +16,8 @@ const GlobalDragDrop = () => { const isValidDrag = (e: DragEvent) => { const types = e.dataTransfer?.types || []; if (types.includes('polymech/internal')) return false; - // Allow Files or Links (text/uri-list) - return types.includes('Files') || types.includes('text/uri-list'); + // Allow Files or Links (text/uri-list) or text/plain (often used for links on mobile) + return types.includes('Files') || types.includes('text/uri-list') || types.includes('text/plain'); }; const handleDragEnter = (e: DragEvent) => { diff --git a/packages/ui/src/components/ImageWizard/README.md b/packages/ui/src/components/ImageWizard/README.md index 0f059de2..c9fa504f 100644 --- a/packages/ui/src/components/ImageWizard/README.md +++ b/packages/ui/src/components/ImageWizard/README.md @@ -1,7 +1,6 @@ # ImageWizard Component Structure ## Overview - The ImageWizard is split into focused modules for better maintainability. ## File Structure @@ -30,25 +29,21 @@ ImageWizard/ ## Recent Refactorings ### 1. ✅ Prompt Splitter Extracted (`promptHandlers.ts`) - - `generateImageSplit` - Sequential multi-prompt generation - Uses `splitPromptByLines` from `@constants.ts` ### 2. ✅ Logger Refactored (`utils/logger.ts`) - - Changed from `addLog('debug', ...)` to `logger.debug(...)` - Created `Logger` interface with clean methods: `debug()`, `info()`, `warning()`, `error()`, `success()`, `verbose()` - All handlers now accept `Logger` instead of raw `addLog` function - Component name auto-prefixed in logs ### 3. ✅ DEFAULT_QUICK_ACTIONS Moved to `@constants.ts` - - `QuickAction` interface now exported from constants - Available throughout the codebase - Removed duplicate from `types.ts` ### 4. ✅ Drag and Drop Extracted (`dropHandlers.ts`) - - `handleDragEnter` - Show drop overlay - `handleDragOver` - Keep overlay active - `handleDragLeave` - Debounced hide (prevents flicker) @@ -57,7 +52,6 @@ ImageWizard/ ## Handler Categories ### 📸 imageHandlers.ts - - `handleFileUpload` - Upload files - `toggleImageSelection` - Select/deselect images - `removeImageRequest` - Request image deletion @@ -66,40 +60,32 @@ ImageWizard/ - `handleDownloadImage` - Download image ### 🎨 generationHandlers.ts - - `handleOptimizePrompt` - Optimize prompt with AI - `buildFullPrompt` - Build prompt with preset context - `abortGeneration` - Cancel generation ### 📝 promptHandlers.ts - - `generateImageSplit` - Sequential multi-prompt generation ### 🎤 voiceHandlers.ts - - `handleMicrophone` - Record audio - `handleVoiceToImage` - Voice-to-image workflow with AI ### 🤖 agentHandlers.ts - - `handleAgentGeneration` - AI Agent mode with tool calling ### 📤 publishHandlers.ts - - `publishImage` - Standard publish - `quickPublishAsNew` - Quick publish with prompt as description ### 💾 dataHandlers.ts - - `loadFamilyVersions` - Load image version families - `loadAvailableImages` - Load gallery images ### ⚙️ settingsHandlers.ts - - Template, preset, workflow, history management ### 🎯 dropHandlers.ts - - Drag and drop file handling ## Usage Example @@ -119,7 +105,6 @@ logger.info('File uploaded successfully'); ``` ## Benefits - ✅ Easier to find specific logic ✅ Better code organization ✅ Simpler testing @@ -127,3 +112,4 @@ logger.info('File uploaded successfully'); ✅ Clear separation of concerns ✅ Clean logging API ✅ Reusable utilities + diff --git a/packages/ui/src/components/ImageWizard/utils/logger.ts b/packages/ui/src/components/ImageWizard/utils/logger.ts index 405103a2..398f19f3 100644 --- a/packages/ui/src/components/ImageWizard/utils/logger.ts +++ b/packages/ui/src/components/ImageWizard/utils/logger.ts @@ -19,7 +19,7 @@ export interface Logger { */ const formatMessage = (message: string, args: any[]): string => { if (args.length === 0) return message; - + // Stringify additional arguments and append them const argsStr = args.map(arg => { if (typeof arg === 'string') return arg; @@ -30,7 +30,7 @@ const formatMessage = (message: string, args: any[]): string => { return String(arg); } }).join(' '); - + return `${message} ${argsStr}`; }; diff --git a/packages/ui/src/components/ListLayout.tsx b/packages/ui/src/components/ListLayout.tsx index ee476943..9535028e 100644 --- a/packages/ui/src/components/ListLayout.tsx +++ b/packages/ui/src/components/ListLayout.tsx @@ -14,7 +14,7 @@ import UserPage from "@/pages/UserPage"; interface ListLayoutProps { sortBy?: FeedSortOption; - navigationSource?: 'home' | 'collection' | 'tag' | 'user' | 'widget'; + navigationSource?: 'home' | 'collection' | 'tag' | 'user'; navigationSourceId?: string; isOwner?: boolean; // Not strictly used for rendering list but good for consistency } @@ -199,7 +199,7 @@ export const ListLayout = ({ {/* Right: Detail */} -
+
{selectedId ? ( (() => { const selectedPost = feedPosts.find((p: any) => p.id === selectedId); diff --git a/packages/ui/src/components/MarkdownRenderer.tsx b/packages/ui/src/components/MarkdownRenderer.tsx index 7692fec7..62a81f14 100644 --- a/packages/ui/src/components/MarkdownRenderer.tsx +++ b/packages/ui/src/components/MarkdownRenderer.tsx @@ -54,12 +54,28 @@ const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRender .replace(/"/g, '"') .replace(/'/g, "'"); - // Configure marked options for regular markdown + + // Configure marked options marked.setOptions({ - breaks: true, // Convert \n to
- gfm: true, // GitHub flavored markdown + breaks: true, + gfm: true, }); + // Custom renderer to add IDs to headings + const renderer = new marked.Renderer(); + renderer.heading = ({ text, depth }: { text: string; depth: number }) => { + const slug = text + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') + .replace(/[\s_-]+/g, '-') + .replace(/^-+|-+$/g, ''); + + return `${text}`; + }; + + marked.use({ renderer }); + // Convert markdown to HTML const rawHtml = marked.parse(decodedContent) as string; diff --git a/packages/ui/src/components/PageActions.tsx b/packages/ui/src/components/PageActions.tsx new file mode 100644 index 00000000..07f72c03 --- /dev/null +++ b/packages/ui/src/components/PageActions.tsx @@ -0,0 +1,503 @@ +import { useState } from "react"; +import { supabase } from "@/integrations/supabase/client"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Eye, EyeOff, Edit3, Trash2, GitMerge, Share2, Link as LinkIcon, FileText, Download, FilePlus } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, + DropdownMenuLabel +} from "@/components/ui/dropdown-menu"; +import { T, translate } from "@/i18n"; +import { PagePickerDialog } from "./widgets/PagePickerDialog"; +import { PageCreationWizard } from "./widgets/PageCreationWizard"; +import { cn } from "@/lib/utils"; + +interface Page { + id: string; + title: string; + content: any; + visible: boolean; + is_public: boolean; + owner: string; + slug: string; + parent: string | null; +} + +interface PageActionsProps { + page: Page; + isOwner: boolean; + isEditMode?: boolean; + onToggleEditMode?: () => void; + onPageUpdate: (updatedPage: Page) => void; + onDelete?: () => void; + className?: string; + showLabels?: boolean; +} + +export const PageActions = ({ + page, + isOwner, + isEditMode = false, + onToggleEditMode, + onPageUpdate, + onDelete, + className, + showLabels = true +}: PageActionsProps) => { + const [loading, setLoading] = useState(false); + const [showPagePicker, setShowPagePicker] = useState(false); + const [showCreationWizard, setShowCreationWizard] = useState(false); + const [isGeneratingPdf, setIsGeneratingPdf] = useState(false); + + const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin; + + const handleParentUpdate = async (parentId: string | null) => { + if (loading) return; + setLoading(true); + + try { + const { error } = await supabase + .from('pages') + .update({ parent: parentId }) + .eq('id', page.id); + + if (error) throw error; + + onPageUpdate({ ...page, parent: parentId }); + toast.success(translate('Page parent updated')); + } catch (error) { + console.error('Error updating page parent:', error); + toast.error(translate('Failed to update page parent')); + } finally { + setLoading(false); + } + }; + + const handleToggleVisibility = async (e?: React.MouseEvent) => { + e?.stopPropagation(); + if (loading) return; + setLoading(true); + + try { + const { error } = await supabase + .from('pages') + .update({ visible: !page.visible }) + .eq('id', page.id); + + if (error) throw error; + + onPageUpdate({ ...page, visible: !page.visible }); + toast.success(translate(page.visible ? 'Page hidden' : 'Page made visible')); + } catch (error) { + console.error('Error toggling visibility:', error); + toast.error(translate('Failed to update page visibility')); + } finally { + setLoading(false); + } + }; + + const handleTogglePublic = async (e?: React.MouseEvent) => { + e?.stopPropagation(); + if (loading) return; + setLoading(true); + + try { + const { error } = await supabase + .from('pages') + .update({ is_public: !page.is_public }) + .eq('id', page.id); + + if (error) throw error; + + onPageUpdate({ ...page, is_public: !page.is_public }); + toast.success(translate(page.is_public ? 'Page made private' : 'Page made public')); + } catch (error) { + console.error('Error toggling public status:', error); + toast.error(translate('Failed to update page status')); + } finally { + setLoading(false); + } + }; + + const handleCopyLink = async () => { + const url = window.location.href; + const title = page.title || 'PolyMech Page'; + + if (navigator.share && navigator.canShare({ url, title })) { + try { + await navigator.share({ url, title }); + return; + } catch (e) { + if ((e as Error).name !== 'AbortError') console.error('Share failed', e); + } + } + + try { + await navigator.clipboard.writeText(url); + toast.success("Link copied to clipboard"); + } catch (e) { + console.error('Clipboard failed', e); + toast.error("Failed to copy link"); + } + }; + + const processPageContent = (content: any): string => { + if (!content) return ''; + if (typeof content === 'string') return content; + + let markdown = ''; + const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin; + + try { + // Determine content root + // Some versions might have content directly, others wrapped in { pages: { [id]: { containers: ... } } } + let root = content; + if (content.pages) { + // Try to find the page by ID or take the first one + const pageIdKey = `page-${page.id}`; + if (content.pages[pageIdKey]) { + root = content.pages[pageIdKey]; + } else { + // Fallback: take first key + const keys = Object.keys(content.pages); + if (keys.length > 0) root = content.pages[keys[0]]; + } + } + + // Traverse containers + if (root.containers && Array.isArray(root.containers)) { + root.containers.forEach((container: any) => { + if (container.widgets && Array.isArray(container.widgets)) { + container.widgets.forEach((widget: any) => { + if (widget.widgetId === 'markdown-text' && widget.props && widget.props.content) { + markdown += widget.props.content + '\n\n'; + } + // Future: Handle other widgets if needed + }); + } + }); + } else if (root.widgets && Array.isArray(root.widgets)) { // Fallback for simple structure + root.widgets.forEach((widget: any) => { + if (widget.widgetId === 'markdown-text' && widget.props && widget.props.content) { + markdown += widget.props.content + '\n\n'; + } + }); + } + } catch (e) { + console.error('Error parsing page content:', e); + return JSON.stringify(content, null, 2); // Fallback to raw JSON + } + + // URL Resolution logic is handled by markdown-text widget content usually containing relative URLs + // If we need to process them: + // markdown = markdown.replace(/!\[(.*?)\]\((.*?)\)/g, (match, alt, url) => { ... }); + // For now returning raw markdown as user requested "mind url" likely meant for PDF which runs server side. + // Client side export usually keeps links as is unless they are purely internal IDs. + + return markdown; + }; + + const getSlug = (text: string) => text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-'); + + const handleExportMarkdown = () => { + try { + let content = processPageContent(page.content); + + // Generate TOC + const lines = content.split('\n'); + let toc = '# Table of Contents\n\n'; + let hasHeadings = false; + + lines.forEach(line => { + // Determine header level + const match = line.match(/^(#{1,3})\s+(.+)/); + if (match) { + hasHeadings = true; + const level = match[1].length; + const text = match[2]; + const slug = getSlug(text); + const indent = ' '.repeat(level - 1); + toc += `${indent}- [${text}](#${slug})\n`; + } + }); + + if (hasHeadings) { + content = `${toc}\n---\n\n${content}`; + } + + const blob = new Blob([content], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${(page.title || 'page').replace(/[^a-z0-9]/gi, '_')}.md`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + toast.success("Markdown downloaded"); + } catch (e) { + console.error("Markdown export failed", e); + toast.error("Failed to export Markdown"); + } + }; + + const handleEmbed = async () => { + // Embed logic: Copy iframe code + // Route: /embed/:id is currently for posts. But maybe it works for pages if we update the backend? + // Wait, current serving index.ts handleGetEmbed fetches from 'posts' table only. + // User asked for "embed (HTML)". + // Assuming we should point to a route that RENDERS the page. + // If we don't have a dedicated page embed route yet, we might need one. + // However, the user said "see dedicated app route ( @[src/main-embed.tsx] )". + // This suggests the frontend app handles it. + // The backend route `/embed/:id` serves the HTML that bootstraps `main-embed.tsx`. + // So we just need to use that URL. BUT `handleGetEmbed` in backend fetches from `posts`. + // We probably need `handleGetEmbedPage` or update `handleGetEmbed` to support pages. + // For now, let's assume valid URL structure is `/embed/page/:id` which we plan to add or `/embed/:id` if we unify. + // Let's use `/embed/page/${page.id}` in the snippet and ensure backend supports it. + + const embedUrl = `${baseUrl}/embed/page/${page.id}`; + const iframeCode = ``; + + try { + await navigator.clipboard.writeText(iframeCode); + toast.success("Embed code copied to clipboard"); + } catch (e) { + toast.error("Failed to copy embed code"); + } + }; + + const handleExportAstro = async () => { + try { + // Re-use markdown export logic + let content = processPageContent(page.content); + const slug = getSlug(page.title || 'page'); + + // Generate TOC (same as markdown export) + const lines = content.split('\n'); + let toc = '# Table of Contents\n\n'; + let hasHeadings = false; + + lines.forEach(line => { + const match = line.match(/^(#{1,3})\s+(.+)/); + if (match) { + hasHeadings = true; + const level = match[1].length; + const text = match[2]; + const id = getSlug(text); + const indent = ' '.repeat(level - 1); + toc += `${indent}- [${text}](#${id})\n`; + } + }); + + if (hasHeadings) { + content = `${toc}\n---\n\n${content}`; + } + + // Construct Front Matter + const safeTitle = (page.title || 'Untitled').replace(/"/g, '\\"'); + const safeSlug = (page.slug || slug).replace(/"/g, '\\"'); + const dateStr = new Date().toISOString().split('T')[0]; + + // Note: Page interface in PageActions might need update to include tags if we want them + // For now, using standard props we have + + const frontMatter = `--- +title: "${safeTitle}" +slug: "${safeSlug}" +date: "${dateStr}" +author: "${page.owner}" +draft: ${!page.visible} +--- + +`; + + const finalContent = frontMatter + content; + const blob = new Blob([finalContent], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${slug}.astro`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + toast.success("Astro export downloaded"); + } catch (e) { + console.error("Astro export failed", e); + toast.error("Failed to export Astro"); + } + }; + + const handleExportPdf = async () => { + setIsGeneratingPdf(true); + toast.info("Generating PDF..."); + try { + const link = document.createElement('a'); + link.href = `${baseUrl}/api/render/pdf/page/${page.id}`; + link.target = "_blank"; + link.download = `${(page.title || 'page').replace(/[^a-z0-9]/gi, '_')}.pdf`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } catch (e) { + console.error(e); + toast.error("Failed to download PDF"); + } finally { + setIsGeneratingPdf(false); + } + }; + + return ( +
+ {/* Share Menu */} + + + + + + Share & Export + + + + Copy Link + + + + Export Markdown + + + + {isGeneratingPdf ? 'Generating PDF...' : 'Export PDF'} + + + + Export Astro (Beta) + + + + Embed (HTML) + + + + + {/* Owner Controls */} + {isOwner && ( + <> + + + + + {onToggleEditMode && ( + + )} + + + + setShowPagePicker(false)} + onSelect={handleParentUpdate} + currentValue={page.parent} + forbiddenIds={[page.id]} + /> + + + + setShowCreationWizard(false)} + parentId={page.id} + /> + + {onDelete && ( + + )} + + )} +
+ ); +}; diff --git a/packages/ui/src/components/PhotoGrid.tsx b/packages/ui/src/components/PhotoGrid.tsx index 75935554..5ad35d05 100644 --- a/packages/ui/src/components/PhotoGrid.tsx +++ b/packages/ui/src/components/PhotoGrid.tsx @@ -59,7 +59,6 @@ const MediaGrid = ({ navigationSourceId, isOwner = false, onFilesDrop, - showVideos = true, sortBy = 'latest', supabaseClient, diff --git a/packages/ui/src/components/ResponsiveImage.tsx b/packages/ui/src/components/ResponsiveImage.tsx index b35548ee..e712edfd 100644 --- a/packages/ui/src/components/ResponsiveImage.tsx +++ b/packages/ui/src/components/ResponsiveImage.tsx @@ -26,6 +26,7 @@ export interface ResponsiveData { srcset: string; type: string; }[]; + stats?: any; } const ResponsiveImage: React.FC = ({ @@ -33,8 +34,8 @@ const ResponsiveImage: React.FC = ({ sizes = '(max-width: 1024px) 100vw, 50vw', className, imgClassName, - responsiveSizes = [180, 640, 1024, 1280, 1600], - formats = ['avif', 'webp'], + responsiveSizes = [640, 1280], + formats = ['avif'], alt, onDataLoaded, rootMargin = '800px', @@ -111,7 +112,7 @@ const ResponsiveImage: React.FC = ({ if (!isInView || isLoadingOrPending) { // Use className for wrapper if provided, otherwise generic // We attach the ref here to detect when this placeholder comes into view - return
; + return
; } if (error || !data) { @@ -151,7 +152,7 @@ const ResponsiveImage: React.FC = ({ /> {!imgLoaded && ( -
+
)} diff --git a/packages/ui/src/components/VideoCard.tsx b/packages/ui/src/components/VideoCard.tsx index de7d91b7..6a6f2551 100644 --- a/packages/ui/src/components/VideoCard.tsx +++ b/packages/ui/src/components/VideoCard.tsx @@ -84,11 +84,7 @@ const VideoCard = ({ // Stop playback on navigation & Cleanup useEffect(() => { - console.log(`[VideoCard ${videoId}] Mounted`); const handleNavigation = () => { - if (isPlaying) { - console.log(`[VideoCard ${videoId}] Navigation detected - stopping`); - } setIsPlaying(false); player.current?.pause(); }; @@ -96,7 +92,6 @@ const VideoCard = ({ handleNavigation(); return () => { - console.log(`[VideoCard ${videoId}] Unmounting - pausing player`); player.current?.pause(); }; }, [location.pathname]); @@ -383,7 +378,6 @@ const VideoCard = ({ }; const handleClick = (e: React.MouseEvent) => { - console.log('Video clicked'); e.preventDefault(); e.stopPropagation(); onClick?.(videoId); @@ -394,7 +388,6 @@ const VideoCard = ({ const handleStopVideo = (e: Event) => { const customEvent = e as CustomEvent; if (customEvent.detail?.sourceId !== videoId && isPlaying) { - console.log(`[VideoCard ${videoId}] Stopping due to global event`); setIsPlaying(false); player.current?.pause(); } @@ -405,7 +398,6 @@ const VideoCard = ({ }, [isPlaying, videoId]); const handlePlayClick = (e: React.MouseEvent) => { - console.log('Play clicked'); e.preventDefault(); e.stopPropagation(); diff --git a/packages/ui/src/components/hmi/GenericCanvas.tsx b/packages/ui/src/components/hmi/GenericCanvas.tsx index 28506045..8f612948 100644 --- a/packages/ui/src/components/hmi/GenericCanvas.tsx +++ b/packages/ui/src/components/hmi/GenericCanvas.tsx @@ -13,6 +13,8 @@ interface GenericCanvasProps { isEditMode?: boolean; showControls?: boolean; className?: string; + selectedWidgetId?: string | null; + onSelectWidget?: (widgetId: string) => void; } const GenericCanvasComponent: React.FC = ({ @@ -21,8 +23,10 @@ const GenericCanvasComponent: React.FC = ({ isEditMode = false, showControls = true, className = '', + selectedWidgetId, + onSelectWidget }) => { - const { + const { loadedPages, loadPageLayout, addWidgetToPage, @@ -33,7 +37,7 @@ const GenericCanvasComponent: React.FC = ({ addPageContainer, removePageContainer, movePageContainer, - exportPageLayout, + exportPageLayout, importPageLayout, saveToApi, isLoading @@ -137,10 +141,10 @@ const GenericCanvasComponent: React.FC = ({ const handleSaveToApi = async () => { if (isSaving) return; - + setIsSaving(true); setSaveStatus('idle'); - + try { const success = await saveToApi(); if (success) { @@ -176,7 +180,7 @@ const GenericCanvasComponent: React.FC = ({ return (
- + {/* Header with Controls */} {showControls && (
@@ -185,7 +189,7 @@ const GenericCanvasComponent: React.FC = ({

{layout.name}

- +
{/* Edit Mode Controls */} @@ -211,13 +215,12 @@ const GenericCanvasComponent: React.FC = ({ onClick={handleSaveToApi} size="sm" disabled={isSaving} - className={`glass-button ${ - saveStatus === 'success' - ? 'bg-green-500 text-white' - : saveStatus === 'error' + className={`glass-button ${saveStatus === 'success' + ? 'bg-green-500 text-white' + : saveStatus === 'error' ? 'bg-red-500 text-white' : 'bg-blue-500 text-white' - }`} + }`} title="Save layout to server" > {isSaving ? ( @@ -282,14 +285,14 @@ const GenericCanvasComponent: React.FC = ({ )} {/* Container Canvas */} -
{layout.containers .sort((a, b) => (a.order || 0) - (b.order || 0)) .map((container, index, array) => ( - = ({ onSelect={handleSelectContainer} onAddWidget={handleAddWidget} isCompactMode={className.includes('p-0')} + selectedWidgetId={selectedWidgetId} + onSelectWidget={onSelectWidget} onRemoveWidget={async (widgetId) => { try { await removeWidgetFromPage(pageId, widgetId); diff --git a/packages/ui/src/components/hmi/LayoutContainer.tsx b/packages/ui/src/components/hmi/LayoutContainer.tsx index 09719846..0797aa79 100644 --- a/packages/ui/src/components/hmi/LayoutContainer.tsx +++ b/packages/ui/src/components/hmi/LayoutContainer.tsx @@ -26,6 +26,8 @@ interface LayoutContainerProps { onMoveContainer?: (containerId: string, direction: 'up' | 'down') => void; canMoveContainerUp?: boolean; canMoveContainerDown?: boolean; + selectedWidgetId?: string | null; + onSelectWidget?: (widgetId: string) => void; depth?: number; isCompactMode?: boolean; } @@ -46,6 +48,8 @@ const LayoutContainerComponent: React.FC = ({ onMoveContainer, canMoveContainerUp, canMoveContainerDown, + selectedWidgetId, + onSelectWidget, depth = 0, isCompactMode = false, }) => { @@ -105,6 +109,8 @@ const LayoutContainerComponent: React.FC = ({ widget={widget} isEditMode={isEditMode} pageId={pageId} + isSelected={selectedWidgetId === widget.id} + onSelect={() => onSelectWidget?.(widget.id)} canMoveUp={index > 0} canMoveDown={index < container.widgets.length - 1} onRemove={onRemoveWidget} @@ -160,6 +166,8 @@ const LayoutContainerComponent: React.FC = ({ onMoveContainer={onMoveContainer} canMoveContainerUp={canMoveContainerUp} canMoveContainerDown={canMoveContainerDown} + selectedWidgetId={selectedWidgetId} + onSelectWidget={onSelectWidget} depth={depth + 1} isCompactMode={isCompactMode} /> @@ -437,6 +445,8 @@ interface WidgetItemProps { canMoveDown: boolean; onRemove?: (widgetInstanceId: string) => void; onMove?: (widgetInstanceId: string, direction: 'up' | 'down' | 'left' | 'right') => void; + isSelected?: boolean; + onSelect?: () => void; } const WidgetItem: React.FC = ({ @@ -447,9 +457,11 @@ const WidgetItem: React.FC = ({ canMoveDown, onRemove, onMove, + isSelected, + onSelect }) => { const widgetDefinition = widgetRegistry.get(widget.widgetId); - const { updateWidgetProps } = useLayout(); + const { updateWidgetProps, renameWidget } = useLayout(); const [showSettingsModal, setShowSettingsModal] = useState(false); // pageId is now passed as a prop from the parent component @@ -489,7 +501,7 @@ const WidgetItem: React.FC = ({ }; return ( -
+
{/* Edit Mode Controls */} {isEditMode && ( <> @@ -531,22 +543,47 @@ const WidgetItem: React.FC = ({
- {/* Move Controls - Cross Pattern */} - onMove?.(widget.id, direction)} - canMoveUp={canMoveUp} - canMoveDown={canMoveDown} - /> + + + {/* Move Controls - Cross Pattern (Only show on hover or selection) */} +
+ onMove?.(widget.id, direction)} + canMoveUp={canMoveUp} + canMoveDown={canMoveDown} + /> +
)} - {/* Widget Content - Always 100% width */} -
+ {/* Widget Content - With selection wrapper */} +
{ + if (isEditMode) { + e.preventDefault(); // Prevent focus stealing if clicking background + e.stopPropagation(); + onSelect?.(); + } + }} + > ) => { try { @@ -559,35 +596,20 @@ const WidgetItem: React.FC = ({
{/* Generic Settings Modal */} - {widgetDefinition.metadata.configSchema && showSettingsModal && ( - setShowSettingsModal(false)} - widgetDefinition={widgetDefinition} - currentProps={widget.props || {}} - onSave={handleSettingsSave} - /> - )} -
+ { + widgetDefinition.metadata.configSchema && showSettingsModal && ( + setShowSettingsModal(false)} + widgetDefinition={widgetDefinition} + currentProps={widget.props || {}} + onSave={handleSettingsSave} + /> + ) + } +
); }; -// Conservative comparison function for React.memo -const areLayoutContainerPropsEqual = ( - prevProps: LayoutContainerProps, - nextProps: LayoutContainerProps -): boolean => { - // Conservative approach: only prevent re-render for truly identical props - // Since we're using deep cloning in LayoutContext, object references will change - // when the layout data actually changes, so we can be more conservative here - - return ( - prevProps.isEditMode === nextProps.isEditMode && - prevProps.selectedContainerId === nextProps.selectedContainerId && - prevProps.depth === nextProps.depth && - prevProps.container === nextProps.container // Reference equality check - ); -}; - -// Export memoized component with conservative comparison -export const LayoutContainer = React.memo(LayoutContainerComponent, areLayoutContainerPropsEqual); \ No newline at end of file +// Export without memoization to ensure reliable updates (Selection highlighting fix) +export const LayoutContainer = LayoutContainerComponent; \ No newline at end of file diff --git a/packages/ui/src/components/hmi/SelectionHandler.tsx b/packages/ui/src/components/hmi/SelectionHandler.tsx new file mode 100644 index 00000000..eb7888b5 --- /dev/null +++ b/packages/ui/src/components/hmi/SelectionHandler.tsx @@ -0,0 +1,58 @@ +import React, { useEffect } from 'react'; + +interface SelectionHandlerProps { + onMoveSelection: (direction: 'up' | 'down' | 'left' | 'right') => void; + onClearSelection: () => void; + enabled?: boolean; +} + +export const SelectionHandler: React.FC = ({ + onMoveSelection, + onClearSelection, + enabled = true +}) => { + useEffect(() => { + if (!enabled) return; + + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore if user is typing in a form field + const active = document.activeElement; + if (active && ( + active.tagName === 'INPUT' || + active.tagName === 'TEXTAREA' || + active.tagName === 'SELECT' || + (active as HTMLElement).isContentEditable + )) { + return; + } + + switch (e.key) { + case 'ArrowUp': + e.preventDefault(); + onMoveSelection('up'); + break; + case 'ArrowDown': + e.preventDefault(); + onMoveSelection('down'); + break; + case 'ArrowLeft': + e.preventDefault(); + onMoveSelection('left'); + break; + case 'ArrowRight': + e.preventDefault(); + onMoveSelection('right'); + break; + case 'Escape': + e.preventDefault(); + onClearSelection(); + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onMoveSelection, onClearSelection, enabled]); + + return null; // Headless component +}; diff --git a/packages/ui/src/components/hmi/WidgetPalette.tsx b/packages/ui/src/components/hmi/WidgetPalette.tsx index 8505b50f..be8e942f 100644 --- a/packages/ui/src/components/hmi/WidgetPalette.tsx +++ b/packages/ui/src/components/hmi/WidgetPalette.tsx @@ -13,10 +13,10 @@ interface WidgetPaletteProps { onWidgetAdd: (widgetId: string) => void; } -export const WidgetPalette: React.FC = ({ - isVisible, - onClose, - onWidgetAdd +export const WidgetPalette: React.FC = ({ + isVisible, + onClose, + onWidgetAdd }) => { const [searchQuery, setSearchQuery] = useState(''); const [selectedCategory, setSelectedCategory] = useState('all'); @@ -47,21 +47,34 @@ export const WidgetPalette: React.FC = ({ if (!isVisible) return null; - const widgets = searchQuery + const allWidgets = widgetRegistry.getAll(); + + // Use a Set to collect unique categories from all widgets + const dynamicCategories = Array.from(new Set([ + 'all', + 'control', + 'display', + 'chart', + 'system', + 'custom', + ...allWidgets.map(w => w.metadata.category) + ])); + + const widgets = searchQuery ? widgetRegistry.search(searchQuery) - : selectedCategory === 'all' - ? widgetRegistry.getAll() + : selectedCategory === 'all' + ? allWidgets : widgetRegistry.getByCategory(selectedCategory); - const categories = ['all', 'control', 'display', 'chart', 'system', 'custom']; + const categories = dynamicCategories; const modalContent = ( -
- e.stopPropagation()} style={{ zIndex: 100000 }} @@ -77,7 +90,7 @@ export const WidgetPalette: React.FC = ({ - + {/* Search */}
@@ -90,7 +103,7 @@ export const WidgetPalette: React.FC = ({ className="pl-8 glass-input" />
- + {/* Categories */}
{categories.map(category => ( @@ -130,7 +143,7 @@ export const WidgetPalette: React.FC = ({
- +
))} - + {widgets.length === 0 && (
No widgets found diff --git a/packages/ui/src/components/playground/PlaygroundHeader.tsx b/packages/ui/src/components/playground/PlaygroundHeader.tsx new file mode 100644 index 00000000..096c4a05 --- /dev/null +++ b/packages/ui/src/components/playground/PlaygroundHeader.tsx @@ -0,0 +1,183 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { T } from '@/i18n'; +import { Settings, FileJson, LayoutTemplate, Save, ClipboardList, Mail } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { LayoutTemplate as ILayoutTemplate } from '@/lib/layoutTemplates'; + +interface PlaygroundHeaderProps { + viewMode: 'design' | 'preview'; + setViewMode: (mode: 'design' | 'preview') => void; + handleExportHtml: () => void; + onSendTestEmail: () => void; + htmlSize?: number; + + // Template Menu + templates: ILayoutTemplate[]; + handleLoadTemplate: (template: ILayoutTemplate) => void; + onSaveTemplateClick: () => void; + + // Other Actions + onPasteJsonClick: () => void; + handleDumpJson: () => void; + handleLoadContext: () => void; + + // Edit Mode + isEditMode: boolean; + setIsEditMode: (mode: boolean) => void; +} + +export const PlaygroundHeader: React.FC = ({ + viewMode, + setViewMode, + handleExportHtml, + onSendTestEmail, + htmlSize, + templates, + handleLoadTemplate, + onSaveTemplateClick, + onPasteJsonClick, + handleDumpJson, + handleLoadContext, + isEditMode, + setIsEditMode +}) => { + return ( +
+
+

Canvas Playground

+

Experiment with widgets and layout

+
+
+ + + +
+ +
+ + + + + + Predefined Layouts + + {templates.filter(t => t.isPredefined).map((t, i) => ( + handleLoadTemplate(t)}> + {t.name} + + ))} + + + My Layouts + + {templates.filter(t => !t.isPredefined).length === 0 && ( +
No saved layouts
+ )} + {templates.filter(t => !t.isPredefined).map((t, i) => ( + handleLoadTemplate(t)}> + {t.name} + + ))} +
+ + + + Save Current as Template... + +
+
+ + + + + + + + {htmlSize !== undefined && ( +
102000 ? 'bg-red-100 text-red-800 border-red-200 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800' : + htmlSize > 80000 ? 'bg-yellow-100 text-yellow-800 border-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-300 dark:border-yellow-800' : + 'bg-slate-100 text-slate-600 border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700' + }`} title="Gmail clips HTML larger than 102KB"> + {(htmlSize / 1024).toFixed(1)}KB + / 102KB +
+ )} + + + + +
+
+ ); +}; diff --git a/packages/ui/src/components/playground/TemplateDialogs.tsx b/packages/ui/src/components/playground/TemplateDialogs.tsx new file mode 100644 index 00000000..ef9df917 --- /dev/null +++ b/packages/ui/src/components/playground/TemplateDialogs.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface TemplateDialogsProps { + // Save Dialog + isSaveDialogOpen: boolean; + setIsSaveDialogOpen: (open: boolean) => void; + newTemplateName: string; + setNewTemplateName: (name: string) => void; + handleSaveTemplate: () => void; + + // Paste JSON Dialog + isPasteDialogOpen: boolean; + setIsPasteDialogOpen: (open: boolean) => void; + pasteJsonContent: string; + setPasteJsonContent: (content: string) => void; + handlePasteJson: () => void; +} + +export const TemplateDialogs: React.FC = ({ + isSaveDialogOpen, + setIsSaveDialogOpen, + newTemplateName, + setNewTemplateName, + handleSaveTemplate, + isPasteDialogOpen, + setIsPasteDialogOpen, + pasteJsonContent, + setPasteJsonContent, + handlePasteJson +}) => { + return ( + <> + + + + Save Layout Template + + Save the current layout configuration to your local browser storage. + + +
+ + setNewTemplateName(e.target.value)} + placeholder="e.g., My Dashboard Layout" + onKeyDown={(e) => e.key === 'Enter' && handleSaveTemplate()} + /> +
+ + + + +
+
+ + + + + Import Layout JSON + + Paste a raw JSON layout string below to import it into the playground. + + +
+ +