From dbf0b0d3c28629758b9f8cd8a54205533527606d Mon Sep 17 00:00:00 2001 From: Babayaga Date: Wed, 4 Feb 2026 16:26:46 +0100 Subject: [PATCH] sw | layout | types | categories --- packages/ui/src/App.tsx | 2 + packages/ui/src/components/GlobalDragDrop.tsx | 11 +- packages/ui/src/components/ListLayout.tsx | 2 +- .../ui/src/components/MarkdownRenderer.tsx | 321 ++++-- packages/ui/src/components/PageActions.tsx | 71 +- .../ui/src/components/UserAvatarBlock.tsx | 26 +- .../ui/src/components/admin/AdminSidebar.tsx | 5 +- .../lazy-editors/MilkdownEditorInternal.tsx | 149 --- .../ui/src/components/types/RJSFTemplates.tsx | 269 +++++ .../ui/src/components/types/TypeBuilder.tsx | 598 +++++++++++ .../ui/src/components/types/TypeRenderer.tsx | 291 ++++++ .../src/components/types/TypesPlayground.tsx | 342 ++++++ packages/ui/src/components/types/db.ts | 179 ++++ .../components/types/randomDataGenerator.ts | 71 ++ .../components/widgets/CategoryManager.tsx | 348 +++++++ packages/ui/src/contexts/WS_Socket.tsx | 4 - packages/ui/src/hooks/useFeedData.ts | 2 - packages/ui/src/index.css | 6 + .../ui/src/integrations/supabase/types.ts | 290 ++++++ .../ui/src/integrations/supabase/types.ts.bak | 973 ------------------ packages/ui/src/lib/db.ts | 108 +- packages/ui/src/lib/image-tools-example.ts | 409 -------- packages/ui/src/pages/AdminPage.tsx | 114 +- packages/ui/src/pages/NewPost.tsx | 182 ++-- packages/ui/src/pages/UserPage.tsx | 40 +- packages/ui/src/services/modbusService.ts | 2 - packages/ui/src/sw.ts | 23 + 27 files changed, 3092 insertions(+), 1746 deletions(-) delete mode 100644 packages/ui/src/components/lazy-editors/MilkdownEditorInternal.tsx create mode 100644 packages/ui/src/components/types/RJSFTemplates.tsx create mode 100644 packages/ui/src/components/types/TypeBuilder.tsx create mode 100644 packages/ui/src/components/types/TypeRenderer.tsx create mode 100644 packages/ui/src/components/types/TypesPlayground.tsx create mode 100644 packages/ui/src/components/types/db.ts create mode 100644 packages/ui/src/components/types/randomDataGenerator.ts create mode 100644 packages/ui/src/components/widgets/CategoryManager.tsx delete mode 100644 packages/ui/src/integrations/supabase/types.ts.bak delete mode 100644 packages/ui/src/lib/image-tools-example.ts diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 4e934d5b..3a90aba4 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -47,6 +47,7 @@ const PlaygroundImages = React.lazy(() => import("./pages/PlaygroundImages")); const PlaygroundImageEditor = React.lazy(() => import("./pages/PlaygroundImageEditor")); const VideoGenPlayground = React.lazy(() => import("./pages/VideoGenPlayground")); const PlaygroundCanvas = React.lazy(() => import("./pages/PlaygroundCanvas")); +const TypesPlayground = React.lazy(() => import("./components/types/TypesPlayground")); import LogsPage from "./components/logging/LogsPage"; const queryClient = new QueryClient(); @@ -134,6 +135,7 @@ const AppWrapper = () => { Loading...}>} /> Loading...}>} /> Loading...}>} /> + Loading...}>} /> } /> {/* Logs */} diff --git a/packages/ui/src/components/GlobalDragDrop.tsx b/packages/ui/src/components/GlobalDragDrop.tsx index 925f542f..3f97bd62 100644 --- a/packages/ui/src/components/GlobalDragDrop.tsx +++ b/packages/ui/src/components/GlobalDragDrop.tsx @@ -4,6 +4,7 @@ import { set } from 'idb-keyval'; import { toast } from 'sonner'; import { Upload } from 'lucide-react'; import { T, translate } from '@/i18n'; +import { supabase } from '@/integrations/supabase/client'; const GlobalDragDrop = () => { const navigate = useNavigate(); @@ -86,8 +87,16 @@ const GlobalDragDrop = () => { toast.info(translate('Processing link...')); const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || 'http://localhost:3333'; + const { data: { session } } = await supabase.auth.getSession(); + const headers: Record = {}; + if (session?.access_token) { + headers['Authorization'] = `Bearer ${session.access_token}`; + } + try { - const response = await fetch(`${serverUrl}/api/serving/site-info?url=${encodeURIComponent(url)}`); + const response = await fetch(`${serverUrl}/api/serving/site-info?url=${encodeURIComponent(url)}`, { + headers + }); if (!response.ok) throw new Error('Failed to fetch site info'); const siteInfo = await response.json(); diff --git a/packages/ui/src/components/ListLayout.tsx b/packages/ui/src/components/ListLayout.tsx index 9535028e..9561ed66 100644 --- a/packages/ui/src/components/ListLayout.tsx +++ b/packages/ui/src/components/ListLayout.tsx @@ -117,7 +117,7 @@ export const ListLayout = ({ sortBy }); - console.log('posts', feedPosts); + // console.log('posts', feedPosts); const handleItemClick = (item: any) => { if (isMobile) { diff --git a/packages/ui/src/components/MarkdownRenderer.tsx b/packages/ui/src/components/MarkdownRenderer.tsx index 62a81f14..7660111f 100644 --- a/packages/ui/src/components/MarkdownRenderer.tsx +++ b/packages/ui/src/components/MarkdownRenderer.tsx @@ -1,8 +1,11 @@ -import React, { useMemo, useEffect, useRef } from 'react'; -import { marked } from 'marked'; -import DOMPurify from 'dompurify'; +import React, { useMemo, useEffect, useRef, useState, Suspense } from 'react'; +import ReactMarkdown from 'react-markdown'; import HashtagText from './HashtagText'; import Prism from 'prismjs'; +import ResponsiveImage from './ResponsiveImage'; +import { useAuth } from '@/hooks/useAuth'; +// Import type from Post page (assuming relative path from src/components to src/pages/Post/types.ts) +import { PostMediaItem } from '../pages/Post/types'; import 'prismjs/components/prism-typescript'; import 'prismjs/components/prism-javascript'; @@ -12,15 +15,77 @@ import 'prismjs/components/prism-css'; import 'prismjs/components/prism-markup'; import '../styles/prism-custom-theme.css'; +// Lazy load SmartLightbox to avoid circular deps or heavy bundle on initial load +const SmartLightbox = React.lazy(() => import('../pages/Post/components/SmartLightbox')); + interface MarkdownRendererProps { content: string; className?: string; } +// Helper function to format URL display text (ported from previous implementation) +const formatUrlDisplay = (url: string): string => { + try { + // Remove protocol + let displayUrl = url.replace(/^https?:\/\//, ''); + + // Remove www. if present + displayUrl = displayUrl.replace(/^www\./, ''); + + // Truncate if too long (keep domain + some path) + if (displayUrl.length > 40) { + const parts = displayUrl.split('/'); + const domain = parts[0]; + const path = parts.slice(1).join('/'); + + if (path.length > 20) { + displayUrl = `${domain}/${path.substring(0, 15)}...`; + } else { + displayUrl = `${domain}/${path}`; + } + } + + return displayUrl; + } catch { + return url; + } +}; + +// Helper for slugifying headings +const slugify = (text: string) => { + return text + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') + .replace(/[\s_-]+/g, '-') + .replace(/^-+|-+$/g, ''); +}; + const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRendererProps) => { const containerRef = React.useRef(null); + const { user } = useAuth(); - // Memoize content analysis + // Lightbox state + const [lightboxOpen, setLightboxOpen] = useState(false); + const [currentImageIndex, setCurrentImageIndex] = useState(0); + + // Extract all images from content for navigation + const allImages = useMemo(() => { + const images: { src: string, alt: string }[] = []; + const regex = /!\[([^\]]*)\]\(([^)]+)\)/g; + let match; + // We clone the regex to avoid stateful issues if reuse happens, though local var is fine + const localRegex = new RegExp(regex); + while ((match = localRegex.exec(content)) !== null) { + images.push({ + alt: match[1], + src: match[2] + }); + } + return images; + }, [content]); + + // Memoize content analysis (keep existing logic for simple hashtag views) const contentAnalysis = useMemo(() => { const hasHashtags = /#[a-zA-Z0-9_]+/.test(content); const hasMarkdownLinks = /\[.*?\]\(.*?\)/.test(content); @@ -33,8 +98,50 @@ const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRender }; }, [content]); + // Apply syntax highlighting after render + useEffect(() => { + if (containerRef.current) { + Prism.highlightAllUnder(containerRef.current); + } + }, [content]); + + const handleImageClick = (src: string) => { + const index = allImages.findIndex(img => img.src === src); + if (index !== -1) { + setCurrentImageIndex(index); + setLightboxOpen(true); + } + }; + + const handleNavigate = (direction: 'prev' | 'next') => { + if (direction === 'prev') { + setCurrentImageIndex(prev => (prev > 0 ? prev - 1 : prev)); + } else { + setCurrentImageIndex(prev => (prev < allImages.length - 1 ? prev + 1 : prev)); + } + }; + + // Mock MediaItem for SmartLightbox + const mockMediaItem = useMemo((): PostMediaItem | null => { + const selectedImage = allImages[currentImageIndex]; + if (!selectedImage) return null; + return { + id: 'md-' + btoa(selectedImage.src).substring(0, 10), // stable ID based on SRC + title: selectedImage.alt || 'Image', + description: '', + image_url: selectedImage.src, + thumbnail_url: selectedImage.src, + user_id: user?.id || 'unknown', + type: 'image', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + position: 0, + likes_count: 0, + post_id: null + }; + }, [currentImageIndex, allImages, user]); + // Only use HashtagText if content has hashtags but NO markdown syntax at all - // This preserves hashtag linking for simple text while allowing markdown to render properly if (contentAnalysis.hasHashtags && !contentAnalysis.hasMarkdownLinks && !contentAnalysis.hasMarkdownSyntax) { return (
@@ -43,100 +150,118 @@ const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRender ); } - // Memoize the expensive HTML processing - const htmlContent = useMemo(() => { - // Decode HTML entities first if present - const decodedContent = content - .replace(/ /g, ' ') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(/'/g, "'"); - - - // Configure marked options - marked.setOptions({ - breaks: true, - gfm: true, - }); - - // Custom renderer to add IDs to headings - const renderer = new marked.Renderer(); - renderer.heading = ({ text, depth }: { text: string; depth: number }) => { - const slug = text - .toLowerCase() - .trim() - .replace(/[^\w\s-]/g, '') - .replace(/[\s_-]+/g, '-') - .replace(/^-+|-+$/g, ''); - - return `${text}`; - }; - - marked.use({ renderer }); - - // Convert markdown to HTML - const rawHtml = marked.parse(decodedContent) as string; - - // Helper function to format URL display text - const formatUrlDisplay = (url: string): string => { - try { - // Remove protocol - let displayUrl = url.replace(/^https?:\/\//, ''); - - // Remove www. if present - displayUrl = displayUrl.replace(/^www\./, ''); - - // Truncate if too long (keep domain + some path) - if (displayUrl.length > 40) { - const parts = displayUrl.split('/'); - const domain = parts[0]; - const path = parts.slice(1).join('/'); - - if (path.length > 20) { - displayUrl = `${domain}/${path.substring(0, 15)}...`; - } else { - displayUrl = `${domain}/${path}`; - } - } - - return displayUrl; - } catch { - return url; - } - }; - - // Post-process to add target="_blank", styling, and format link text - const processedHtml = rawHtml.replace( - /]*)>([^<]*)<\/a>/g, - (match, href, attrs, text) => { - // If the link text is the same as the URL (auto-generated), format it nicely - const isAutoLink = text === href || text.replace(/^https?:\/\//, '') === href.replace(/^https?:\/\//, ''); - const displayText = isAutoLink ? formatUrlDisplay(href) : text; - - return `${displayText}`; - } - ); - - return DOMPurify.sanitize(processedHtml, { - ADD_ATTR: ['target', 'rel', 'class'] // Allow target, rel, and class attributes for links - }); - }, [content]); - - // Apply syntax highlighting after render - React.useEffect(() => { - if (containerRef.current) { - Prism.highlightAllUnder(containerRef.current); - } - }, [htmlContent]); - return ( -
+ <> +
+ { + // Basic implementation of ResponsiveImage + return ( + + src && handleImageClick(src)} + /> + {title && {title}} + + ); + }, + a: ({ node, href, children, ...props }) => { + if (!href) return {children}; + + // Logic to format display text if it matches the URL + let childText = ''; + if (typeof children === 'string') { + childText = children; + } else if (Array.isArray(children) && children.length > 0 && typeof children[0] === 'string') { + // Simple approximation for React children + childText = children[0]; + } + + const isAutoLink = childText === href || childText.replace(/^https?:\/\//, '') === href.replace(/^https?:\/\//, ''); + const displayContent = isAutoLink ? formatUrlDisplay(href) : children; + + return ( + + {displayContent} + + ); + }, + h1: ({ node, children, ...props }) => { + const text = String(children); + const id = slugify(text); + return

{children}

; + }, + h2: ({ node, children, ...props }) => { + const text = String(children); + const id = slugify(text); + return

{children}

; + }, + h3: ({ node, children, ...props }) => { + const text = String(children); + const id = slugify(text); + return

{children}

; + }, + h4: ({ node, children, ...props }) => { + const text = String(children); + const id = slugify(text); + return

{children}

; + }, + p: ({ node, children, ...props }) => { + // Check if the paragraph contains an image + // @ts-ignore + const hasImage = node?.children?.some((child: any) => + child.type === 'element' && child.tagName === 'img' + ); + + if (hasImage) { + return
{children}
; + } + return

{children}

; + }, + }} + > + {content} +
+
+ + {lightboxOpen && mockMediaItem && ( + + setLightboxOpen(false)} + mediaItem={mockMediaItem} + imageUrl={mockMediaItem.image_url} + imageTitle={mockMediaItem.title} + user={user} + isVideo={false} + // Dummy handlers for actions that aren't supported in this context + onPublish={async () => { }} + onNavigate={handleNavigate} + onOpenInWizard={() => { }} + currentIndex={currentImageIndex} + totalCount={allImages.length} + /> + + )} + ); }); diff --git a/packages/ui/src/components/PageActions.tsx b/packages/ui/src/components/PageActions.tsx index 07f72c03..db30d8c2 100644 --- a/packages/ui/src/components/PageActions.tsx +++ b/packages/ui/src/components/PageActions.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { supabase } from "@/integrations/supabase/client"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { Eye, EyeOff, Edit3, Trash2, GitMerge, Share2, Link as LinkIcon, FileText, Download, FilePlus } from "lucide-react"; +import { Eye, EyeOff, Edit3, Trash2, GitMerge, Share2, Link as LinkIcon, FileText, Download, FilePlus, FolderTree } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, @@ -14,6 +14,7 @@ import { import { T, translate } from "@/i18n"; import { PagePickerDialog } from "./widgets/PagePickerDialog"; import { PageCreationWizard } from "./widgets/PageCreationWizard"; +import { CategoryManager } from "./widgets/CategoryManager"; import { cn } from "@/lib/utils"; interface Page { @@ -25,6 +26,7 @@ interface Page { owner: string; slug: string; parent: string | null; + meta?: any; } interface PageActionsProps { @@ -51,10 +53,39 @@ export const PageActions = ({ const [loading, setLoading] = useState(false); const [showPagePicker, setShowPagePicker] = useState(false); const [showCreationWizard, setShowCreationWizard] = useState(false); + const [showCategoryManager, setShowCategoryManager] = useState(false); const [isGeneratingPdf, setIsGeneratingPdf] = useState(false); const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin; + const invalidatePageCache = async () => { + try { + const session = await supabase.auth.getSession(); + const token = session.data.session?.access_token; + if (!token) return; + + // Invalidate API and HTML routes + // API: /api/user-page/USER_ID/SLUG + // HTML: /user/USER_ID/pages/SLUG + const apiPath = `/api/user-page/${page.owner}/${page.slug}`; + const htmlPath = `/user/${page.owner}/pages/${page.slug}`; + + await fetch(`${baseUrl}/api/cache/invalidate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + paths: [apiPath, htmlPath] + }) + }); + console.log('Cache invalidated for:', page.slug); + } catch (e) { + console.error('Failed to invalidate cache:', e); + } + }; + const handleParentUpdate = async (parentId: string | null) => { if (loading) return; setLoading(true); @@ -69,6 +100,7 @@ export const PageActions = ({ onPageUpdate({ ...page, parent: parentId }); toast.success(translate('Page parent updated')); + invalidatePageCache(); } catch (error) { console.error('Error updating page parent:', error); toast.error(translate('Failed to update page parent')); @@ -77,6 +109,20 @@ export const PageActions = ({ } }; + const handleMetaUpdate = (newMeta: any) => { + // PageActions locally updates the page object. + // Ideally we should reload the page via UserPage but this gives instant feedback. + onPageUpdate({ ...page, meta: newMeta }); + // NOTE: If meta update persists to DB elsewhere (CategoryManager), it should probably handle invalidation too. + // But if CategoryManager is purely local until save, then we do nothing. + // Looking at CategoryManager usage, it likely saves. + // We might want to pass invalidatePageCache to it or call it here if we know it saved. + // Use timeout to debounce invalidation? For now assume CategoryManager handles its own saving/invalidation or we rely on page refresh. + // Actually, CategoryManager props has "onPageMetaUpdate", which updates local state. + // If CategoryManager saves to DB, it should invalidate. + // Let's stick to the handlers we control here. + }; + const handleToggleVisibility = async (e?: React.MouseEvent) => { e?.stopPropagation(); if (loading) return; @@ -92,6 +138,7 @@ export const PageActions = ({ onPageUpdate({ ...page, visible: !page.visible }); toast.success(translate(page.visible ? 'Page hidden' : 'Page made visible')); + invalidatePageCache(); } catch (error) { console.error('Error toggling visibility:', error); toast.error(translate('Failed to update page visibility')); @@ -115,6 +162,7 @@ export const PageActions = ({ onPageUpdate({ ...page, is_public: !page.is_public }); toast.success(translate(page.is_public ? 'Page made private' : 'Page made public')); + invalidatePageCache(); } catch (error) { console.error('Error toggling public status:', error); toast.error(translate('Failed to update page status')); @@ -450,6 +498,27 @@ draft: ${!page.visible} )} + {/* Categorization - New All-in-One Component */} + + + setShowCategoryManager(false)} + currentPageId={page.id} + currentPageMeta={page.meta} + onPageMetaUpdate={handleMetaUpdate} + /> + + {/* Legacy/Standard Parent Picker - Keeping relevant as "Page Hierarchy" vs "Category Taxonomy" */} + 0 ? `${id}-error` : undefined} + /> +
+ ); +}; + +// Custom FieldTemplate +export const FieldTemplate = (props: any) => { + const { + id, + classNames, + label, + help, + required, + description, + errors, + children, + schema, + } = props; + + // Format the label to be human-readable + const formattedLabel = label ? formatLabel(label) : label; + + return ( +
+ {formattedLabel && ( + + )} + {description && ( +
{description}
+ )} + {children} + {errors && errors.length > 0 && ( +
+ {errors} +
+ )} + {help &&

{help}

} +
+ ); +}; + +// Custom ObjectFieldTemplate with Grouping Support +export const ObjectFieldTemplate = (props: any) => { + const { properties, schema, uiSchema } = props; + + // Group properties based on uiSchema + const groups: Record = {}; + const ungrouped: any[] = []; + + properties.forEach((element: any) => { + // Skip if hidden widget + if (uiSchema?.[element.name]?.['ui:widget'] === 'hidden') { + return; + } + + const groupName = uiSchema?.[element.name]?.['ui:group']; + if (groupName) { + if (!groups[groupName]) { + groups[groupName] = []; + } + groups[groupName].push(element); + } else { + ungrouped.push(element); + } + }); + + const hasGroups = Object.keys(groups).length > 0; + + if (!hasGroups) { + return ( +
+ {props.description && ( +

{props.description}

+ )} +
+ {properties.map((element: any) => ( +
+ {element.content} +
+ ))} +
+
+ ); + } + + return ( +
+ {props.description && ( +

{props.description}

+ )} + + {/* Render Groups */} + {hasGroups && ( +
+ {Object.entries(groups).map(([groupName, elements]) => ( + +
+ {elements.map((element: any) => ( +
+ {element.content} +
+ ))} +
+
+ ))} +
+ )} + + {/* Render Ungrouped Fields */} + {ungrouped.length > 0 && ( +
+ {ungrouped.map((element: any) => ( +
+ {element.content} +
+ ))} +
+ )} +
+ ); +}; + +// Custom widgets +export const customWidgets: RegistryWidgetsType = { + TextWidget, + CheckboxWidget, +}; + +// Custom templates +export const customTemplates = { + FieldTemplate, + ObjectFieldTemplate, +}; diff --git a/packages/ui/src/components/types/TypeBuilder.tsx b/packages/ui/src/components/types/TypeBuilder.tsx new file mode 100644 index 00000000..982a0485 --- /dev/null +++ b/packages/ui/src/components/types/TypeBuilder.tsx @@ -0,0 +1,598 @@ +import React, { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { + DndContext, + DragOverlay, + useDraggable, + useDroppable, + DragEndEvent, + DragStartEvent, + useSensor, + useSensors, + PointerSensor, + pointerWithin, + rectIntersection, + MeasuringStrategy +} from '@dnd-kit/core'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; +import { GripVertical, Type as TypeIcon, Hash, ToggleLeft, Box, List, FileJson, Trash2 } from 'lucide-react'; + +export interface BuilderElement { + id: string; + type: string; + name: string; + description?: string; + title?: string; + jsonSchema?: any; + uiSchema?: any; +} + +const PALETTE_ITEMS: BuilderElement[] = [ + { id: 'p-string', type: 'string', name: 'String', title: 'New String' }, + { id: 'p-number', type: 'number', name: 'Number', title: 'New Number' }, + { id: 'p-boolean', type: 'boolean', name: 'Boolean', title: 'New Boolean' }, + { id: 'p-object', type: 'object', name: 'Object', title: 'New Object' }, + { id: 'p-array', type: 'array', name: 'Array', title: 'New Array' }, +]; + +const DraggablePaletteItem = ({ item }: { item: BuilderElement }) => { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: item.id, + data: item, + }); + + const Icon = getIconForType(item.type); + + return ( +
+ + + {item.name} +
+ ); +}; + +const CanvasElement = ({ + element, + isSelected, + onSelect, + onDelete, + onRemoveOnly +}: { + element: BuilderElement, + isSelected: boolean, + onSelect: () => void, + onDelete: () => void, + onRemoveOnly?: () => void +}) => { + const Icon = getIconForType(element.type); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + // Check if this is a primitive type or a custom type + const primitiveTypes = ['string', 'number', 'boolean', 'array', 'object']; + const isPrimitive = primitiveTypes.includes(element.type); + + return ( +
{ e.stopPropagation(); onSelect(); }} + className={`p-3 border rounded-md mb-2 cursor-pointer flex items-center justify-between group ${isSelected ? 'ring-2 ring-primary border-primary' : 'hover:border-primary/50 bg-background'}`} + > +
+ +
+ {element.title || element.name} + {element.type} +
+
+ + + + + e.stopPropagation()}> + + Remove Field? + +
+ {isPrimitive ? ( + <>This will remove the field "{element.title || element.name}" from the structure and delete it from the database. This action cannot be undone. + ) : ( + <> + Choose how to remove the field "{element.title || element.name}": +
    +
  • Remove Only: Unlink from this structure but keep the field type in the database
  • +
  • Delete in Database: Remove from structure and permanently delete the field type
  • +
+ + )} +
+
+
+ + Cancel + {!isPrimitive && onRemoveOnly && ( + { + e.stopPropagation(); + onRemoveOnly(); + setShowDeleteDialog(false); + }} + className="bg-secondary text-secondary-foreground hover:bg-secondary/90" + > + Remove Only + + )} + { + e.stopPropagation(); + onDelete(); + setShowDeleteDialog(false); + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete in Database + + +
+
+
+ ); +}; + +function getIconForType(type: string) { + switch (type) { + case 'string': return TypeIcon; + case 'number': return Hash; + case 'boolean': return ToggleLeft; + case 'object': return Box; + case 'array': return List; + default: return FileJson; + } +} + +export type BuilderMode = 'structure' | 'alias'; + +export interface BuilderOutput { + mode: BuilderMode; + elements: BuilderElement[]; + name: string; + description?: string; + fieldsToDelete?: string[]; // Field type IDs to delete from database +} + +import { TypeDefinition } from './db'; + +// Inner Component to consume DndContext +const TypeBuilderContent: React.FC<{ + mode: BuilderMode; + setMode: (m: BuilderMode) => void; + elements: BuilderElement[]; + setElements: React.Dispatch>; + selectedId: string | null; + setSelectedId: (id: string | null) => void; + onCancel: () => void; + onSave: (data: BuilderOutput) => void; + deleteElement: (id: string) => void; + removeElement: (id: string) => void; + updateSelectedElement: (updates: Partial) => void; + selectedElement?: BuilderElement; + availableTypes: TypeDefinition[]; + typeName: string; + setTypeName: (n: string) => void; + typeDescription: string; + setTypeDescription: (d: string) => void; + fieldsToDelete: string[]; +}> = ({ + mode, setMode, elements, setElements, selectedId, setSelectedId, + onCancel, onSave, deleteElement, removeElement, updateSelectedElement, selectedElement, + availableTypes, typeName, setTypeName, typeDescription, setTypeDescription, fieldsToDelete +}) => { + // This hook now works because it's inside DndContext provided by parent + const { setNodeRef: setCanvasRef, isOver } = useDroppable({ + id: 'canvas', + }); + + const customPaletteItems = React.useMemo(() => { + return availableTypes + .filter(t => t.kind !== 'field') // Exclude field types from palette + .map(t => ({ + id: `type-${t.id}`, + type: t.name, // Use name as type reference for now? Or ID? ID is better for strictness, Name for display. + // Actually, for the builder element, 'type' should probably be the KIND if primitive, or the ID if custom. + // But our current system uses 'string', 'number' etc. + // If we use 'Alias', the internal type is effectively the referenced type. + // Let's store the REFERENCED TYPE ID in a special field if it's custom? + // Or just use the Type Name as the 'type' for visual simplicity in this prototype. + name: t.name, + title: t.name, + description: t.description || undefined, + isCustom: true, + refId: t.id + } as BuilderElement & { isCustom?: boolean, refId?: string })); + }, [availableTypes]); + + return ( +
+ {/* Palette */} + + + Palette + + +
+
Primitives
+
+ {PALETTE_ITEMS.map(item => ( + + ))} +
+
+ + {customPaletteItems.length > 0 && mode !== 'alias' && ( +
+
Custom Types
+
+ {customPaletteItems.map(item => ( + + ))} +
+
+ )} +
+
+ + {/* Canvas */} + + +
+ Builder + { setMode(v as BuilderMode); setElements([]); }} className="w-[200px]"> + + Structure + Single Type + + +
+
+ + +
+
+
+ {isOver && ( +
+ )} +
+ {elements.length === 0 ? ( +
+ +

+ {mode === 'alias' + ? "Drag a primitive type here to define the base type" + : "Drag items here to build your structure" + } +

+
+ ) : ( +
+ {elements.map(el => ( + setSelectedId(el.id)} + onDelete={() => deleteElement(el.id)} + onRemoveOnly={() => removeElement(el.id)} + /> + ))} +
+ )} +
+
+ + + {/* Configuration Pane */} + + + Configuration + + + +
+ + setSelectedId(null)}>Type Settings + Field Settings + +
+ + +
+ + setTypeName(e.target.value)} + placeholder="e.g. MyCustomType" + className={!typeName.trim() ? "border-red-300" : ""} + /> +

The unique name for this new type.

+
+ {mode === 'alias' && ( +
+ + +

The primitive type to alias.

+
+ )} +
+ +