From 1be7374aae968be8e56ff984c6c0675031778d6b Mon Sep 17 00:00:00 2001 From: Babayaga Date: Tue, 17 Feb 2026 13:31:46 +0100 Subject: [PATCH] layouts --- packages/ui/src/App.tsx | 30 --- .../ui/src/components/CreationWizardPopup.tsx | 38 ++-- packages/ui/src/components/ImageWizard.tsx | 33 ++- packages/ui/src/components/ListLayout.tsx | 4 - .../ui/src/components/StreamInvalidator.tsx | 2 +- .../components/variables/VariablesEditor.tsx | 21 +- .../components/widgets/CategoryManager.tsx | 74 +++++- .../ui/src/components/widgets/ImageWidget.tsx | 142 ++++++++++++ packages/ui/src/contexts/StreamContext.tsx | 4 +- packages/ui/src/hooks/usePlaygroundLogic.tsx | 67 +++--- packages/ui/src/index.css | 2 +- packages/ui/src/lib/db.ts | 114 +--------- packages/ui/src/lib/schema-utils.ts | 23 +- .../ui/src/modules/layout/LayoutContext.tsx | 1 + .../ui/src/modules/layout/LayoutManager.ts | 67 +++--- .../ui/src/modules/layout/client-layouts.ts | 27 ++- packages/ui/src/modules/layout/useLayouts.ts | 124 ++-------- packages/ui/src/modules/pages/NewPage.tsx | 213 ++++++++++++++---- packages/ui/src/modules/pages/UserPage.tsx | 48 ++-- .../src/modules/pages/editor/UserPageEdit.tsx | 6 +- .../pages/editor/UserPageTypeFields.tsx | 11 +- .../pages/editor/ribbons/PageRibbonBar.tsx | 49 ++-- .../ui/src/modules/types/RJSFTemplates.tsx | 33 +-- packages/ui/src/modules/types/TypeBuilder.tsx | 146 ++++++++++-- .../ui/src/modules/types/TypeRenderer.tsx | 119 +--------- packages/ui/src/modules/types/TypesEditor.tsx | 11 +- packages/ui/src/sw.ts | 24 -- 27 files changed, 786 insertions(+), 647 deletions(-) create mode 100644 packages/ui/src/components/widgets/ImageWidget.tsx diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 2d6e38e0..1c5ad73a 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -111,35 +111,6 @@ const AppWrapper = () => { {/* Admin Routes */} Loading...}>} /> - {/* Organization-scoped routes */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - Loading...}>} /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - Loading map...}> - - - } /> - Loading...}>} /> - Loading...}>} /> - Loading...}>} /> - Loading...}>} /> - Loading...}>} /> - Loading...}>} /> - Loading...}>} /> - {/* Playground Routes */} Loading...}>} /> Loading...}>} /> @@ -148,7 +119,6 @@ const AppWrapper = () => { Loading...}>} /> Loading...}>} /> Loading...}>} /> - Loading...}>} /> } /> {/* Logs */} diff --git a/packages/ui/src/components/CreationWizardPopup.tsx b/packages/ui/src/components/CreationWizardPopup.tsx index 65559f06..0758f92c 100644 --- a/packages/ui/src/components/CreationWizardPopup.tsx +++ b/packages/ui/src/components/CreationWizardPopup.tsx @@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { T, translate } from '@/i18n'; -import { Image, FilePlus, Zap, Mic, Loader2, Upload, Video, Layers, BookPlus } from 'lucide-react'; +import { Image, FilePlus, Zap, Mic, Loader2, Upload, Video, Layers, BookPlus, Plus } from 'lucide-react'; import { useImageWizard } from '@/hooks/useImageWizard'; import { usePageGenerator } from '@/hooks/usePageGenerator'; import VoiceRecordingPopup from './VoiceRecordingPopup'; @@ -35,10 +35,8 @@ export const CreationWizardPopup: React.FC = ({ }) => { const navigate = useNavigate(); const { user } = useAuth(); - const { orgSlug, isOrgContext } = useOrganization(); const { clearWizardImage } = useWizardContext(); - const { openWizard } = useImageWizard(); - const { generatePageFromVoice, generatePageFromText, isGenerating, status, cancelGeneration } = usePageGenerator(); + const { generatePageFromText, isGenerating, status, cancelGeneration } = usePageGenerator(); const { triggerRefresh } = useMediaRefresh(); const [showVoicePopup, setShowVoicePopup] = useState(false); const [showTextGenerator, setShowTextGenerator] = useState(initialMode === 'page'); @@ -210,6 +208,16 @@ export const CreationWizardPopup: React.FC = ({ if (result) onClose(); }; + const handleNewPage = () => { + if (!user) { + toast.error(translate('Please sign in to create a page')); + return; + } + onClose(); + const url = `/user/${user.id}/pages/new`; + navigate(url); + }; + const handleImageUpload = async () => { // If we have preloaded images, upload them directly if (preloadedImages.length > 0) { @@ -220,15 +228,6 @@ export const CreationWizardPopup: React.FC = ({ setIsUploadingImage(true); try { let organizationId = null; - if (isOrgContext && orgSlug) { - const { data: org } = await supabase - .from('organizations') - .select('id') - .eq('slug', orgSlug) - .single(); - organizationId = org?.id || null; - } - for (const img of preloadedImages) { // Handle External Pages (Links) if (img.type === 'page-external') { @@ -399,15 +398,6 @@ export const CreationWizardPopup: React.FC = ({ // Get organization ID if in org context let organizationId = null; - if (isOrgContext && orgSlug) { - const { data: org } = await supabase - .from('organizations') - .select('id') - .eq('slug', orgSlug) - .single(); - organizationId = org?.id || null; - } - // Save picture metadata to database const imageTitle = file.name.replace(/\.[^/.]+$/, ''); // Remove extension const { error: dbError, data: dbData } = await supabase @@ -600,6 +590,10 @@ export const CreationWizardPopup: React.FC = ({ Generate with AI + + + + + + ); + } + return ( diff --git a/packages/ui/src/components/widgets/ImageWidget.tsx b/packages/ui/src/components/widgets/ImageWidget.tsx new file mode 100644 index 00000000..ffe248c7 --- /dev/null +++ b/packages/ui/src/components/widgets/ImageWidget.tsx @@ -0,0 +1,142 @@ +import React, { useState, useEffect } from 'react'; +import { WidgetProps } from '@rjsf/utils'; +import { Button } from '@/components/ui/button'; +import { Image as ImageIcon, X, RefreshCw } from 'lucide-react'; +import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog'; +import { supabase } from '@/integrations/supabase/client'; +import MediaCard from '@/components/MediaCard'; +import { MediaType } from '@/lib/mediaRegistry'; + +export const ImageWidget = (props: WidgetProps) => { + const { + id, + value, + readonly, + disabled, + onChange, + onBlur, + onFocus, + } = props; + + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [picture, setPicture] = useState(null); + const [loading, setLoading] = useState(false); + + // Fetch picture details if value exists + useEffect(() => { + const fetchPicture = async () => { + if (!value) { + setPicture(null); + return; + } + + // If we already have the picture and IDs match, don't re-fetch + if (picture && picture.id === value) return; + + setLoading(true); + try { + const { data, error } = await supabase + .from('pictures') + .select('*') + .eq('id', value) + .single(); + + if (error) throw error; + setPicture(data); + } catch (error) { + console.error("Error fetching picture:", error); + // Keep the value but maybe show error state? + } finally { + setLoading(false); + } + }; + + fetchPicture(); + }, [value]); + + const handleSelectPicture = (selectedPicture: any) => { + onChange(selectedPicture.id); + setPicture(selectedPicture); + setIsDialogOpen(false); + }; + + const handleClear = () => { + onChange(undefined); + setPicture(null); + }; + + return ( +
+ {!value ? ( + + ) : ( +
+
+ {loading ? ( +
+ +
+ ) : picture ? ( +
+ {picture.title +
+ {picture.title} +
+
+ ) : ( +
+ Image ID: {value} (Loading or Not Found) +
+ )} +
+ + {/* Actions Overlay */} + {!readonly && !disabled && ( +
+ + +
+ )} +
+ )} + + setIsDialogOpen(false)} + onSelectPicture={handleSelectPicture} + currentValue={value} + /> +
+ ); +}; diff --git a/packages/ui/src/contexts/StreamContext.tsx b/packages/ui/src/contexts/StreamContext.tsx index 12a39f42..24e914f4 100644 --- a/packages/ui/src/contexts/StreamContext.tsx +++ b/packages/ui/src/contexts/StreamContext.tsx @@ -69,7 +69,7 @@ export const StreamProvider: React.FC = ({ children, url }) // Listen for 'connected' event (handshake) eventSource.addEventListener('connected', (e) => { - const data = JSON.parse(e.data); + //const data = JSON.parse(e.data); }); // Listen for specific event types @@ -86,7 +86,7 @@ export const StreamProvider: React.FC = ({ children, url }) // 2. Update state (subject to React batching, useful for debug/simple UI) setLastEvent(eventData); - console.log('Stream event received', eventData); + // console.log('Stream event received', eventData); // logger.debug('Stream event received', eventData); } catch (err) { logger.error('Failed to parse stream event', err); diff --git a/packages/ui/src/hooks/usePlaygroundLogic.tsx b/packages/ui/src/hooks/usePlaygroundLogic.tsx index e9ea38b7..7dc07b83 100644 --- a/packages/ui/src/hooks/usePlaygroundLogic.tsx +++ b/packages/ui/src/hooks/usePlaygroundLogic.tsx @@ -25,7 +25,6 @@ export function usePlaygroundLogic() { // Layout Context const { loadedPages, - exportPageLayout, importPageLayout, loadPageLayout } = useLayout(); @@ -41,43 +40,11 @@ export function usePlaygroundLogic() { const [pasteJsonContent, setPasteJsonContent] = useState(''); const { loadWidgetBundle } = useWidgetLoader(); - const { getLayouts, createLayout, updateLayout, deleteLayout } = useLayouts(); + const { getLayouts, getLayout, createLayout, updateLayout, deleteLayout } = useLayouts(); const handleSave = async () => { try { - const { upsertLayout } = await import('@/lib/db'); - const layout = loadedPages.get(pageId); - if (!layout) return; - // Playground always uses 'layouts' table via upsertLayout? - // Or should it use updatePage if it was a page? - // The pageId is 'playground-canvas-demo'. - // layoutStorage treated it as a page if starting with 'page-'. - // But 'playground-canvas-demo' doesn't start with 'page-'. - // Wait, layoutStorage check: if (!isPage && !isLayout) return null. - // 'playground-canvas-demo' would fail that check in layoutStorage! - // But it seems it was working? - // Ah, maybe playground uses a specific ID that passes? - // Line 21: const pageId = 'playground-canvas-demo'; - // layoutStorage lines 22: if (!isPage && !isLayout) ... - // So layoutStorage would have rejected it! - // Unless I missed something. - - // However, for Playground, we probably just want to save to localStorage or ephemeral? - // But existing code called saveToApi(). - // If layoutStorage rejected it, it was a no-op? - // Let's implement a simple save if it's a layout/page, or just log if not supported. - // Actually, 'playground-canvas-demo' might be intended to be temporary. - // But lines 111 calls saveToApi(). - - // Let's assume we want to save it if it's a valid ID, or maybe just do nothing for demo? - // If we want to support saving, we need a valid ID. - // For now, let's just make it a no-op or log, matching potential previous behavior if it was failing. - // Or if users want to save context, they use "Save Template". - // The auto-save in restore/loadContext might be for persistence? - - console.log("Playground handleSave triggered"); - // Implement actual save if needed, for instance if we map it to a real layout. } catch (e) { console.error("Failed to save", e); @@ -205,11 +172,17 @@ export function usePlaygroundLogic() { }, [loadedPages, pageId]); // Removed viewMode dependency const handleDumpJson = async () => { + if (!currentLayout) { + toast.error("No layout loaded"); + return; + } try { - const json = await exportPageLayout(pageId); - setLayoutJson(JSON.stringify(JSON.parse(json), null, 2)); + // 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"); @@ -218,10 +191,19 @@ export function usePlaygroundLogic() { 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(template.layout_json); + const layoutJsonString = JSON.stringify(data.layout_json); await importPageLayout(pageId, layoutJsonString); - toast.success(`Loaded layout: ${template.name}`); + toast.success(`Loaded layout: ${data.name || template.name}`); setLayoutJson(null); } catch (e) { console.error("Failed to load layout", e); @@ -234,13 +216,16 @@ export function usePlaygroundLogic() { toast.error("Please enter a layout name"); return; } + if (!currentLayout) { + toast.error("No layout loaded to save"); + return; + } try { - const json = await exportPageLayout(pageId); - const layoutObject = JSON.parse(json); + const layoutObject = currentLayout; const { data, error } = await createLayout({ name: newTemplateName.trim(), - layout_json: layoutObject, + layout_json: layoutObject as any, type: 'canvas', visibility: 'private' as LayoutVisibility, meta: {} diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index a06194d4..770f846d 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -150,7 +150,7 @@ /* Body Background Variables */ --body-bg: hsl(var(--background)); --body-bg-gradient: var(--gradient-hero); - --body-bg-cover: url('/cover-dark.jpg'); + --body-bg-cover: url('/home-background.jpg'); --body-font-weight: 400; --body-letter-spacing: normal; --body-bg-image: url('/pattern-dark.png'); diff --git a/packages/ui/src/lib/db.ts b/packages/ui/src/lib/db.ts index 4cd8b06d..0524c25d 100644 --- a/packages/ui/src/lib/db.ts +++ b/packages/ui/src/lib/db.ts @@ -619,6 +619,7 @@ export const fetchUserPage = async (userId: string, slug: string, client?: Supab const supabase = client || defaultSupabase; const key = `user-page-${userId}-${slug}`; return fetchWithDeduplication(key, async () => { + console.log('Fetching user page for user:', userId, 'slug:', slug); const { data: sessionData } = await supabase.auth.getSession(); const token = sessionData.session?.access_token; @@ -800,6 +801,7 @@ export const fetchCategories = async (options?: { parentSlug?: string; includeCh if (!res.ok) throw new Error(`Failed to fetch categories: ${res.statusText}`); return await res.json(); }; + export const fetchTypes = async (options?: { kind?: string; visibility?: string }): Promise => { const { data: sessionData } = await defaultSupabase.auth.getSession(); const token = sessionData.session?.access_token; @@ -1044,118 +1046,6 @@ export const deleteGlossary = async (id: string) => { invalidateCache('i18n-glossaries'); return true; }; - -// Layout Operations - -export const fetchLayoutById = async (layoutId: string, client?: SupabaseClient) => { - const supabase = client || defaultSupabase; - - // Validate UUID to prevent DB error - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - if (!uuidRegex.test(layoutId)) { - console.warn(`⚠️ Invalid UUID format for layout fetch: ${layoutId}`); - return null; // Return null instead of crashing - } - - return fetchWithDeduplication(`layout-${layoutId}`, async () => { - const { data, error } = await supabase - .from('layouts') - .select('layout_json') - .eq('id', layoutId) - .single(); - - if (error) { - console.error(`❌ Failed to load layout from layouts table`, { id: layoutId, error }); - return null; - } - - if (data && data.layout_json) { - return data.layout_json; - } - - return null; - }); -}; - -export const upsertLayout = async ( - layout: { - id: string; - layout_json: any; - name?: string; - owner_id?: string; - type?: string; - visibility?: string; - meta?: any; - }, - client?: SupabaseClient -) => { - const supabase = client || defaultSupabase; - const timestamp = new Date().toISOString(); - - // Validate UUID - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - if (!uuidRegex.test(layout.id)) { - console.error(`❌ Cannot upsert layout with invalid UUID: ${layout.id}`); - return false; - } - - // Check if exists - const { data: existing } = await supabase - .from('layouts') - .select('id') - .eq('id', layout.id) - .maybeSingle(); - - if (existing) { - const { error } = await supabase - .from('layouts') - .update({ - layout_json: layout.layout_json, - updated_at: timestamp, - ...(layout.meta ? { meta: layout.meta } : {}) - }) - .eq('id', layout.id); - - if (error) { - console.error(`❌ Failed to update layouts table`, { id: layout.id, error }); - return false; - } - return true; - } else { - // Insert new layout - let ownerId = layout.owner_id; - if (!ownerId) { - const { data: { user } } = await supabase.auth.getUser(); - if (user) { - ownerId = user.id; - } else { - console.error(`❌ Cannot create new layout without owner_id`, { id: layout.id }); - return false; - } - } - - const { error } = await supabase - .from('layouts') - .insert({ - id: layout.id, - name: layout.name || `Layout ${layout.id}`, - owner_id: ownerId, - layout_json: layout.layout_json, - created_at: timestamp, - updated_at: timestamp, - visibility: (layout.visibility as Database["public"]["Enums"]["layout_visibility"]) || 'private', - type: layout.type || 'generic', - meta: layout.meta || {} - }); - - if (error) { - console.error(`❌ Failed to insert into layouts table`, { id: layout.id, error }); - return false; - } - return true; - } -}; - /** * Update user secrets in user_secrets table (settings column) */ diff --git a/packages/ui/src/lib/schema-utils.ts b/packages/ui/src/lib/schema-utils.ts index 66596058..e2c5a82a 100644 --- a/packages/ui/src/lib/schema-utils.ts +++ b/packages/ui/src/lib/schema-utils.ts @@ -1,4 +1,4 @@ -import { TypeDefinition } from '../components/types/db'; +import { TypeDefinition } from '@/modules/types/client-types'; // Mapping from our primitive type names to JSON Schema types export const primitiveToJsonSchema: Record = { @@ -81,7 +81,7 @@ export const generateUiSchemaForType = (typeId: string, types: TypeDefinition[], visited.add(typeId); const uiSchema: Record = { 'ui:options': { orderable: false }, - 'ui:classNames': 'grid grid-cols-1 md:grid-cols-2 gap-4' + 'ui:classNames': 'grid grid-cols-1 gap-y-1' }; type.structure_fields.forEach(field => { @@ -108,3 +108,22 @@ export const generateUiSchemaForType = (typeId: string, types: TypeDefinition[], return uiSchema; }; + +// Deep merge two UI schemas, prioritizing the source (override) but merging objects +export const deepMergeUiSchema = (target: any, source: any): any => { + if (typeof target !== 'object' || target === null || typeof source !== 'object' || source === null) { + return source === undefined ? target : source; + } + + const output = { ...target }; + + Object.keys(source).forEach(key => { + if (typeof source[key] === 'object' && source[key] !== null && key in target) { + output[key] = deepMergeUiSchema(target[key], source[key]); + } else { + output[key] = source[key]; + } + }); + + return output; +}; diff --git a/packages/ui/src/modules/layout/LayoutContext.tsx b/packages/ui/src/modules/layout/LayoutContext.tsx index e1052820..12441120 100644 --- a/packages/ui/src/modules/layout/LayoutContext.tsx +++ b/packages/ui/src/modules/layout/LayoutContext.tsx @@ -322,6 +322,7 @@ export const LayoutProvider: React.FC = ({ children }) => { const hydratePageLayout = useCallback((pageId: string, layout: PageLayout) => { setLoadedPages(prev => new Map(prev).set(pageId, layout)); + setIsLoading(false); }, []); const saveToApi = useCallback(async (): Promise => { diff --git a/packages/ui/src/modules/layout/LayoutManager.ts b/packages/ui/src/modules/layout/LayoutManager.ts index 522883b1..a9bb0b0e 100644 --- a/packages/ui/src/modules/layout/LayoutManager.ts +++ b/packages/ui/src/modules/layout/LayoutManager.ts @@ -1,4 +1,4 @@ -import { fetchLayoutById } from '@/lib/db'; +import { getLayout, createLayout, updateLayout } from './client-layouts'; import { widgetRegistry } from '@/lib/widgetRegistry'; import { updatePage } from '@/modules/pages/client-pages'; @@ -97,6 +97,7 @@ export class UnifiedLayoutManager { } try { + console.log('load root data', pageId) const isPage = pageId.startsWith('page-'); const isLayout = pageId.startsWith('layout-') || pageId.startsWith('tabs-'); @@ -123,14 +124,19 @@ export class UnifiedLayoutManager { } } else if (isLayout) { const layoutId = pageId.replace(/^(layout-|tabs-)/, ''); - const layoutJson = await fetchLayoutById(layoutId); + try { + const { data } = await getLayout(layoutId); + const layoutJson = data?.layout_json; - if (layoutJson) { - return { - pages: { [pageId]: layoutJson as PageLayout }, - version: this.VERSION, - lastUpdated: Date.now() - }; + if (layoutJson) { + return { + pages: { [pageId]: layoutJson as PageLayout }, + version: this.VERSION, + lastUpdated: Date.now() + }; + } + } catch (e) { + console.warn('Layout not found or failed to load:', layoutId); } } } catch (error) { @@ -145,7 +151,6 @@ export class UnifiedLayoutManager { }; } - // Save root data to storage (database-only, no localStorage) // Save root data to storage (database-only, no localStorage) static async saveRootData(data: RootLayoutData, pageId?: string, metadata?: Record): Promise { if (!pageId) return; @@ -157,26 +162,39 @@ export class UnifiedLayoutManager { if (isPage) { const actualId = pageId.replace('page-', ''); await updatePage(actualId, { - content: data, // Note: we might want to save just the layout part if possible, but existing logic saved RootLayoutData? - // Actually, UserPageEdit saves 'layout' directly to content. - // layoutStorage.saveToApiOnly saved 'data' directly. - // If 'data' is RootLayoutData, it has { pages: ... }. - // But 'content' usually expects PageLayout structure in newer usage? - // Let's stick to what layoutStorage did: it saved 'data'. + content: data, updated_at: new Date().toISOString(), ...(metadata || {}) }); } else if (isLayout) { - const { upsertLayout } = await import('@/lib/db'); const layoutJson = data.pages?.[pageId]; if (layoutJson) { const layoutId = pageId.replace(/^(layout-|tabs-)/, ''); - await upsertLayout({ + const layoutData = { id: layoutId, layout_json: layoutJson, meta: metadata, name: metadata?.title || `Layout ${pageId}` - }); + }; + + // Upsert logic + try { + let exists = false; + try { + await getLayout(layoutId); + exists = true; + } catch (e) { + // Assuming 404 or failure means we should try create + } + + if (exists) { + await updateLayout(layoutId, layoutData); + } else { + await createLayout(layoutData); + } + } catch (error) { + console.error('Failed to save layout via API', error); + } } } } catch (error) { @@ -184,23 +202,12 @@ export class UnifiedLayoutManager { } } + // Manual save to API static async saveToApi(data?: RootLayoutData): Promise { try { const dataToSave = data || await this.loadRootData(); dataToSave.lastUpdated = Date.now(); - // Deprecated: use saveRootData internal logic or just return false - // For backward compatibility, try to save if we have data? - // But we removed layoutStorage.saveToApiOnly. - // Let's reuse saveRootData if possible, but saveToApi didn't take pageId? - // It iterates? RootLayoutData has multiple pages? - // If dataToSave has pages, we might need to save each? - // layoutStorage.saveToApiOnly took 'data' and 'pageId'. - // But UnifiedLayoutManager.saveToApi called it with just 'dataToSave'. - // layoutStorage.saveToApiOnly checked 'pageId', which was undefined! - // So layoutStorage.saveToApiOnly would return false (line 74: if (!pageId) return false;). - // So UnifiedLayoutManager.saveToApi was BROKEN/NO-OP anyway? - // Let's just make it return false or true. return false; } catch (error) { console.error('Failed to save layouts to API:', error); diff --git a/packages/ui/src/modules/layout/client-layouts.ts b/packages/ui/src/modules/layout/client-layouts.ts index 217fa5b8..4bb1cc23 100644 --- a/packages/ui/src/modules/layout/client-layouts.ts +++ b/packages/ui/src/modules/layout/client-layouts.ts @@ -1,7 +1,6 @@ import { supabase as defaultSupabase } from "@/integrations/supabase/client"; -import { z } from "zod"; import { SupabaseClient } from "@supabase/supabase-js"; -import { Database } from "@/integrations/supabase/types"; + export const createLayout = async (layoutData: any, client?: SupabaseClient) => { const supabase = client || defaultSupabase; const { data: sessionData } = await supabase.auth.getSession(); @@ -25,6 +24,30 @@ export const createLayout = async (layoutData: any, client?: SupabaseClient) => return await res.json(); }; +export const getLayout = async (layoutId: string, client?: SupabaseClient) => { + const supabase = client || defaultSupabase; + const { data: sessionData } = await supabase.auth.getSession(); + const token = sessionData.session?.access_token; + + 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 getLayouts = async (filters?: { type?: string, visibility?: string, limit?: number, offset?: number }, client?: SupabaseClient) => { const supabase = client || defaultSupabase; const { data: sessionData } = await supabase.auth.getSession(); diff --git a/packages/ui/src/modules/layout/useLayouts.ts b/packages/ui/src/modules/layout/useLayouts.ts index 2e81f850..162564c9 100644 --- a/packages/ui/src/modules/layout/useLayouts.ts +++ b/packages/ui/src/modules/layout/useLayouts.ts @@ -1,5 +1,5 @@ -import { supabase } from '@/integrations/supabase/client'; 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']; @@ -19,108 +19,22 @@ export interface UseLayoutsReturn { deleteLayout: (id: string) => Promise<{ error: any }>; } -export function useLayouts(): UseLayoutsReturn { - const getLayouts = async (filters?: { - visibility?: LayoutVisibility; - type?: string; - limit?: number; - offset?: number; - }) => { - try { - let query = supabase - .from('layouts') - .select('*') - .order('updated_at', { ascending: false }); +const safe = async (fn: () => Promise) => { + try { + const res: any = await fn(); + return res?.data ? res : { data: res, error: null }; + } catch (error) { + return { data: null, error }; + } +}; - if (filters?.visibility) { - query = query.eq('visibility', filters.visibility); - } - - if (filters?.type) { - query = query.eq('type', filters.type); - } - - const limit = filters?.limit || 50; - const offset = filters?.offset || 0; - query = query.range(offset, offset + limit - 1); - - const { data, error } = await query; - return { data, error }; - } catch (error) { - return { data: null, error }; - } - }; - - const getLayout = async (id: string) => { - try { - const { data, error } = await supabase - .from('layouts') - .select('*') - .eq('id', id) - .single(); - - return { data, error }; - } catch (error) { - return { data: null, error }; - } - }; - - const createLayout = async (layout: Omit) => { - try { - // Get current user - const { data: { user } } = await supabase.auth.getUser(); - if (!user) { - return { data: null, error: new Error('Not authenticated') }; - } - - const { data, error } = await supabase - .from('layouts') - .insert({ - ...layout, - owner_id: user.id - }) - .select() - .single(); - - return { data, error }; - } catch (error) { - return { data: null, error }; - } - }; - - const updateLayout = async (id: string, updates: LayoutUpdate) => { - try { - const { data, error } = await supabase - .from('layouts') - .update(updates) - .eq('id', id) - .select() - .single(); - - return { data, error }; - } catch (error) { - return { data: null, error }; - } - }; - - const deleteLayout = async (id: string) => { - try { - const { error } = await supabase - .from('layouts') - .delete() - .eq('id', id); - - return { error }; - } catch (error) { - return { error }; - } - }; - - return { - getLayouts, - getLayout, - createLayout, - updateLayout, - deleteLayout - }; -} +export const useLayouts = (): UseLayoutsReturn => ({ + getLayouts: (filters) => safe(() => getLayouts(filters as any)), + getLayout: (id) => safe(() => getLayout(id)), + createLayout: (layout) => safe(() => createLayout(layout)), + updateLayout: (id, updates) => safe(() => updateLayout(id, updates)), + deleteLayout: async (id) => { + try { await deleteLayout(id); return { error: null }; } + catch (error) { return { error }; } + } +}); diff --git a/packages/ui/src/modules/pages/NewPage.tsx b/packages/ui/src/modules/pages/NewPage.tsx index dda1196f..249d9e03 100644 --- a/packages/ui/src/modules/pages/NewPage.tsx +++ b/packages/ui/src/modules/pages/NewPage.tsx @@ -1,14 +1,13 @@ -import { useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; -import { supabase } from "@/integrations/supabase/client"; +import { useState, useEffect } from "react"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { createPage, fetchPageById } from "@/modules/pages/client-pages"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; 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 { Switch } from "@/components/ui/switch"; -import { ArrowLeft, FileText } from "lucide-react"; +import { ArrowLeft, FileText, FolderTree, LayoutTemplate, X, GitMerge } from "lucide-react"; import { T, translate } from "@/i18n"; import { Card, @@ -24,21 +23,71 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { CategoryManager } from "@/components/widgets/CategoryManager"; +import { getLayouts } from "@/modules/layout/client-layouts"; +import { useQuery } from "@tanstack/react-query"; +import { fetchCategories, Category } from "@/lib/db"; const NewPage = () => { const navigate = useNavigate(); const { user } = useAuth(); const { orgSlug } = useParams<{ orgSlug?: string }>(); + const [searchParams] = useSearchParams(); + const parentPageId = searchParams.get('parent'); const [title, setTitle] = useState(""); const [slug, setSlug] = useState(""); - const [type, setType] = useState("page"); const [tags, setTags] = useState(""); const [isPublic, setIsPublic] = useState(true); const [visible, setVisible] = useState(true); const [autoGenerateSlug, setAutoGenerateSlug] = useState(true); const [creating, setCreating] = useState(false); + // Category picker + const [selectedCategoryIds, setSelectedCategoryIds] = useState([]); + const [showCategoryPicker, setShowCategoryPicker] = useState(false); + + // Layout template + const [selectedLayoutId, setSelectedLayoutId] = useState("none"); + + // Fetch parent page details if parentPageId is set + const { data: parentPage } = useQuery({ + queryKey: ['page', parentPageId], + queryFn: () => fetchPageById(parentPageId!), + enabled: !!parentPageId, + }); + + // Fetch layout templates + const { data: templates = [] } = useQuery({ + queryKey: ['layouts', 'canvas'], + queryFn: async () => { + const { data } = await getLayouts({ type: 'canvas' }); + return data || []; + } + }); + + // Fetch categories for display + const { data: allCategories = [] } = useQuery({ + queryKey: ['categories'], + queryFn: () => fetchCategories({ includeChildren: true }) + }); + + // Flatten categories for name lookup + const flattenCategories = (cats: Category[]): Category[] => { + let flat: Category[] = []; + cats.forEach(c => { + flat.push(c); + if (c.children) { + c.children.forEach(childRel => { + flat = [...flat, ...flattenCategories([childRel.child])]; + }); + } + }); + return flat; + }; + + const allCatsFlat = flattenCategories(allCategories); + const generateSlug = (text: string) => { return text .toLowerCase() @@ -74,43 +123,38 @@ const NewPage = () => { try { setCreating(true); - // Check if slug already exists for this user - const { data: existingPage } = await supabase - .from('pages') - .select('id') - .eq('owner', user.id) - .eq('slug', slug) - .maybeSingle(); - - if (existingPage) { - toast.error(translate('A page with this slug already exists')); - setCreating(false); - return; - } - // Parse tags const tagArray = tags .split(',') .map(tag => tag.trim()) .filter(tag => tag.length > 0); - // Create the page - const { data: newPage, error } = await supabase - .from('pages') - .insert([{ - title: title.trim(), - slug: slug.trim(), - owner: user.id, - type: type || null, - tags: tagArray.length > 0 ? tagArray : null, - is_public: isPublic, - visible: visible, - content: null, // Will be edited later - }]) - .select() - .single(); + // Build meta with categories + const meta: any = {}; + if (selectedCategoryIds.length > 0) { + meta.categoryIds = selectedCategoryIds; + } - if (error) throw error; + // Build content from selected layout template + let content: any = null; + if (selectedLayoutId && selectedLayoutId !== 'none') { + const template = templates.find((t: any) => t.id === selectedLayoutId); + if (template?.layout_json) { + content = template.layout_json; + } + } + + // Create via server API (ensures cache flush) + const newPage = await createPage({ + title: title.trim(), + slug: slug.trim(), + tags: tagArray.length > 0 ? tagArray : null, + is_public: isPublic, + visible: visible, + content, + meta: Object.keys(meta).length > 0 ? meta : null, + ...(parentPageId ? { parent: parentPageId } : {}), + }); toast.success(translate('Page created successfully!')); @@ -120,9 +164,12 @@ const NewPage = () => { : `/user/${user.id}/pages/${newPage.slug}`; navigate(pageUrl); - } catch (error) { + } catch (error: any) { console.error('Error creating page:', error); - toast.error(translate('Failed to create page')); + const msg = error?.message?.includes('slug already exists') + ? translate('A page with this slug already exists') + : translate('Failed to create page'); + toast.error(msg); } finally { setCreating(false); } @@ -162,6 +209,24 @@ const NewPage = () => { + {/* Parent Page */} + {parentPage && ( +
+ + Child of + {parentPage.title} + +
+ )} + {/* Title */}
- {/* Type */} + {/* Category */}
-
{/* Tags */} @@ -296,6 +400,15 @@ const NewPage = () => {
+ + {/* Category Picker Dialog */} + setShowCategoryPicker(false)} + mode="pick" + selectedCategoryIds={selectedCategoryIds} + onPick={setSelectedCategoryIds} + /> ); }; diff --git a/packages/ui/src/modules/pages/UserPage.tsx b/packages/ui/src/modules/pages/UserPage.tsx index 05134827..eef3725c 100644 --- a/packages/ui/src/modules/pages/UserPage.tsx +++ b/packages/ui/src/modules/pages/UserPage.tsx @@ -41,7 +41,7 @@ const UserPageContent = ({ userId: propUserId, slug: propSlug, embedded = false, const { userId: paramUserId, username: paramUsername, slug: paramSlug, orgSlug } = useParams<{ userId: string; username: string; slug: string; orgSlug?: string }>(); const navigate = useNavigate(); const { user: currentUser } = useAuth(); - const { getLoadedPageLayout, loadPageLayout } = useLayout(); + const { getLoadedPageLayout, loadPageLayout, hydratePageLayout } = useLayout(); const [resolvedUserId, setResolvedUserId] = useState(null); @@ -126,30 +126,34 @@ const UserPageContent = ({ userId: propUserId, slug: propSlug, embedded = false, useEffect(() => { if (!page) return; - const extract = async () => { - if (typeof page.content === 'string') { - const extracted = extractHeadings(page.content); - setHeadings(extracted); - } else { - const pageId = `page-${page.id}`; - // Try to get from context - let layout = getLoadedPageLayout(pageId); + if (typeof page.content === 'string') { + const extracted = extractHeadings(page.content); + setHeadings(extracted); + } else { + const pageId = `page-${page.id}`; + // Try to get from context + let layout = getLoadedPageLayout(pageId); - if (!layout) { - // If not loaded yet, check if we need to trigger load - // Only trigger load if we haven't already (prevents loops if load fails) - // Actually loadPageLayout checks if loaded, so safe to call. - - loadPageLayout(pageId, page.title).catch(e => console.error(e)); - // We don't await here because we want this effect to re-run when loadedPages updates - } else { - const extracted = extractHeadingsFromLayout(layout); - setHeadings(extracted); + if (!layout && page.content) { + // page.content could be a PageLayout directly or a RootLayoutData wrapper + const content = page.content as any; + let resolved = null; + if (content.id && content.containers) { + // Direct PageLayout + resolved = content; + } else if (content.pages && content.pages[pageId]) { + // RootLayoutData wrapper + resolved = content.pages[pageId]; } + if (resolved) { + hydratePageLayout(pageId, resolved); + } + // Effect will re-run when loadedPages updates + } else if (layout) { + const extracted = extractHeadingsFromLayout(layout); + setHeadings(extracted); } - }; - - extract(); + } }, [page, loadedPages]); // Re-run when page set or any layout loads // Hash Navigation Effect diff --git a/packages/ui/src/modules/pages/editor/UserPageEdit.tsx b/packages/ui/src/modules/pages/editor/UserPageEdit.tsx index fef614e4..e8faf599 100644 --- a/packages/ui/src/modules/pages/editor/UserPageEdit.tsx +++ b/packages/ui/src/modules/pages/editor/UserPageEdit.tsx @@ -21,6 +21,7 @@ import { UserPageDetails } from "./UserPageDetails"; import PageRibbonBar from "./ribbons/PageRibbonBar"; import { ConfirmationDialog } from "@/components/ConfirmationDialog"; import { deletePage, updatePage } from "../client-pages"; +import { updateLayout } from "@/modules/layout/client-layouts"; import { PagePickerDialog } from "../PagePickerDialog"; import { CategoryManager } from "@/components/widgets/CategoryManager"; @@ -281,9 +282,9 @@ const UserPageEditInner = ({ // --- Save handler --- const handleSave = async () => { + console.log("Saving..."); const toastId = toast.loading(translate("Saving...")); try { - const { upsertLayout } = await import('@/lib/db'); const promises: Promise[] = []; loadedPages.forEach((layout, id) => { @@ -303,8 +304,7 @@ const UserPageEditInner = ({ promises.push(updatePage(pId, updatePayload)); } else if (id.startsWith('layout-')) { const layoutId = id.replace('layout-', ''); - promises.push(upsertLayout({ - id: layoutId, + promises.push(updateLayout(layoutId, { layout_json: layout, name: layout.name || `Layout ${layoutId}`, type: 'component' diff --git a/packages/ui/src/modules/pages/editor/UserPageTypeFields.tsx b/packages/ui/src/modules/pages/editor/UserPageTypeFields.tsx index a612d58f..3a6c6e60 100644 --- a/packages/ui/src/modules/pages/editor/UserPageTypeFields.tsx +++ b/packages/ui/src/modules/pages/editor/UserPageTypeFields.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useMemo } from 'react'; import Form from '@rjsf/core'; import validator from '@rjsf/validator-ajv8'; import { TypeDefinition, fetchTypes } from '@/modules/types/client-types'; -import { generateSchemaForType, generateUiSchemaForType } from '@/lib/schema-utils'; +import { generateSchemaForType, generateUiSchemaForType, deepMergeUiSchema } from '@/lib/schema-utils'; import { customWidgets, customTemplates } from '@/modules/types/RJSFTemplates'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; @@ -133,10 +133,13 @@ export const UserPageTypeFields: React.FC = ({ const uiSchema = generateUiSchemaForType(type.id, allTypes); - // Merge with type's own UI schema + // Merge with type's own UI schema but FORCE our single-column grid layout + // Merge with type's own UI schema but FORCE our single-column grid layout + // Use deep merge to preserve field settings + const mergedUiSchema = deepMergeUiSchema(uiSchema, type.meta?.uiSchema || {}); const finalUiSchema = { - ...uiSchema, - ...(type.meta?.uiSchema || {}) + ...mergedUiSchema, + 'ui:classNames': 'grid grid-cols-1 gap-y-1' }; const typeData = formData[type.id] || {}; diff --git a/packages/ui/src/modules/pages/editor/ribbons/PageRibbonBar.tsx b/packages/ui/src/modules/pages/editor/ribbons/PageRibbonBar.tsx index b2e80f19..df529a0f 100644 --- a/packages/ui/src/modules/pages/editor/ribbons/PageRibbonBar.tsx +++ b/packages/ui/src/modules/pages/editor/ribbons/PageRibbonBar.tsx @@ -60,7 +60,6 @@ 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"; import * as PageCommands from "@/modules/layout/commands"; import { ActionProvider } from "@/actions/ActionProvider"; @@ -350,17 +349,11 @@ export const PageRibbonBar = ({ hasClipboard = false, onAIAssistant }: PageRibbonBarProps) => { - const { executeCommand, loadPageLayout, clearHistory } = useLayout(); + const { executeCommand, loadPageLayout, clearHistory, getLoadedPageLayout } = useLayout(); const navigate = useNavigate(); - const { orgSlug } = useParams(); - const handleOpenTypes = React.useCallback(() => { - if (orgSlug) { - navigate(`/org/${orgSlug}/types-editor`); - } else { - navigate('/types-editor'); - } - }, [navigate, orgSlug]); + navigate('/types-editor'); + }, [navigate]); const [activeTab, setActiveTab] = useState<'page' | 'widgets' | 'layouts' | 'view' | 'advanced'>('page'); const scrollContainerRef = useRef(null); @@ -394,7 +387,7 @@ export const PageRibbonBar = ({ const session = await supabase.auth.getSession(); const token = session.data.session?.access_token; // Note: This endpoint must match the iframe preview endpoint - const endpoint = `${baseUrl}${orgSlug ? `/org/${orgSlug}` : ''}/user/${page.owner}/pages/${page.slug}/email-preview`; + const endpoint = `${baseUrl}/user/${page.owner}/pages/${page.slug}/email-preview`; const res = await fetch(endpoint, { headers: token ? { 'Authorization': `Bearer ${token}` } : {} @@ -412,7 +405,7 @@ export const PageRibbonBar = ({ }; fetchSize(); } - }, [activeTab, page.owner, page.slug, baseUrl, orgSlug]); + }, [activeTab, page.owner, page.slug, baseUrl]); const handleParentUpdate = React.useCallback(async (newParentPage: any | null) => { if (loading) return; @@ -542,7 +535,22 @@ export const PageRibbonBar = ({ const handleDumpJson = React.useCallback(async () => { try { - const pageJson = JSON.stringify(page, null, 2); + const pageId = `page-${page.id}`; + const currentLayout = getLoadedPageLayout(pageId); + + // Clone page to include latest content + const pageDump = { ...page }; + + if (currentLayout) { + const rootContent = { + pages: { [pageId]: currentLayout }, + version: '1.0.0', + lastUpdated: Date.now() + }; + pageDump.content = rootContent as any; + } + + const pageJson = JSON.stringify(pageDump, null, 2); console.log('Page JSON:', pageJson); await navigator.clipboard.writeText(pageJson); toast.success("Page JSON dumped to console and clipboard"); @@ -550,7 +558,7 @@ export const PageRibbonBar = ({ console.error("Failed to dump JSON", e); toast.error("Failed to dump JSON"); } - }, [page]); + }, [page, getLoadedPageLayout]); // Sync actions with props React.useEffect(() => { @@ -747,7 +755,7 @@ export const PageRibbonBar = ({ { icon: FilePlus, label: "Add Child", - onClick: () => setShowCreationWizard(true), + onClick: () => navigate(`/user/${page.owner}/pages/new?parent=${page.id}`), iconColor: "text-green-600 dark:text-green-500" } ]} @@ -762,11 +770,7 @@ export const PageRibbonBar = ({ icon: Plus, label: "New", onClick: () => { - if (orgSlug) { - navigate(`/org/${orgSlug}/user/${page.owner}/pages/new`); - } else { - navigate(`/user/${page.owner}/pages/new`); - } + navigate(`/user/${page.owner}/pages/new?parent=${page.id}`); }, iconColor: "text-green-600 dark:text-green-400" }, @@ -1160,11 +1164,6 @@ export const PageRibbonBar = ({ forbiddenIds={[page.id]} /> - setShowCreationWizard(false)} - parentId={page.id} - /> diff --git a/packages/ui/src/modules/types/RJSFTemplates.tsx b/packages/ui/src/modules/types/RJSFTemplates.tsx index 2d189848..96241fc2 100644 --- a/packages/ui/src/modules/types/RJSFTemplates.tsx +++ b/packages/ui/src/modules/types/RJSFTemplates.tsx @@ -41,7 +41,7 @@ const TextWidget = (props: WidgetProps) => { { const formattedLabel = label ? formatLabel(label) : label; return ( -
- {formattedLabel && ( - - )} - {children} +
+
+ {formattedLabel && ( + + )} +
{children}
+
{errors && errors.length > 0 && ( -
+
{errors}
)} - {help &&

{help}

} +
); }; @@ -257,9 +259,12 @@ export const ObjectFieldTemplate = (props: any) => { }; // Custom widgets +import { ImageWidget } from '@/components/widgets/ImageWidget'; + export const customWidgets: RegistryWidgetsType = { TextWidget, CheckboxWidget, + ImageWidget, }; // Custom templates diff --git a/packages/ui/src/modules/types/TypeBuilder.tsx b/packages/ui/src/modules/types/TypeBuilder.tsx index 4e50c5d0..eeb3f51a 100644 --- a/packages/ui/src/modules/types/TypeBuilder.tsx +++ b/packages/ui/src/modules/types/TypeBuilder.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { TypeDefinition } from './client-types'; import { createPortal } from 'react-dom'; import { DndContext, @@ -179,6 +180,115 @@ function getIconForType(type: string) { } } +const WIDGET_OPTIONS = [ + { + label: 'Standard', options: [ + { value: 'text', label: 'Text Input', types: ['string', 'int', 'float', 'number'] }, + { value: 'textarea', label: 'Text Area', types: ['string'] }, + { value: 'password', label: 'Password', types: ['string'] }, + { value: 'email', label: 'Email', types: ['string'] }, + { value: 'uri', label: 'URL', types: ['string'] }, + { value: 'date', label: 'Date', types: ['string'] }, + { value: 'datetime', label: 'Date & Time', types: ['string'] }, + { value: 'time', label: 'Time', types: ['string'] }, + { value: 'color', label: 'Color Picker', types: ['string'] }, + { value: 'updown', label: 'Number (Up/Down)', types: ['int', 'float', 'number'] }, + { value: 'range', label: 'Range Slider', types: ['int', 'float', 'number'] }, + { value: 'radio', label: 'Radio Buttons', types: ['string', 'int', 'float', 'number', 'bool', 'boolean'] }, + { value: 'select', label: 'Dropdown (Select)', types: ['string', 'int', 'float', 'number', 'bool', 'boolean', 'enum'] }, + { value: 'checkbox', label: 'Checkbox', types: ['bool', 'boolean'] }, + { value: 'hidden', label: 'Hidden', types: ['string', 'int', 'float', 'number', 'bool', 'boolean'] }, + { value: 'file', label: 'File Upload', types: ['string'] }, + ] + }, + { + label: 'Custom', options: [ + { value: 'TextWidget', label: 'Custom Text (Styled)', types: ['string'] }, + { value: 'CheckboxWidget', label: 'Toggle Switch', types: ['bool', 'boolean'] }, + { value: 'ImageWidget', label: 'Image Picker', types: ['string'] }, + ] + } +]; + +// Helper to resolve the primitive type from a type name (handling aliases) +const resolvePrimitiveType = (typeName: string, types: TypeDefinition[]): string => { + // If it's a known primitive name, return it + const primitives = ['string', 'int', 'float', 'number', 'bool', 'boolean', 'object', 'array', 'enum']; + if (primitives.includes(typeName)) return typeName; + + // Find the type definition + const typeDef = types.find(t => t.name === typeName); + if (!typeDef) return typeName; // Fallback to name if not found + + // If it's a primitive kind, return its name (should be in the list above, but safe check) + if (typeDef.kind === 'primitive') return typeDef.name; + + // If it has a parent type (alias), recurse + if (typeDef.parent_type_id) { + const parentType = types.find(t => t.id === typeDef.parent_type_id); + if (parentType) { + return resolvePrimitiveType(parentType.name, types); + } + } + + return typeName; +}; + +const WidgetPicker = ({ value, onChange, fieldType, types }: { value: string | undefined, onChange: (val: string | undefined) => void, fieldType: string, types: TypeDefinition[] }) => { + // Resolve any aliases to the underlying primitive type + const primitiveType = resolvePrimitiveType(fieldType, types); + + // Filter options based on field type + const filteredOptions = WIDGET_OPTIONS.map(group => ({ + ...group, + options: group.options.filter(opt => !opt.types || opt.types.includes(primitiveType)) + })).filter(group => group.options.length > 0); + + // Treat empty string as "starting custom" so it renders the input + // Also consider it custom if the value is set but not in the filtered list (e.g. if type changed) + const isCustom = value !== undefined && (value === '' || !filteredOptions.some(g => g.options.some(o => o.value === value))); + const selectedKey = value === undefined ? 'default' : (isCustom ? 'custom' : value); + + return ( +
+ + {selectedKey === 'custom' && ( + onChange(e.target.value)} + placeholder="Enter custom widget name..." + className="h-8 text-xs" + autoFocus + /> + )} +
+ ); +}; + export type BuilderMode = 'structure' | 'alias'; export interface BuilderOutput { @@ -189,7 +299,7 @@ export interface BuilderOutput { fieldsToDelete?: string[]; // Field type IDs to delete from database } -import { TypeDefinition } from './db'; + // Inner Component to consume DndContext const TypeBuilderContent: React.FC<{ @@ -211,10 +321,12 @@ const TypeBuilderContent: React.FC<{ typeDescription: string; setTypeDescription: (d: string) => void; fieldsToDelete: string[]; + types: TypeDefinition[]; // Add types to props }> = ({ mode, setMode, elements, setElements, selectedId, setSelectedId, onCancel, onSave, deleteElement, removeElement, updateSelectedElement, selectedElement, - availableTypes, typeName, setTypeName, typeDescription, setTypeDescription, fieldsToDelete + availableTypes, typeName, setTypeName, typeDescription, setTypeDescription, fieldsToDelete, + types // Add types to destructuring }) => { // This hook now works because it's inside DndContext provided by parent const { setNodeRef: setCanvasRef, isOver } = useDroppable({ @@ -301,7 +413,7 @@ const TypeBuilderContent: React.FC<{
{ setMode(v as BuilderMode); setElements([]); }} className="w-[200px]"> - + Structure Single Type @@ -454,16 +566,20 @@ const TypeBuilderContent: React.FC<{
- { - const val = e.target.value; - updateSelectedElement({ - uiSchema: { ...selectedElement.uiSchema, 'ui:widget': val || undefined } - }); + + { + const newUiSchema = { ...selectedElement.uiSchema }; + if (val === undefined) { + delete newUiSchema['ui:widget']; + } else { + newUiSchema['ui:widget'] = val; + } + updateSelectedElement({ uiSchema: newUiSchema }); }} - placeholder="e.g. textarea, radio, select" - className="h-8 text-xs" />
@@ -500,8 +616,9 @@ export const TypeBuilder = React.forwardRef void, onCancel: () => void, availableTypes: TypeDefinition[], - initialData?: BuilderOutput -}>(({ onSave, onCancel, availableTypes, initialData }, ref) => { + initialData?: BuilderOutput, + types: TypeDefinition[]; // Add types to props +}>(({ onSave, onCancel, availableTypes, initialData, types }, ref) => { const [mode, setMode] = useState(initialData?.mode || 'structure'); const [elements, setElements] = useState(initialData?.elements || []); const [selectedId, setSelectedId] = useState(null); @@ -627,6 +744,7 @@ export const TypeBuilder = React.forwardRef {createPortal( diff --git a/packages/ui/src/modules/types/TypeRenderer.tsx b/packages/ui/src/modules/types/TypeRenderer.tsx index ab6c0204..86ff377e 100644 --- a/packages/ui/src/modules/types/TypeRenderer.tsx +++ b/packages/ui/src/modules/types/TypeRenderer.tsx @@ -8,8 +8,10 @@ import Form from '@rjsf/core'; import validator from '@rjsf/validator-ajv8'; import { customWidgets, customTemplates } from './RJSFTemplates'; import { generateRandomData } from './randomDataGenerator'; -import { TypeDefinition } from './db'; + import { toast } from 'sonner'; +import { generateSchemaForType, generateUiSchemaForType, deepMergeUiSchema } from '@/lib/schema-utils'; +import { TypeDefinition } from './client-types'; export interface TypeRendererRef { triggerSave: () => Promise; @@ -37,123 +39,16 @@ export const TypeRenderer = forwardRef(({ React.useEffect(() => { if (!editedType) return; - // Mapping from our primitive type names to JSON Schema types - const primitiveToJsonSchema: Record = { - 'string': { type: 'string' }, - 'int': { type: 'integer' }, - 'float': { type: 'number' }, - 'bool': { type: 'boolean' }, - 'array': { type: 'array', items: {} }, - 'object': { type: 'object' }, - 'enum': { type: 'string', enum: [] }, - 'flags': { type: 'array', items: { type: 'string' } }, - 'reference': { type: 'string' }, - 'alias': { type: 'string' } - }; - - // Recursive function to generate schema for any type (primitive or structure) - const generateSchemaForType = (typeId: string, visited = new Set()): any => { - // Prevent infinite recursion for circular references - if (visited.has(typeId)) { - return { type: 'object', description: 'Circular reference detected' }; - } - - const type = types.find(t => t.id === typeId); - if (!type) return { type: 'string' }; - - // If it's a primitive, return the JSON schema mapping - if (type.kind === 'primitive') { - return primitiveToJsonSchema[type.name] || { type: 'string' }; - } - - // If it's a structure, recursively build its schema - if (type.kind === 'structure' && type.structure_fields) { - visited.add(typeId); - const properties: Record = {}; - const required: string[] = []; - - type.structure_fields.forEach(field => { - const fieldType = types.find(t => t.id === field.field_type_id); - if (fieldType && fieldType.parent_type_id) { - const parentType = types.find(t => t.id === fieldType.parent_type_id); - if (parentType) { - // Recursively generate schema for the parent type - properties[field.field_name] = { - ...generateSchemaForType(parentType.id, new Set(visited)), - title: field.field_name, - ...(fieldType.description && { description: fieldType.description }) - }; - if (field.required) { - required.push(field.field_name); - } - } - } - }); - - return { - type: 'object', - properties, - ...(required.length > 0 && { required }) - }; - } - - // Fallback for other kinds - return { type: 'string' }; - }; - // For structures, generate JSON schema from structure_fields if (editedType.kind === 'structure' && editedType.structure_fields && editedType.structure_fields.length > 0) { - const generatedSchema = generateSchemaForType(editedType.id); + const generatedSchema = generateSchemaForType(editedType.id, types); setJsonSchemaString(JSON.stringify(generatedSchema, null, 2)); - // Recursive function to generate UI schema for a type - const generateUiSchemaForType = (typeId: string, visited = new Set()): any => { - if (visited.has(typeId)) return {}; - - const type = types.find(t => t.id === typeId); - if (!type || type.kind !== 'structure' || !type.structure_fields) { - return {}; - } - - visited.add(typeId); - const uiSchema: Record = { - 'ui:options': { orderable: false }, - 'ui:classNames': 'grid grid-cols-1 md:grid-cols-2 gap-4' - }; - - type.structure_fields.forEach(field => { - const fieldType = types.find(t => t.id === field.field_type_id); - const parentType = fieldType?.parent_type_id - ? types.find(t => t.id === fieldType.parent_type_id) - : null; - - const isNestedStructure = parentType?.kind === 'structure'; - const fieldUiSchema = fieldType?.meta?.uiSchema || {}; - - if (isNestedStructure && parentType) { - // Recursively generate UI schema for nested structure - const nestedUiSchema = generateUiSchemaForType(parentType.id, new Set(visited)); - uiSchema[field.field_name] = { - ...fieldUiSchema, - ...nestedUiSchema, - 'ui:classNames': 'col-span-full border-t pt-4 mt-2' - }; - } else { - uiSchema[field.field_name] = fieldUiSchema; - } - }); - - return uiSchema; - }; - // Generate UI schema recursively - const generatedUiSchema = generateUiSchemaForType(editedType.id); + const generatedUiSchema = generateUiSchemaForType(editedType.id, types); - // Merge with structure's own UI schema (structure-level overrides) - const finalUiSchema = { - ...generatedUiSchema, - ...(editedType.meta?.uiSchema || {}) - }; + // Merge with structure's own UI schema (structure-level overrides) using deep merge + const finalUiSchema = deepMergeUiSchema(generatedUiSchema, editedType.meta?.uiSchema || {}); setUiSchemaString(JSON.stringify(finalUiSchema, null, 2)); } else { diff --git a/packages/ui/src/modules/types/TypesEditor.tsx b/packages/ui/src/modules/types/TypesEditor.tsx index ea3f1278..6eb4a3f3 100644 --- a/packages/ui/src/modules/types/TypesEditor.tsx +++ b/packages/ui/src/modules/types/TypesEditor.tsx @@ -50,7 +50,8 @@ export const TypesEditor: React.FC = ({ name: field.field_name, type: fieldType?.name || 'string', title: field.field_name, - description: fieldType?.description || '' + description: fieldType?.description || '', + uiSchema: fieldType?.meta?.uiSchema || {} } as BuilderElement; }); } @@ -78,7 +79,8 @@ export const TypesEditor: React.FC = ({ name: field.field_name, type: fieldType?.name || 'string', title: field.field_name, - description: fieldType?.description || '' + description: fieldType?.description || '', + uiSchema: fieldType?.meta?.uiSchema || {} } as BuilderElement; }); } @@ -130,7 +132,7 @@ export const TypesEditor: React.FC = ({ kind: 'field' as const, description: el.description || `Field ${el.name}`, parent_type_id: parentType.id, - meta: {} + meta: { ...fieldType?.meta, uiSchema: el.uiSchema || {} } }; if (fieldType) { @@ -201,7 +203,7 @@ export const TypesEditor: React.FC = ({ kind: 'field', description: el.description || `Field ${el.name}`, parent_type_id: parentType.id, - meta: {} + meta: { uiSchema: el.uiSchema || {} } } as any); })); @@ -320,6 +322,7 @@ export const TypesEditor: React.FC = ({ setBuilderInitialData(undefined); }} availableTypes={types} + types={types} initialData={builderInitialData || getBuilderData()} />
diff --git a/packages/ui/src/sw.ts b/packages/ui/src/sw.ts index 0bb9bd34..663f9754 100644 --- a/packages/ui/src/sw.ts +++ b/packages/ui/src/sw.ts @@ -64,30 +64,6 @@ registerRoute( 'POST' ); -import { StaleWhileRevalidate } from 'workbox-strategies'; -import { CacheableResponsePlugin } from 'workbox-cacheable-response'; -import { ExpirationPlugin } from 'workbox-expiration'; - -// Cache API User Pages -/* -registerRoute( - ({ url }) => url.pathname.startsWith('/api/user-page/'), - new StaleWhileRevalidate({ - cacheName: 'api-user-pages', - plugins: [ - new CacheableResponsePlugin({ - statuses: [0, 200], - headers: { 'X-Cache': 'HIT' } // Only cache if server says it's good? No, cache everything 200 - }), - new ExpirationPlugin({ - maxEntries: 50, - maxAgeSeconds: 5 * 60, // 5 minutes - }), - ], - }) -); -*/ - // Navigation handler: Prefer network to get server injection, fallback to index.html const navigationHandler = async (params: any) => { try {