This commit is contained in:
lovebird 2026-02-17 13:31:46 +01:00
parent 0d30aa1c87
commit 1be7374aae
27 changed files with 786 additions and 647 deletions

View File

@ -111,35 +111,6 @@ const AppWrapper = () => {
{/* Admin Routes */}
<Route path="/admin/*" element={<React.Suspense fallback={<div>Loading...</div>}><AdminPage /></React.Suspense>} />
{/* Organization-scoped routes */}
<Route path="/org/:orgSlug" element={<Index />} />
<Route path="/org/:orgSlug/auth" element={<Auth />} />
<Route path="/org/:orgSlug/post/:id" element={<Post />} />
<Route path="/org/:orgSlug/video/:id" element={<Post />} />
<Route path="/org/:orgSlug/user/:userId" element={<UserProfile />} />
<Route path="/org/:orgSlug/user/:userId/collections" element={<UserCollections />} />
<Route path="/org/:orgSlug/user/:userId/pages/new" element={<NewPage />} />
<Route path="/org/:orgSlug/user/:username/pages/:slug" element={<React.Suspense fallback={<div>Loading...</div>}><UserPage /></React.Suspense>} />
<Route path="/org/:orgSlug/collections/new" element={<NewCollection />} />
<Route path="/org/:orgSlug/collections/:userId/:slug" element={<Collections />} />
<Route path="/org/:orgSlug/tags/:tag" element={<TagPage />} />
<Route path="/org/:orgSlug/categories/:slug" element={<Index />} />
<Route path="/org/:orgSlug/search" element={<SearchResults />} />
<Route path="/org/:orgSlug/wizard" element={<Wizard />} />
<Route path="/org/:orgSlug/new" element={<NewPost />} />
<Route path="/org/:orgSlug/version-map/:id" element={
<React.Suspense fallback={<div className="flex items-center justify-center h-screen">Loading map...</div>}>
<VersionMap />
</React.Suspense>
} />
<Route path="/org/:orgSlug/settings/providers" element={<React.Suspense fallback={<div>Loading...</div>}><ProviderSettings /></React.Suspense>} />
<Route path="/org/:orgSlug/playground/editor" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundEditor /></React.Suspense>} />
<Route path="/org/:orgSlug/playground/editor-llm" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundEditorLLM /></React.Suspense>} />
<Route path="/org/:orgSlug/playground/video-player" element={<React.Suspense fallback={<div>Loading...</div>}><VideoPlayerPlayground /></React.Suspense>} />
<Route path="/org/:orgSlug/video-feed" element={<React.Suspense fallback={<div>Loading...</div>}><VideoFeedPlayground /></React.Suspense>} />
<Route path="/org/:orgSlug/video-feed" element={<React.Suspense fallback={<div>Loading...</div>}><VideoFeedPlayground /></React.Suspense>} />
<Route path="/org/:orgSlug/video-feed/:id" element={<React.Suspense fallback={<div>Loading...</div>}><VideoFeedPlayground /></React.Suspense>} />
{/* Playground Routes */}
<Route path="/playground/images" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundImages /></React.Suspense>} />
<Route path="/playground/image-editor" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundImageEditor /></React.Suspense>} />
@ -148,7 +119,6 @@ const AppWrapper = () => {
<Route path="/types-editor" element={<React.Suspense fallback={<div>Loading...</div>}><TypesPlayground /></React.Suspense>} />
<Route path="/variables-editor" element={<React.Suspense fallback={<div>Loading...</div>}><VariablePlayground /></React.Suspense>} />
<Route path="/playground/i18n" element={<React.Suspense fallback={<div>Loading...</div>}><I18nPlayground /></React.Suspense>} />
<Route path="/org/:orgSlug/types-editor" element={<React.Suspense fallback={<div>Loading...</div>}><TypesPlayground /></React.Suspense>} />
<Route path="/test-cache/:id" element={<CacheTest />} />
{/* Logs */}

View File

@ -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<CreationWizardPopupProps> = ({
}) => {
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<CreationWizardPopupProps> = ({
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<CreationWizardPopupProps> = ({
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<CreationWizardPopupProps> = ({
// 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<CreationWizardPopupProps> = ({
<Zap className="h-4 w-4 mr-2" />
<T>Generate with AI</T>
</Button>
<Button variant="outline" onClick={handleNewPage}>
<Plus className="h-4 w-4 mr-2" />
<T>New Page</T>
</Button>
<Button variant="default" onClick={handlePageFromVoice}>
<Mic className="h-4 w-4 mr-2" />
<T>Voice + AI</T>

View File

@ -132,7 +132,6 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
}) => {
const { user } = useAuth();
const navigate = useNavigate();
const { orgSlug, isOrgContext } = useOrganization();
const { addLog, isLoggerVisible, setLoggerVisible } = useLog();
// Create logger instance for this component
@ -554,8 +553,8 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
postTitle: '',
postDescription: '',
prompt: prompt,
isOrgContext: isOrgContext,
orgSlug: orgSlug,
isOrgContext: false,
orgSlug: null,
onPublish: (url) => {
// No navigation needed for gallery publish, maybe just stay or go specific place?
// For now stay in wizard or close? Implementation plan said "Verify success message" so staying might be fine, but closing is better UX usually.
@ -1063,8 +1062,8 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
currentImageIndex,
postTitle,
prompt,
isOrgContext,
orgSlug,
isOrgContext: false,
orgSlug: null,
onPublish,
},
setIsPublishing
@ -1081,8 +1080,8 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
currentImageIndex,
postTitle,
prompt,
isOrgContext,
orgSlug,
isOrgContext: false,
orgSlug: null,
onPublish,
},
setIsPublishing
@ -1111,8 +1110,8 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
title: title || prompt, // Fallback to prompt if no title
description: description,
postId: currentEditingPostId,
isOrgContext,
orgSlug,
isOrgContext: false,
orgSlug: null,
collectionIds: []
});
// Navigate back success
@ -1181,8 +1180,8 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
blob,
title,
description,
isOrgContext,
orgSlug,
isOrgContext: false,
orgSlug: null,
collectionIds,
});
} else if (option === 'version' && parentId) {
@ -1192,8 +1191,8 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
title,
description,
parentId,
isOrgContext,
orgSlug,
isOrgContext: false,
orgSlug: null,
collectionIds,
});
} else if (option === 'version' && !parentId) {
@ -1208,8 +1207,8 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
blob,
title,
description,
isOrgContext,
orgSlug,
isOrgContext: false,
orgSlug: null,
collectionIds,
});
}
@ -1516,8 +1515,8 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
postDescription: postDescription,
settings: publishSettings, // Pass enriched settings
prompt: prompt,
isOrgContext: isOrgContext,
orgSlug: orgSlug,
isOrgContext: false,
orgSlug: null,
publishAll: mode === 'post',
editingPostId: currentEditingPostId,
onPublish: (url, postId) => {

View File

@ -100,8 +100,6 @@ export const ListLayout = ({
}: ListLayoutProps) => {
const navigate = useNavigate();
const isMobile = useIsMobile();
const { orgSlug, isOrgContext } = useOrganization();
// State for desktop selection
const [selectedId, setSelectedId] = useState<string | null>(null);
@ -113,8 +111,6 @@ export const ListLayout = ({
} = useFeedData({
source: navigationSource,
sourceId: navigationSourceId,
isOrgContext,
orgSlug,
sortBy,
categorySlugs
});

View File

@ -27,7 +27,7 @@ export const StreamInvalidator = () => {
queryClient.invalidateQueries({ queryKey });
} else {
// Optional: Log unhandled types if you want to verify what's missing
console.log(`[StreamInvalidator] Unknown event type for invalidation: ${event.type}`);
// console.log(`[StreamInvalidator] Unknown event type for invalidation: ${event.type}`);
}
}
});

View File

@ -3,14 +3,27 @@ import { VariableBuilder } from './VariableBuilder';
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
const STORAGE_KEY = 'variables-editor-playground';
const defaultOnLoad = async (): Promise<Record<string, any>> => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : {};
} catch { return {}; }
};
const defaultOnSave = async (data: Record<string, any>): Promise<void> => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
};
export interface VariablesEditorProps {
onLoad: () => Promise<Record<string, any>>;
onSave: (data: Record<string, any>) => Promise<void>;
onLoad?: () => Promise<Record<string, any>>;
onSave?: (data: Record<string, any>) => Promise<void>;
}
export const VariablesEditor: React.FC<VariablesEditorProps> = ({
onLoad,
onSave
onLoad = defaultOnLoad,
onSave = defaultOnSave
}) => {
const [variables, setVariables] = useState<Record<string, any>>({});
const [loading, setLoading] = useState(true);

View File

@ -23,14 +23,23 @@ interface CategoryManagerProps {
onPageMetaUpdate?: (newMeta: any, newCategories?: Category[]) => void;
filterByType?: string; // Filter categories by meta.type (e.g., 'layout', 'page', 'email')
defaultMetaType?: string; // Default type to set in meta when creating new categories
/** Picker mode: simplified view for selecting categories */
mode?: 'manage' | 'pick';
/** Called in pick mode when user confirms selection */
onPick?: (categoryIds: string[]) => void;
/** Pre-selected category IDs for pick mode */
selectedCategoryIds?: string[];
}
export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMeta, onPageMetaUpdate, filterByType, defaultMetaType }: CategoryManagerProps) => {
export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMeta, onPageMetaUpdate, filterByType, defaultMetaType, mode = 'manage', onPick, selectedCategoryIds: externalSelectedIds }: CategoryManagerProps) => {
const [actionLoading, setActionLoading] = useState(false);
// Selection state
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
// Picker mode state
const [pickerSelectedIds, setPickerSelectedIds] = useState<string[]>(externalSelectedIds || []);
// Editing/Creating state
const [editingCategory, setEditingCategory] = useState<Partial<Category> | null>(null);
const [isCreating, setIsCreating] = useState(false);
@ -240,7 +249,38 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
}
};
// Picker mode: toggle category in selection
const togglePickerCategory = (catId: string) => {
setPickerSelectedIds(prev =>
prev.includes(catId) ? prev.filter(id => id !== catId) : [...prev, catId]
);
};
// Render Logic
const renderPickerItem = (cat: Category, level: number = 0): React.ReactNode => {
const isPicked = pickerSelectedIds.includes(cat.id);
return (
<div key={cat.id}>
<div
className={cn(
"flex items-center gap-2 p-2 rounded hover:bg-muted/50 cursor-pointer",
isPicked && "bg-primary/10"
)}
style={{ marginLeft: `${level * 16}px` }}
onClick={() => togglePickerCategory(cat.id)}
>
<Checkbox checked={isPicked} onCheckedChange={() => togglePickerCategory(cat.id)} />
<FolderTree className="h-3 w-3 text-muted-foreground opacity-50" />
<span className={cn("text-sm", isPicked && "font-semibold text-primary")}>{cat.name}</span>
</div>
{cat.children
?.filter(childRel => !filterByType || (childRel.child as any).meta?.type === filterByType)
.map(childRel => renderPickerItem(childRel.child, level + 1))
}
</div>
);
};
const renderCategoryItem = (cat: Category, level: number = 0) => {
const isSelected = selectedCategoryId === cat.id;
const isLinked = linkedCategoryIds.includes(cat.id);
@ -251,7 +291,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
className={cn(
"flex items-center justify-between p-2 rounded hover:bg-muted/50 cursor-pointer group",
isSelected && "bg-muted",
isLinked && !isSelected && "bg-primary/5" // Slight highlight for linked items not selected
isLinked && !isSelected && "bg-primary/5"
)}
style={{ marginLeft: `${level * 16}px` }}
onClick={() => {
@ -288,6 +328,36 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
);
};
// Picker mode: simplified dialog
if (mode === 'pick') {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-lg max-h-[70vh] flex flex-col">
<DialogHeader>
<DialogTitle><T>Select Categories</T></DialogTitle>
<DialogDescription>
Choose one or more categories for your page.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto min-h-0 border rounded-md p-2">
{loading ? (
<div className="flex justify-center p-4"><Loader2 className="h-6 w-6 animate-spin" /></div>
) : (
<div className="space-y-1">
{categories.map(cat => renderPickerItem(cat))}
{categories.length === 0 && <div className="text-center text-sm text-muted-foreground py-8">No categories found.</div>}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={() => { onPick?.(pickerSelectedIds); onClose(); }}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-5xl h-[80vh] flex flex-col">

View File

@ -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<any>(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 (
<div className="space-y-2">
{!value ? (
<Button
type="button"
variant="outline"
className="w-full h-24 border-dashed flex flex-col gap-2 items-center justify-center text-muted-foreground hover:text-foreground hover:border-primary/50 hover:bg-muted/50 transition-colors"
onClick={() => setIsDialogOpen(true)}
disabled={disabled || readonly}
>
<ImageIcon className="h-8 w-8 opacity-50" />
<span className="text-xs">Select Image</span>
</Button>
) : (
<div className="relative group rounded-lg overflow-hidden border border-border bg-muted/30">
<div className="aspect-video w-full max-w-sm relative">
{loading ? (
<div className="absolute inset-0 flex items-center justify-center">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : picture ? (
<div className="w-full h-full relative">
<img
src={picture.image_url}
alt={picture.title || 'Selected image'}
className="w-full h-full object-contain bg-black/5"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 p-2 text-white text-xs truncate">
{picture.title}
</div>
</div>
) : (
<div className="flex items-center justify-center h-full text-xs text-muted-foreground">
Image ID: {value} (Loading or Not Found)
</div>
)}
</div>
{/* Actions Overlay */}
{!readonly && !disabled && (
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
type="button"
variant="secondary"
size="icon"
className="h-7 w-7 shadow-sm"
onClick={() => setIsDialogOpen(true)}
title="Change Image"
>
<RefreshCw className="h-3 w-3" />
</Button>
<Button
type="button"
variant="destructive"
size="icon"
className="h-7 w-7 shadow-sm"
onClick={handleClear}
title="Remove Image"
>
<X className="h-3 w-3" />
</Button>
</div>
)}
</div>
)}
<ImagePickerDialog
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
onSelectPicture={handleSelectPicture}
currentValue={value}
/>
</div>
);
};

View File

@ -69,7 +69,7 @@ export const StreamProvider: React.FC<StreamProviderProps> = ({ 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<StreamProviderProps> = ({ 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);

View File

@ -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: {}

View File

@ -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');

View File

@ -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<any[]> => {
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)
*/

View File

@ -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<string, any> = {
@ -81,7 +81,7 @@ export const generateUiSchemaForType = (typeId: string, types: TypeDefinition[],
visited.add(typeId);
const uiSchema: Record<string, any> = {
'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;
};

View File

@ -322,6 +322,7 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
const hydratePageLayout = useCallback((pageId: string, layout: PageLayout) => {
setLoadedPages(prev => new Map(prev).set(pageId, layout));
setIsLoading(false);
}, []);
const saveToApi = useCallback(async (): Promise<boolean> => {

View File

@ -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<string, any>): Promise<void> {
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<boolean> {
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);

View File

@ -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();

View File

@ -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 <T>(fn: () => Promise<T>) => {
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<LayoutInsert, 'owner_id'>) => {
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 }; }
}
});

View File

@ -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<string>("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<string[]>([]);
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
// Layout template
const [selectedLayoutId, setSelectedLayoutId] = useState<string>("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 = () => {
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Parent Page */}
{parentPage && (
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 rounded-md px-3 py-2">
<GitMerge className="h-4 w-4 shrink-0 text-orange-500" />
<span><T>Child of</T></span>
<span className="font-medium text-foreground">{parentPage.title}</span>
<Button
variant="ghost"
size="sm"
className="ml-auto h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
onClick={() => navigate(window.location.pathname, { replace: true })}
title={translate('Remove parent')}
>
<X className="h-3 w-3" />
</Button>
</div>
)}
{/* Title */}
<div className="space-y-2">
<Label htmlFor="title">
@ -203,24 +268,63 @@ const NewPage = () => {
</p>
</div>
{/* Type */}
{/* Category */}
<div className="space-y-2">
<Label htmlFor="type">
<T>Page Type</T>
<Label>
<T>Category</T>
</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger id="type">
<SelectValue />
<div className="flex items-center gap-2">
<Button
variant="outline"
className="w-full justify-start"
onClick={() => setShowCategoryPicker(true)}
>
<FolderTree className="mr-2 h-4 w-4 text-muted-foreground" />
{selectedCategoryIds.length > 0 ? (
<span className="truncate">
{selectedCategoryIds.map(id => allCatsFlat.find(c => c.id === id)?.name || id).join(', ')}
</span>
) : (
<span className="text-muted-foreground"><T>Select categories...</T></span>
)}
</Button>
{selectedCategoryIds.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedCategoryIds([])}
className="text-muted-foreground"
>
×
</Button>
)}
</div>
</div>
{/* Layout Template */}
<div className="space-y-2">
<Label htmlFor="layout">
<T>Layout Template</T>
</Label>
<Select value={selectedLayoutId} onValueChange={setSelectedLayoutId}>
<SelectTrigger id="layout">
<SelectValue placeholder={translate('Select a layout...')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="page"><T>Page</T></SelectItem>
<SelectItem value="article"><T>Article</T></SelectItem>
<SelectItem value="portfolio"><T>Portfolio</T></SelectItem>
<SelectItem value="blog"><T>Blog</T></SelectItem>
<SelectItem value="documentation"><T>Documentation</T></SelectItem>
<SelectItem value="custom"><T>Custom</T></SelectItem>
<SelectItem value="none"><T>Empty (blank page)</T></SelectItem>
{templates.map((t: any) => (
<SelectItem key={t.id} value={t.id}>
<div className="flex items-center gap-2">
<LayoutTemplate className="h-3 w-3" />
{t.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
<T>Start with a pre-built layout or blank page</T>
</p>
</div>
{/* Tags */}
@ -296,6 +400,15 @@ const NewPage = () => {
</CardContent>
</Card>
</div>
{/* Category Picker Dialog */}
<CategoryManager
isOpen={showCategoryPicker}
onClose={() => setShowCategoryPicker(false)}
mode="pick"
selectedCategoryIds={selectedCategoryIds}
onPick={setSelectedCategoryIds}
/>
</div>
);
};

View File

@ -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<string | null>(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

View File

@ -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<any>[] = [];
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'

View File

@ -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<UserPageTypeFieldsProps> = ({
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] || {};

View File

@ -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<HTMLDivElement>(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]}
/>
<PageCreationWizard
isOpen={showCreationWizard}
onClose={() => setShowCreationWizard(false)}
parentId={page.id}
/>
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
<AlertDialogContent>

View File

@ -41,7 +41,7 @@ const TextWidget = (props: WidgetProps) => {
<input
id={id}
type={type || 'text'}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
className="flex h-7 w-full rounded-md border border-input bg-background px-2 py-1 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-xs file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
readOnly={readonly}
disabled={disabled}
autoFocus={autofocus}
@ -142,23 +142,25 @@ export const FieldTemplate = (props: any) => {
const formattedLabel = label ? formatLabel(label) : label;
return (
<div className={`mb-4 ${classNames}`}>
{formattedLabel && (
<label
htmlFor={id}
className="block text-sm font-medium mb-1"
>
{formattedLabel}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
{children}
<div className={`w-full ${classNames}`}>
<div className="flex items-center gap-2">
{formattedLabel && (
<label
htmlFor={id}
className="text-[11px] font-medium text-muted-foreground shrink-0 whitespace-nowrap"
>
{formattedLabel}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
)}
<div className="flex-1 min-w-0">{children}</div>
</div>
{errors && errors.length > 0 && (
<div id={`${id}-error`} className="mt-1 text-sm text-red-600">
<div id={`${id}-error`} className="mt-1 text-xs text-red-600 pl-[88px]">
{errors}
</div>
)}
{help && <p className="mt-1 text-sm text-gray-500">{help}</p>}
</div>
);
};
@ -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

View File

@ -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 (
<div className="space-y-2">
<Select value={selectedKey} onValueChange={(val) => {
if (val === 'default') onChange(undefined);
else if (val === 'custom') onChange('');
else onChange(val);
}}>
<SelectTrigger className="h-8 text-xs w-full">
<SelectValue placeholder={`Select a widget (${primitiveType})`} />
</SelectTrigger>
<SelectContent>
<SelectItem value="default" className="text-xs">Default (Auto)</SelectItem>
<SelectItem value="custom" className="text-xs font-medium">Custom...</SelectItem>
{filteredOptions.map(group => (
<React.Fragment key={group.label}>
<div className="px-2 py-1.5 text-[0.625rem] font-semibold text-muted-foreground uppercase tracking-wider bg-muted/50 mt-1">
{group.label}
</div>
{group.options.map(opt => (
<SelectItem key={opt.value} value={opt.value} className="text-xs pl-4">
{opt.label}
</SelectItem>
))}
</React.Fragment>
))}
</SelectContent>
</Select>
{selectedKey === 'custom' && (
<Input
value={value || ''}
onChange={e => onChange(e.target.value)}
placeholder="Enter custom widget name..."
className="h-8 text-xs"
autoFocus
/>
)}
</div>
);
};
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<{
<CardHeader className="py-3 px-4 border-b flex flex-row justify-between items-center">
<div className="flex items-center gap-4">
<Tabs value={mode} onValueChange={(v) => { setMode(v as BuilderMode); setElements([]); }} className="w-[200px]">
<TabsList className="grid grid-cols-2 h-7">
<TabsList className="grid grid-cols2 h-7">
<TabsTrigger value="structure" className="text-xs">Structure</TabsTrigger>
<TabsTrigger value="alias" className="text-xs">Single Type</TabsTrigger>
</TabsList>
@ -454,16 +566,20 @@ const TypeBuilderContent: React.FC<{
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs">Widget</Label>
<Input
value={selectedElement.uiSchema?.['ui:widget'] || ''}
onChange={e => {
const val = e.target.value;
updateSelectedElement({
uiSchema: { ...selectedElement.uiSchema, 'ui:widget': val || undefined }
});
<Label className="text-xs">Widget</Label>
<WidgetPicker
fieldType={selectedElement.type}
types={types}
value={selectedElement.uiSchema?.['ui:widget']}
onChange={(val) => {
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"
/>
</div>
<div className="space-y-1">
@ -500,8 +616,9 @@ export const TypeBuilder = React.forwardRef<TypeBuilderRef, {
onSave: (data: BuilderOutput) => 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<BuilderMode>(initialData?.mode || 'structure');
const [elements, setElements] = useState<BuilderElement[]>(initialData?.elements || []);
const [selectedId, setSelectedId] = useState<string | null>(null);
@ -627,6 +744,7 @@ export const TypeBuilder = React.forwardRef<TypeBuilderRef, {
typeDescription={typeDescription}
setTypeDescription={setTypeDescription}
fieldsToDelete={fieldsToDelete}
types={types}
/>
{createPortal(

View File

@ -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<void>;
@ -37,123 +39,16 @@ export const TypeRenderer = forwardRef<TypeRendererRef, TypeRendererProps>(({
React.useEffect(() => {
if (!editedType) return;
// Mapping from our primitive type names to JSON Schema types
const primitiveToJsonSchema: Record<string, any> = {
'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<string>()): 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<string, any> = {};
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<string>()): 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<string, any> = {
'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 {

View File

@ -50,7 +50,8 @@ export const TypesEditor: React.FC<TypesEditorProps> = ({
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<TypesEditorProps> = ({
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<TypesEditorProps> = ({
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<TypesEditorProps> = ({
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<TypesEditorProps> = ({
setBuilderInitialData(undefined);
}}
availableTypes={types}
types={types}
initialData={builderInitialData || getBuilderData()}
/>
</div>

View File

@ -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 {