layouts
This commit is contained in:
parent
0d30aa1c87
commit
1be7374aae
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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">
|
||||
|
||||
142
packages/ui/src/components/widgets/ImageWidget.tsx
Normal file
142
packages/ui/src/components/widgets/ImageWidget.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
|
||||
@ -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: {}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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)
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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> => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 }; }
|
||||
}
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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] || {};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user