sw | layout | types | categories

This commit is contained in:
lovebird 2026-02-04 16:26:46 +01:00
parent e8af7b8dd0
commit dbf0b0d3c2
27 changed files with 3092 additions and 1746 deletions

View File

@ -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 = () => {
<Route path="/playground/image-editor" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundImageEditor /></React.Suspense>} />
<Route path="/playground/video-generator" element={<React.Suspense fallback={<div>Loading...</div>}><VideoGenPlayground /></React.Suspense>} />
<Route path="/playground/canvas" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundCanvas /></React.Suspense>} />
<Route path="/playground/types" element={<React.Suspense fallback={<div>Loading...</div>}><TypesPlayground /></React.Suspense>} />
<Route path="/test-cache/:id" element={<CacheTest />} />
{/* Logs */}

View File

@ -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<string, string> = {};
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();

View File

@ -117,7 +117,7 @@ export const ListLayout = ({
sortBy
});
console.log('posts', feedPosts);
// console.log('posts', feedPosts);
const handleItemClick = (item: any) => {
if (isMobile) {

View File

@ -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,74 +15,15 @@ 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;
}
const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRendererProps) => {
const containerRef = React.useRef<HTMLDivElement>(null);
// Memoize content analysis
const contentAnalysis = useMemo(() => {
const hasHashtags = /#[a-zA-Z0-9_]+/.test(content);
const hasMarkdownLinks = /\[.*?\]\(.*?\)/.test(content);
const hasMarkdownSyntax = /(\*\*|__|##?|###?|####?|#####?|######?|\*|\n\*|\n-|\n\d+\.)/.test(content);
return {
hasHashtags,
hasMarkdownLinks,
hasMarkdownSyntax
};
}, [content]);
// 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 (
<div className={`prose prose-sm max-w-none dark:prose-invert ${className}`}>
<HashtagText>{content}</HashtagText>
</div>
);
}
// Memoize the expensive HTML processing
const htmlContent = useMemo(() => {
// Decode HTML entities first if present
const decodedContent = content
.replace(/&#x20;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/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 `<h${depth} id="${slug}">${text}</h${depth}>`;
};
marked.use({ renderer });
// Convert markdown to HTML
const rawHtml = marked.parse(decodedContent) as string;
// Helper function to format URL display text
// Helper function to format URL display text (ported from previous implementation)
const formatUrlDisplay = (url: string): string => {
try {
// Remove protocol
@ -107,36 +51,217 @@ const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRender
}
};
// Post-process to add target="_blank", styling, and format link text
const processedHtml = rawHtml.replace(
/<a href="([^"]*)"([^>]*)>([^<]*)<\/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;
// Helper for slugifying headings
const slugify = (text: string) => {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
};
return `<a href="${href}"${attrs} target="_blank" rel="noopener noreferrer" class="text-primary hover:text-primary/80 underline hover:no-underline transition-colors">${displayText}</a>`;
}
);
const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRendererProps) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const { user } = useAuth();
return DOMPurify.sanitize(processedHtml, {
ADD_ATTR: ['target', 'rel', 'class'] // Allow target, rel, and class attributes for links
// 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);
const hasMarkdownSyntax = /(\*\*|__|##?|###?|####?|#####?|######?|\*|\n\*|\n-|\n\d+\.)/.test(content);
return {
hasHashtags,
hasMarkdownLinks,
hasMarkdownSyntax
};
}, [content]);
// Apply syntax highlighting after render
React.useEffect(() => {
useEffect(() => {
if (containerRef.current) {
Prism.highlightAllUnder(containerRef.current);
}
}, [htmlContent]);
}, [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
if (contentAnalysis.hasHashtags && !contentAnalysis.hasMarkdownLinks && !contentAnalysis.hasMarkdownSyntax) {
return (
<div className={`prose prose-sm max-w-none dark:prose-invert ${className}`}>
<HashtagText>{content}</HashtagText>
</div>
);
}
return (
<>
<div
ref={containerRef}
className={`prose prose-sm max-w-none dark:prose-invert ${className}`}
dangerouslySetInnerHTML={{ __html: htmlContent }}
>
<ReactMarkdown
components={{
img: ({ node, src, alt, title, ...props }) => {
// Basic implementation of ResponsiveImage
return (
<span className="block my-4">
<ResponsiveImage
src={src || ''}
alt={alt || ''}
title={title} // Pass title down if ResponsiveImage supports it or wrap it
className={`cursor-pointer ${props.className || ''}`}
imgClassName="cursor-pointer hover:opacity-95 transition-opacity"
// Default generous sizes for blog post content
sizes="(max-width: 768px) 100vw, 800px"
loading="lazy"
onClick={() => src && handleImageClick(src)}
/>
{title && <span className="block text-center text-sm text-muted-foreground mt-2 italic">{title}</span>}
</span>
);
},
a: ({ node, href, children, ...props }) => {
if (!href) return <span {...props}>{children}</span>;
// 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 (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:text-primary/80 underline hover:no-underline transition-colors"
{...props}
>
{displayContent}
</a>
);
},
h1: ({ node, children, ...props }) => {
const text = String(children);
const id = slugify(text);
return <h1 id={id} {...props}>{children}</h1>;
},
h2: ({ node, children, ...props }) => {
const text = String(children);
const id = slugify(text);
return <h2 id={id} {...props}>{children}</h2>;
},
h3: ({ node, children, ...props }) => {
const text = String(children);
const id = slugify(text);
return <h3 id={id} {...props}>{children}</h3>;
},
h4: ({ node, children, ...props }) => {
const text = String(children);
const id = slugify(text);
return <h4 id={id} {...props}>{children}</h4>;
},
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 <div {...props}>{children}</div>;
}
return <p {...props}>{children}</p>;
},
}}
>
{content}
</ReactMarkdown>
</div>
{lightboxOpen && mockMediaItem && (
<Suspense fallback={null}>
<SmartLightbox
isOpen={lightboxOpen}
onClose={() => 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}
/>
</Suspense>
)}
</>
);
});

View File

@ -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}
</Button>
)}
{/* Categorization - New All-in-One Component */}
<Button
variant="outline"
size="sm"
onClick={(e) => { e.stopPropagation(); setShowCategoryManager(true); }}
className={cn("text-muted-foreground hover:text-foreground", page.meta?.categoryId && "text-primary border-primary")}
title={translate("Manage Categories")}
>
<FolderTree className="h-4 w-4" />
{showLabels && <span className="ml-2 hidden md:inline"><T>Categories</T></span>}
</Button>
<CategoryManager
isOpen={showCategoryManager}
onClose={() => setShowCategoryManager(false)}
currentPageId={page.id}
currentPageMeta={page.meta}
onPageMetaUpdate={handleMetaUpdate}
/>
{/* Legacy/Standard Parent Picker - Keeping relevant as "Page Hierarchy" vs "Category Taxonomy" */}
<Button
variant="outline"
size="sm"

View File

@ -37,6 +37,30 @@ const UserAvatarBlock: React.FC<UserAvatarBlockProps> = ({
// Prefer prop displayName if truthy (e.g. override), else usage context
const effectiveDisplayName = displayName || profile?.display_name || `User ${userId.slice(0, 8)}`;
const getOptimizedAvatarUrl = (url?: string | null) => {
if (!url) return undefined;
// If it's already a blob/data URI or relative asset, leave it alone (though avatars are usually remote)
if (url.startsWith('data:') || url.startsWith('blob:')) return url;
// Construct optimized URL
// We use the render endpoint: /api/images/render?url=...&width=128&format=webp
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL;
if (!serverUrl) return url;
try {
const optimized = new URL(`${serverUrl}/api/images/render`);
optimized.searchParams.set('url', url);
optimized.searchParams.set('width', '128');
optimized.searchParams.set('format', 'webp');
return optimized.toString();
} catch (e) {
console.warn('Failed to construct optimized avatar URL:', e);
return url;
}
};
const optimizedAvatarUrl = getOptimizedAvatarUrl(effectiveAvatarUrl);
useEffect(() => {
// If we don't have the profile in context, ask for it
if (!profile) {
@ -70,7 +94,7 @@ const UserAvatarBlock: React.FC<UserAvatarBlockProps> = ({
return (
<div className="flex items-center space-x-2" onClick={handleClick}>
<Avatar className={`${className} bg-background hover:scale-105 transition-transform cursor-pointer`}>
<AvatarImage src={effectiveAvatarUrl || undefined} alt={effectiveDisplayName || "Avatar"} className="object-cover" />
<AvatarImage src={optimizedAvatarUrl} alt={effectiveDisplayName || "Avatar"} className="object-cover" />
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-indigo-600 text-white">
<UserIcon className="h-1/2 w-1/2" />
</AvatarFallback>

View File

@ -10,9 +10,9 @@ import {
useSidebar
} from "@/components/ui/sidebar";
import { T, translate } from "@/i18n";
import { LayoutDashboard, Users } from "lucide-react";
import { LayoutDashboard, Users, Server } from "lucide-react";
export type AdminActiveSection = 'dashboard' | 'users';
export type AdminActiveSection = 'dashboard' | 'users' | 'server';
export const AdminSidebar = ({
activeSection,
@ -27,6 +27,7 @@ export const AdminSidebar = ({
const menuItems = [
{ id: 'dashboard' as AdminActiveSection, label: translate('Dashboard'), icon: LayoutDashboard },
{ id: 'users' as AdminActiveSection, label: translate('Users'), icon: Users },
{ id: 'server' as AdminActiveSection, label: translate('Server'), icon: Server },
];
return (

View File

@ -1,149 +0,0 @@
import React, { useEffect, useRef } from 'react';
import { CrepeBuilder } from '@milkdown/crepe/builder';
import { blockEdit } from '@milkdown/crepe/feature/block-edit';
import { toolbar } from '@milkdown/crepe/feature/toolbar';
import '@milkdown/crepe/theme/common/prosemirror.css';
import '@milkdown/crepe/theme/common/reset.css';
import '@milkdown/crepe/theme/common/block-edit.css';
import '@milkdown/crepe/theme/common/toolbar.css';
import '@milkdown/crepe/theme/common/image-block.css';
import '@milkdown/crepe/theme/common/style.css';
import '@milkdown/crepe/theme/frame.css';
interface MilkdownEditorInternalProps {
value: string;
onChange: (value: string) => void;
className?: string;
}
const MilkdownEditorInternal: React.FC<MilkdownEditorInternalProps> = ({
value,
onChange,
className
}) => {
const ref = useRef<HTMLDivElement>(null);
const builderRef = useRef<CrepeBuilder | null>(null);
const editorReady = useRef(false);
const debounceTimer = useRef<NodeJS.Timeout>();
const isUpdatingFromOutside = useRef(false);
const lastEmittedValue = useRef<string>(value);
// Memoize onChange to prevent effect re-runs
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
// Effect for editor setup and mutation observer
useEffect(() => {
if (!ref.current) return;
editorReady.current = false;
// Create builder with basic config
const builder = new CrepeBuilder({
root: ref.current,
defaultValue: value || '',
});
// Add features manually
builder
.addFeature(blockEdit)
.addFeature(toolbar);
// Create the editor and wait for it to be ready
builder.create().then(() => {
editorReady.current = true;
builderRef.current = builder;
lastEmittedValue.current = value; // Initialize with current value
});
// Listen to updates via mutation observer
const handleUpdate = () => {
if (isUpdatingFromOutside.current || !editorReady.current) return;
clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => {
if (!builderRef.current || !editorReady.current) return;
try {
const markdown = builderRef.current.getMarkdown();
// Only call onChange if content actually changed
if (markdown !== lastEmittedValue.current) {
lastEmittedValue.current = markdown;
onChangeRef.current(markdown);
}
} catch (error) {
console.error('Error getting markdown:', error);
}
}, 300);
};
const observer = new MutationObserver(handleUpdate);
observer.observe(ref.current, {
childList: true,
subtree: true,
characterData: true,
});
return () => {
observer.disconnect();
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
editorReady.current = false;
builderRef.current?.destroy();
builderRef.current = null;
};
}, []); // Only run once on mount
// Effect for handling external value changes
useEffect(() => {
if (!builderRef.current || !editorReady.current) return;
try {
const editorContent = builderRef.current.getMarkdown();
// Only update if value is different from both editor and last emitted value
if (value !== editorContent && value !== lastEmittedValue.current) {
isUpdatingFromOutside.current = true;
// Note: Builder doesn't have setMarkdown, need to use internal methods or recreate if drastic
// But for Crepe, we might need a way to set content.
// Just updating the ref is not enough if the editor doesn't update.
// However, the original code didn't actually implementing 'setMarkdown' logic for Crepe properly
// beyond setting isUpdatingFromOutside to true...
// Wait, looking at original code:
// "Note: Builder doesn't have setMarkdown, need to recreate or use editor directly"
// It seems the original code FAILED to actually update the editor content visually?
// Ah, lines 186-206 in original file.
// It sets isUpdatingFromOutside = true, updates lastEmittedValue.
// But it DOES NOT actually update the editor content. This looks like a bug in the original code?
// Or maybe Crepe updates automatically? No.
// Let's keep strict parity with original code first.
lastEmittedValue.current = value;
// Allow DOM to update
setTimeout(() => {
isUpdatingFromOutside.current = false;
}, 150);
}
} catch (error) {
console.error('Error syncing external value:', error);
}
}, [value]);
return (
<div
ref={ref}
className={`milkdown-editor p-3 min-h-[120px] prose prose-sm max-w-none dark:prose-invert ${className || ''}`}
/>
);
};
export default MilkdownEditorInternal;

View File

@ -0,0 +1,269 @@
import React from 'react';
import type { WidgetProps, RegistryWidgetsType } from '@rjsf/utils';
import CollapsibleSection from '../../components/CollapsibleSection';
// Utility function to convert camelCase to Title Case
const formatLabel = (str: string): string => {
// Split on capital letters and join with spaces
return str
.replace(/([A-Z])/g, ' $1') // Add space before capital letters
.replace(/^./, (char) => char.toUpperCase()) // Capitalize first letter
.trim();
};
// Custom TextWidget using Tailwind/shadcn styling
const TextWidget = (props: WidgetProps) => {
const {
id,
required,
readonly,
disabled,
type,
label,
value,
onChange,
onBlur,
onFocus,
autofocus,
options,
schema,
rawErrors = [],
} = props;
const _onChange = ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) =>
onChange(value === '' ? options.emptyValue : value);
const _onBlur = ({ target: { value } }: React.FocusEvent<HTMLInputElement>) =>
onBlur(id, value);
const _onFocus = ({ target: { value } }: React.FocusEvent<HTMLInputElement>) =>
onFocus(id, value);
return (
<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"
readOnly={readonly}
disabled={disabled}
autoFocus={autofocus}
value={value || ''}
required={required}
onChange={_onChange}
onBlur={_onBlur}
onFocus={_onFocus}
aria-describedby={rawErrors.length > 0 ? `${id}-error` : undefined}
/>
);
};
// Custom CheckboxWidget for toggle switches
const CheckboxWidget = (props: WidgetProps) => {
const {
id,
value,
disabled,
readonly,
label,
onChange,
onBlur,
onFocus,
autofocus,
rawErrors = [],
} = props;
// Handle both boolean and string "true"/"false" values
const isChecked = value === true || value === 'true';
const _onChange = ({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
// Emit boolean to satisfy z.boolean() schema
onChange(checked);
};
const _onBlur = () => onBlur(id, value);
const _onFocus = () => onFocus(id, value);
return (
<div className="flex items-center">
<button
type="button"
role="switch"
aria-checked={isChecked}
disabled={disabled || readonly}
onClick={() => {
if (!disabled && !readonly) {
onChange(!isChecked);
}
}}
onBlur={_onBlur}
onFocus={_onFocus}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
disabled:cursor-not-allowed disabled:opacity-50
${isChecked ? 'bg-indigo-600' : 'bg-gray-200'}
`}
>
<span
className={`
inline-block h-4 w-4 transform rounded-full transition-transform
${isChecked ? 'translate-x-6' : 'translate-x-1'}
`}
/>
</button>
<input
type="checkbox"
id={id}
checked={isChecked}
onChange={_onChange}
disabled={disabled}
readOnly={readonly}
autoFocus={autofocus}
className="sr-only"
aria-describedby={rawErrors.length > 0 ? `${id}-error` : undefined}
/>
</div>
);
};
// 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 (
<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>
)}
{description && (
<div className="text-sm text-gray-500 mb-2">{description}</div>
)}
{children}
{errors && errors.length > 0 && (
<div id={`${id}-error`} className="mt-1 text-sm text-red-600">
{errors}
</div>
)}
{help && <p className="mt-1 text-sm text-gray-500">{help}</p>}
</div>
);
};
// Custom ObjectFieldTemplate with Grouping Support
export const ObjectFieldTemplate = (props: any) => {
const { properties, schema, uiSchema } = props;
// Group properties based on uiSchema
const groups: Record<string, any[]> = {};
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 (
<div className="space-y-4">
{props.description && (
<p className="text-sm text-gray-600 mb-4">{props.description}</p>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{properties.map((element: any) => (
<div key={element.name} className="w-full">
{element.content}
</div>
))}
</div>
</div>
);
}
return (
<div className="space-y-6">
{props.description && (
<p className="text-sm text-gray-600 mb-4">{props.description}</p>
)}
{/* Render Groups */}
{hasGroups && (
<div className="flex flex-col gap-4">
{Object.entries(groups).map(([groupName, elements]) => (
<CollapsibleSection
key={groupName}
title={groupName}
initiallyOpen={true}
minimal={true}
storageKey={`competitor-search-group-${groupName}`}
className=""
headerClassName="flex justify-between items-center p-3 cursor-pointer hover:/50 transition-colors rounded-t-lg"
contentClassName="p-3"
>
<div className="grid grid-cols-1 gap-4">
{elements.map((element: any) => (
<div key={element.name} className="w-full">
{element.content}
</div>
))}
</div>
</CollapsibleSection>
))}
</div>
)}
{/* Render Ungrouped Fields */}
{ungrouped.length > 0 && (
<div className="grid grid-cols-1 gap-4">
{ungrouped.map((element: any) => (
<div key={element.name} className="w-full">
{element.content}
</div>
))}
</div>
)}
</div>
);
};
// Custom widgets
export const customWidgets: RegistryWidgetsType = {
TextWidget,
CheckboxWidget,
};
// Custom templates
export const customTemplates = {
FieldTemplate,
ObjectFieldTemplate,
};

View File

@ -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 (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
className={`flex items-center gap-2 p-2 border rounded-md cursor-grab hover:bg-muted ${isDragging ? 'opacity-50' : 'bg-background'}`}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
<Icon className="h-4 w-4" />
<span className="text-sm font-medium">{item.name}</span>
</div>
);
};
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 (
<div
onClick={(e) => { 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'}`}
>
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<div className="flex flex-col">
<span className="text-sm font-medium">{element.title || element.name}</span>
<span className="text-xs text-muted-foreground">{element.type}</span>
</div>
</div>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle>Remove Field?</AlertDialogTitle>
<AlertDialogDescription asChild>
<div>
{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}":
<ul className="mt-2 space-y-1 text-xs">
<li><strong>Remove Only:</strong> Unlink from this structure but keep the field type in the database</li>
<li><strong>Delete in Database:</strong> Remove from structure and permanently delete the field type</li>
</ul>
</>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
{!isPrimitive && onRemoveOnly && (
<AlertDialogAction
onClick={(e) => {
e.stopPropagation();
onRemoveOnly();
setShowDeleteDialog(false);
}}
className="bg-secondary text-secondary-foreground hover:bg-secondary/90"
>
Remove Only
</AlertDialogAction>
)}
<AlertDialogAction
onClick={(e) => {
e.stopPropagation();
onDelete();
setShowDeleteDialog(false);
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete in Database
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
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<React.SetStateAction<BuilderElement[]>>;
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<BuilderElement>) => 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 (
<div className="flex h-full gap-6">
{/* Palette */}
<Card className="w-64 flex flex-col">
<CardHeader className="py-3 px-4 border-b">
<CardTitle className="text-sm font-medium">Palette</CardTitle>
</CardHeader>
<CardContent className="flex-1 p-3 space-y-6 overflow-y-auto">
<div>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Primitives</div>
<div className="space-y-2">
{PALETTE_ITEMS.map(item => (
<DraggablePaletteItem key={item.id} item={item} />
))}
</div>
</div>
{customPaletteItems.length > 0 && mode !== 'alias' && (
<div>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Custom Types</div>
<div className="space-y-2">
{customPaletteItems.map(item => (
<DraggablePaletteItem key={item.id} item={item} />
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Canvas */}
<Card className={`flex-1 flex flex-col transition-colors ${isOver ? 'bg-muted/30 border-primary/50 ring-2 ring-primary/20' : ''}`}>
<CardHeader className="py-3 px-4 border-b flex flex-row justify-between items-center">
<div className="flex items-center gap-4">
<CardTitle className="text-sm font-medium">Builder</CardTitle>
<Tabs value={mode} onValueChange={(v) => { setMode(v as BuilderMode); setElements([]); }} className="w-[200px]">
<TabsList className="grid w-full grid-cols-2 h-7">
<TabsTrigger value="structure" className="text-xs">Structure</TabsTrigger>
<TabsTrigger value="alias" className="text-xs">Single Type</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={onCancel}>Cancel</Button>
<Button size="sm" onClick={() => onSave({ mode, elements, name: typeName, description: typeDescription, fieldsToDelete })} disabled={!typeName.trim()}>
Save Type
</Button>
</div>
</CardHeader>
<div ref={setCanvasRef} className="flex-1 p-6 bg-muted/10 overflow-y-auto min-h-[300px] transition-colors relative">
{isOver && (
<div className="absolute inset-0 bg-primary/5 rounded-none border-2 border-primary/20 border-dashed pointer-events-none z-0" />
)}
<div className="relative z-10 min-h-full">
{elements.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground border-2 border-dashed rounded-lg opacity-50 min-h-[250px]">
<Box className="h-12 w-12 opacity-50 mb-2" />
<p>
{mode === 'alias'
? "Drag a primitive type here to define the base type"
: "Drag items here to build your structure"
}
</p>
</div>
) : (
<div className="space-y-2 max-w-md mx-auto">
{elements.map(el => (
<CanvasElement
key={el.id}
element={el}
isSelected={selectedId === el.id}
onSelect={() => setSelectedId(el.id)}
onDelete={() => deleteElement(el.id)}
onRemoveOnly={() => removeElement(el.id)}
/>
))}
</div>
)}
</div>
</div>
</Card>
{/* Configuration Pane */}
<Card className="w-80 flex flex-col">
<CardHeader className="py-3 px-4 border-b">
<CardTitle className="text-sm font-medium">Configuration</CardTitle>
</CardHeader>
<CardContent className="flex-1 p-0 overflow-hidden flex flex-col">
<Tabs defaultValue={selectedElement ? "field" : "type"} value={selectedElement ? "field" : "type"} className="flex-1 flex flex-col">
<div className="px-4 py-2 border-b">
<TabsList className="w-full">
<TabsTrigger value="type" className="flex-1" onClick={() => setSelectedId(null)}>Type Settings</TabsTrigger>
<TabsTrigger value="field" className="flex-1" disabled={!selectedElement}>Field Settings</TabsTrigger>
</TabsList>
</div>
<TabsContent value="type" className="flex-1 p-4 space-y-4 m-0 overflow-y-auto">
<div className="space-y-2">
<Label>Type Name <span className="text-red-500">*</span></Label>
<Input
value={typeName}
onChange={e => setTypeName(e.target.value)}
placeholder="e.g. MyCustomType"
className={!typeName.trim() ? "border-red-300" : ""}
/>
<p className="text-[10px] text-muted-foreground">The unique name for this new type.</p>
</div>
{mode === 'alias' && (
<div className="space-y-2">
<Label>Parent Type</Label>
<Select
value={elements[0]?.type || ''}
onValueChange={(val) => {
if (elements.length > 0) {
const updated = { ...elements[0], type: val, name: 'value', title: val + ' Alias' };
setElements([updated]);
setSelectedId(updated.id);
} else {
// Create new if empty? Or just wait for drag?
// Let's allow creating via select if empty
const newItemId = `field-${Date.now()}`;
const newItem: BuilderElement = {
id: newItemId,
type: val,
name: 'value',
title: val + ' Alias',
uiSchema: {}
};
setElements([newItem]);
setSelectedId(newItemId);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a primitive type" />
</SelectTrigger>
<SelectContent>
{PALETTE_ITEMS.map(p => (
<SelectItem key={p.id} value={p.type}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">The primitive type to alias.</p>
</div>
)}
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={typeDescription}
onChange={e => setTypeDescription(e.target.value)}
className="resize-none h-20"
placeholder="What is this type for?"
/>
</div>
</TabsContent>
<TabsContent value="field" className="flex-1 p-4 m-0 overflow-y-auto">
{selectedElement ? (
<div className="space-y-4">
{mode === 'structure' && (
<div className="space-y-2">
<Label>Field Name (key)</Label>
<Input
value={selectedElement.name}
onChange={e => updateSelectedElement({ name: e.target.value })}
placeholder="e.g. firstName"
/>
<p className="text-[10px] text-muted-foreground">The property key in the JSON object.</p>
</div>
)}
<div className="space-y-2">
<Label>Title (Label)</Label>
<Input
value={selectedElement.title}
onChange={e => updateSelectedElement({ title: e.target.value })}
placeholder="e.g. First Name"
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={selectedElement.description || ''}
onChange={e => updateSelectedElement({ description: e.target.value })}
className="resize-none h-20"
placeholder="Helper text for the user..."
/>
</div>
<div className="pt-4 border-t">
<h4 className="text-xs font-semibold mb-3">UI Schema</h4>
<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 }
});
}}
placeholder="e.g. textarea, radio, select"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Placeholder</Label>
<Input
value={selectedElement.uiSchema?.['ui:placeholder'] || ''}
onChange={e => {
const val = e.target.value;
updateSelectedElement({
uiSchema: { ...selectedElement.uiSchema, 'ui:placeholder': val || undefined }
});
}}
placeholder="Placeholder text..."
className="h-8 text-xs"
/>
</div>
</div>
</div>
</div>
) : (
<div className="text-center text-sm text-muted-foreground mt-10">
Select an element on the canvas to configure it.
</div>
)}
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
);
};
export const TypeBuilder: React.FC<{
onSave: (data: BuilderOutput) => void,
onCancel: () => void,
availableTypes: TypeDefinition[],
initialData?: BuilderOutput
}> = ({ onSave, onCancel, availableTypes, initialData }) => {
const [mode, setMode] = useState<BuilderMode>(initialData?.mode || 'structure');
const [elements, setElements] = useState<BuilderElement[]>(initialData?.elements || []);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [activeDragItem, setActiveDragItem] = useState<BuilderElement | null>(null);
const [typeName, setTypeName] = useState<string>(initialData?.name || '');
const [typeDescription, setTypeDescription] = useState<string>(initialData?.description || '');
const [fieldsToDelete, setFieldsToDelete] = useState<string[]>([]); // Track field type IDs to delete
// Setup Sensors with activation constraint
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
);
const handleDragStart = (event: DragStartEvent) => {
if (event.active.data.current) {
setActiveDragItem(event.active.data.current as BuilderElement);
}
};
const handleDragEnd = (event: DragEndEvent) => {
const { over, active } = event;
setActiveDragItem(null);
if (over && over.id === 'canvas') {
const template = active.data.current as BuilderElement;
// Generate robust ID to avoid collisions
const newItemId = `field-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
// Map JSON Schema type names to database primitive type names
const typeNameMap: Record<string, string> = {
'number': 'int', 'boolean': 'bool', 'string': 'string', 'array': 'array', 'object': 'object'
};
const dbTypeName = typeNameMap[template.type] || template.type;
// Look up the type ID from availableTypes
const typeDefinition = availableTypes.find(t => t.name === dbTypeName);
const newItem: BuilderElement = {
id: newItemId,
type: template.type,
name: template.name.toLowerCase().replace(/\s+/g, '_'),
title: template.name,
description: '',
uiSchema: {},
...(typeDefinition && { refId: typeDefinition.id } as any) // Store the type ID
};
setElements(prev => [...prev, newItem]);
setSelectedId(newItemId);
}
};
const selectedElement = elements.find(e => e.id === selectedId);
const updateSelectedElement = (updates: Partial<BuilderElement>) => {
if (!selectedId) return;
setElements(prev => prev.map(e => e.id === selectedId ? { ...e, ...updates } : e));
};
// Remove element from builder only (for "Remove Only" option)
const removeElement = (id: string) => {
setElements(prev => prev.filter(e => e.id !== id));
if (selectedId === id) setSelectedId(null);
};
// Delete element and mark its field type for database deletion
const deleteElement = (id: string) => {
const element = elements.find(e => e.id === id);
if (element && (element as any).fieldTypeId) {
setFieldsToDelete(prev => [...prev, (element as any).fieldTypeId]);
}
setElements(prev => prev.filter(e => e.id !== id));
if (selectedId === id) setSelectedId(null);
};
return (
<DndContext
sensors={sensors}
collisionDetection={pointerWithin} // Use pointerWithin for container dropping
measuring={{
droppable: {
strategy: MeasuringStrategy.Always,
}
}}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<TypeBuilderContent
mode={mode}
setMode={setMode}
elements={elements}
setElements={setElements}
selectedId={selectedId}
setSelectedId={setSelectedId}
onCancel={onCancel}
onSave={onSave}
deleteElement={deleteElement}
removeElement={removeElement}
updateSelectedElement={updateSelectedElement}
selectedElement={selectedElement}
availableTypes={availableTypes}
typeName={typeName}
setTypeName={setTypeName}
typeDescription={typeDescription}
setTypeDescription={setTypeDescription}
fieldsToDelete={fieldsToDelete}
/>
{createPortal(
<DragOverlay dropAnimation={null}>
{activeDragItem ? (
<div className="flex items-center gap-2 p-2 border rounded-md bg-background shadow-lg opacity-90 cursor-grabbing w-[150px] pointer-events-none ring-2 ring-primary">
<GripVertical className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{activeDragItem.name}</span>
</div>
) : null}
</DragOverlay>,
document.body
)}
</DndContext>
);
};

View File

@ -0,0 +1,291 @@
import React, { useState, useMemo } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { RefreshCw, Save, Trash2, Play } from 'lucide-react';
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';
interface TypeRendererProps {
editedType: TypeDefinition;
types: TypeDefinition[];
onSave: (jsonSchema: string, uiSchema: string) => Promise<void>;
onDelete: () => Promise<void>;
onVisualEdit: () => void;
}
export const TypeRenderer: React.FC<TypeRendererProps> = ({
editedType,
types,
onSave,
onDelete,
onVisualEdit
}) => {
const [jsonSchemaString, setJsonSchemaString] = useState('{}');
const [uiSchemaString, setUiSchemaString] = useState('{}');
const [showPreview, setShowPreview] = useState(false);
const [previewFormData, setPreviewFormData] = useState<any>({});
const [previewKey, setPreviewKey] = useState(0);
// Generate JSON schema and UI schema when editedType changes
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' }
};
// For structures, generate JSON schema from structure_fields
if (editedType.kind === 'structure' && editedType.structure_fields && editedType.structure_fields.length > 0) {
const properties: Record<string, any> = {};
const required: string[] = [];
editedType.structure_fields.forEach(field => {
// Find the field type to get its parent type
const fieldType = types.find(type => type.id === field.field_type_id);
if (fieldType && fieldType.parent_type_id) {
// Find the parent type (primitive)
const parentType = types.find(type => type.id === fieldType.parent_type_id);
if (parentType) {
// Use the primitive mapping to get the JSON Schema type
const jsonSchemaType = primitiveToJsonSchema[parentType.name] || { type: 'string' };
properties[field.field_name] = {
...jsonSchemaType,
title: field.field_name,
...(fieldType.description && { description: fieldType.description })
};
if (field.required) {
required.push(field.field_name);
}
}
}
});
const generatedSchema = {
type: 'object',
properties,
...(required.length > 0 && { required })
};
setJsonSchemaString(JSON.stringify(generatedSchema, null, 2));
// Also aggregate UI schema from fields
const aggregatedUiSchema: Record<string, any> = {};
editedType.structure_fields.forEach(field => {
const fieldType = types.find(type => type.id === field.field_type_id);
if (fieldType && fieldType.meta?.uiSchema) {
aggregatedUiSchema[field.field_name] = fieldType.meta.uiSchema;
}
});
// Merge with structure's own UI schema
const finalUiSchema = {
...aggregatedUiSchema,
...(editedType.meta?.uiSchema || {})
};
setUiSchemaString(JSON.stringify(finalUiSchema, null, 2));
} else {
setJsonSchemaString(JSON.stringify(editedType.json_schema || {}, null, 2));
setUiSchemaString(JSON.stringify(editedType.meta?.uiSchema || {}, null, 2));
}
// Reset preview when type changes
setShowPreview(false);
}, [editedType, types]);
const previewSchema = useMemo(() => {
try {
return JSON.parse(jsonSchemaString);
} catch (e) {
return {};
}
}, [jsonSchemaString]);
const previewUiSchema = useMemo(() => {
try {
return JSON.parse(uiSchemaString);
} catch (e) {
return {};
}
}, [uiSchemaString]);
const handleSave = async () => {
await onSave(jsonSchemaString, uiSchemaString);
};
const handlePreviewToggle = () => {
if (!showPreview && editedType?.json_schema) {
const randomData = generateRandomData(previewSchema);
setPreviewFormData(randomData);
}
setShowPreview(!showPreview);
};
const handleRegenerateData = () => {
if (previewSchema) {
const randomData = generateRandomData(previewSchema);
setPreviewFormData(randomData);
}
};
return (
<>
<CardHeader className="border-b py-3 md:py-4">
<div className="flex justify-between items-center">
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-lg">{editedType.name}</CardTitle>
<span className="text-xs uppercase tracking-wider font-mono bg-muted px-2 py-0.5 rounded border">
{editedType.kind}
</span>
</div>
<CardDescription className="text-xs mt-1 truncate max-w-md">
{editedType.description || "No description"}
</CardDescription>
</div>
<div className="flex gap-2">
<Button onClick={onVisualEdit} size="sm" variant="outline">
<RefreshCw className="mr-2 h-3.5 w-3.5" />
Visual Edit
</Button>
{editedType.kind === 'structure' && (
<Button
onClick={handlePreviewToggle}
size="sm"
variant={showPreview ? "default" : "outline"}
>
<Play className="mr-2 h-3.5 w-3.5" />
{showPreview ? 'Hide' : 'Preview'}
</Button>
)}
<Button
variant="destructive"
size="sm"
onClick={onDelete}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</Button>
<Button onClick={handleSave} size="sm">
<Save className="mr-2 h-3.5 w-3.5" />
Save Changes
</Button>
</div>
</div>
</CardHeader>
<CardContent className="flex-1 min-h-0 p-0 overflow-hidden">
<div className="grid grid-cols-2 h-full divide-x">
{/* Editor Column */}
<div className="flex flex-col h-full min-h-0 bg-muted/10">
<Tabs defaultValue="schema" className="flex-1 flex flex-col min-h-0">
<div className="px-4 py-2 border-b bg-background">
<TabsList className="w-full justify-start h-8 p-0 bg-transparent">
<TabsTrigger value="schema" className="data-[state=active]:bg-muted data-[state=active]:shadow-none h-8 rounded-none border-b-2 border-transparent data-[state=active]:border-primary px-4 bg-transparent">JSON Schema</TabsTrigger>
<TabsTrigger value="uischema" className="data-[state=active]:bg-muted data-[state=active]:shadow-none h-8 rounded-none border-b-2 border-transparent data-[state=active]:border-primary px-4 bg-transparent">UI Schema</TabsTrigger>
<TabsTrigger value="meta" className="data-[state=active]:bg-muted data-[state=active]:shadow-none h-8 rounded-none border-b-2 border-transparent data-[state=active]:border-primary px-4 bg-transparent">Meta & Settings</TabsTrigger>
</TabsList>
</div>
<TabsContent value="schema" className="flex-1 m-0 min-h-0 relative">
<Textarea
value={jsonSchemaString}
onChange={e => setJsonSchemaString(e.target.value)}
className="absolute inset-0 w-full h-full resize-none rounded-none border-0 font-mono text-xs p-4 focus-visible:ring-0"
spellCheck={false}
/>
</TabsContent>
<TabsContent value="uischema" className="flex-1 m-0 min-h-0 relative">
<div className="absolute inset-0 flex flex-col">
<div className="p-2 text-xs text-muted-foreground bg-muted/30 border-b">
Edit the <code>uiSchema</code> object below. This is stored in <code>meta.uiSchema</code>.
</div>
<Textarea
value={uiSchemaString}
onChange={e => setUiSchemaString(e.target.value)}
className="flex-1 w-full h-full resize-none rounded-none border-0 font-mono text-xs p-4 focus-visible:ring-0"
spellCheck={false}
/>
</div>
</TabsContent>
<TabsContent value="meta" className="flex-1 m-0 min-h-0 relative p-4 overflow-auto">
<pre className="text-xs font-mono">{JSON.stringify(editedType, null, 2)}</pre>
</TabsContent>
</Tabs>
</div>
{/* Preview Column */}
<div className="flex flex-col h-full min-h-0 bg-background/50">
<div className="flex items-center justify-between px-4 py-2 border-b bg-background h-[3rem]">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{showPreview ? 'Preview with Data' : 'Preview'}
</span>
<div className="flex gap-1">
{showPreview && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={handleRegenerateData}
>
<RefreshCw className="h-3 w-3 mr-1" />
Regenerate
</Button>
)}
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => setPreviewKey(k => k + 1)}>
<RefreshCw className="h-3 w-3" />
</Button>
</div>
</div>
<div className="flex-1 p-6 overflow-y-auto">
<div className="max-w-md mx-auto space-y-4">
<Form
key={previewKey}
schema={previewSchema}
uiSchema={previewUiSchema}
formData={showPreview ? previewFormData : undefined}
validator={validator}
widgets={customWidgets}
templates={customTemplates}
onChange={({ formData }) => showPreview && setPreviewFormData(formData)}
onSubmit={({ formData }) => toast.success("Form submitted (check console)")}
onError={(errors) => console.log('Form errors:', errors)}
className="space-y-4"
>
<Button type="submit" className="w-full mt-4">Submit Test</Button>
</Form>
{showPreview && (
<div className="mt-6 pt-6 border-t">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Form Data (JSON)</span>
</div>
<pre className="text-xs font-mono bg-muted p-3 rounded border overflow-x-auto">
{JSON.stringify(previewFormData, null, 2)}
</pre>
</div>
)}
</div>
</div>
</div>
</div>
</CardContent>
</>
);
};
export default TypeRenderer;

View File

@ -0,0 +1,342 @@
import React, { useEffect, useState, useMemo } from 'react';
import { fetchTypes, updateType, createType, deleteType, TypeDefinition } from './db';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Loader2, Plus, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { TypeBuilder, BuilderOutput, BuilderElement, BuilderMode } from './TypeBuilder';
import TypeRenderer from './TypeRenderer';
const TypesPlayground: React.FC = () => {
const [types, setTypes] = useState<TypeDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [selectedTypeId, setSelectedTypeId] = useState<string | null>(null);
const [editedType, setEditedType] = useState<TypeDefinition | null>(null);
const [isBuilding, setIsBuilding] = useState(false);
const [builderInitialData, setBuilderInitialData] = useState<BuilderOutput | undefined>(undefined);
const loadTypes = async () => {
setLoading(true);
try {
const data = await fetchTypes();
console.log('types', data);
setTypes(data);
if (selectedTypeId) {
const refreshed = data.find(t => t.id === selectedTypeId);
if (refreshed) selectType(refreshed);
}
} catch (error) {
console.error("Failed to fetch types", error);
toast.error("Failed to load types");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadTypes();
}, []);
const selectType = (t: TypeDefinition) => {
setIsBuilding(false);
setSelectedTypeId(t.id);
setEditedType(t);
};
const handleCreateNew = () => {
setSelectedTypeId(null);
setEditedType(null);
setBuilderInitialData(undefined);
setIsBuilding(true);
};
const handleEditVisual = () => {
if (!editedType) return;
// Convert current type to builder format
const builderData: BuilderOutput = {
mode: editedType.kind as BuilderMode,
name: editedType.name,
description: editedType.description || '',
elements: []
};
// For structures, convert structure_fields to builder elements
if (editedType.kind === 'structure' && editedType.structure_fields) {
builderData.elements = editedType.structure_fields.map(field => {
const fieldType = types.find(t => t.id === field.field_type_id);
return {
id: field.id || crypto.randomUUID(),
name: field.field_name,
type: fieldType?.name || 'string',
title: field.field_name,
description: fieldType?.description || ''
} as BuilderElement;
});
}
setBuilderInitialData(builderData);
setIsBuilding(true);
};
const handleBuilderSave = async (output: BuilderOutput) => {
console.log('Builder output:', output);
if (builderInitialData) {
// Editing existing type
if (!editedType) return;
try {
// For structures, we need to update structure_fields
if (output.mode === 'structure') {
// Create/update field types for each element
const fieldUpdates = await Promise.all(output.elements.map(async (el) => {
// Find or create the field type
let fieldType = types.find(t => t.name === `${editedType.name}.${el.name}` && t.kind === 'field');
// Find the primitive type for this field
const primitiveType = types.find(t => t.kind === 'primitive' && t.name === el.type);
if (!primitiveType) {
console.error(`Primitive type not found: ${el.type}`);
return null;
}
const fieldTypeData = {
name: `${editedType.name}.${el.name}`,
kind: 'field' as const,
description: el.description || `Field ${el.name}`,
parent_type_id: primitiveType.id,
meta: {}
};
if (fieldType) {
// Update existing field type
await updateType(fieldType.id, fieldTypeData);
return { ...fieldType, ...fieldTypeData };
} else {
// Create new field type
const newFieldType = await createType(fieldTypeData as any);
return newFieldType;
}
}));
// Filter out any null results
const validFieldTypes = fieldUpdates.filter(f => f !== null);
// Update the structure with new structure_fields
const structureFields = output.elements.map((el, idx) => ({
field_name: el.name,
field_type_id: validFieldTypes[idx]?.id || '',
required: false,
order: idx
}));
await updateType(editedType.id, {
name: output.name,
description: output.description,
structure_fields: structureFields
});
}
toast.success("Type updated");
setIsBuilding(false);
loadTypes();
} catch (error) {
console.error("Failed to update type", error);
toast.error("Failed to update type");
}
} else {
// Creating new type
try {
const newType: Partial<TypeDefinition> = {
name: output.name,
description: output.description,
kind: output.mode,
};
// For structures, create field types first
if (output.mode === 'structure') {
const fieldTypes = await Promise.all(output.elements.map(async (el) => {
const primitiveType = types.find(t => t.kind === 'primitive' && t.name === el.type);
if (!primitiveType) {
throw new Error(`Primitive type not found: ${el.type}`);
}
return await createType({
name: `${output.name}.${el.name}`,
kind: 'field',
description: el.description || `Field ${el.name}`,
parent_type_id: primitiveType.id,
meta: {}
} as any);
}));
newType.structure_fields = output.elements.map((el, idx) => ({
field_name: el.name,
field_type_id: fieldTypes[idx].id,
required: false,
order: idx
}));
}
await createType(newType as any);
toast.success("Type created successfully");
setIsBuilding(false);
loadTypes();
} catch (error) {
console.error("Failed to create type", error);
toast.error("Failed to create type");
}
}
};
// Group types by kind (exclude field types from display)
const groupedTypes = useMemo(() => {
const groups: Record<string, TypeDefinition[]> = {};
types
.filter(t => t.kind !== 'field') // Don't show field types in the main list
.forEach(t => {
const kind = t.kind || 'other';
if (!groups[kind]) groups[kind] = [];
groups[kind].push(t);
});
return groups;
}, [types]);
const kindOrder = ['primitive', 'enum', 'flags', 'structure', 'alias', 'other'];
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="border-b px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Types Playground</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage and preview your type definitions
</p>
</div>
<Button onClick={handleCreateNew} size="sm">
<Plus className="mr-2 h-4 w-4" />
New Type
</Button>
</div>
{/* Main Content */}
<div className="flex-1 min-h-0 p-6">
{loading ? (
<div className="h-full flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<div className="grid grid-cols-12 gap-6 h-full">
{/* List Sidebar */}
<Card className={`flex flex-col min-h-0 ${isBuilding ? 'hidden' : 'col-span-3'}`}>
<CardHeader className="pb-3 border-b px-4 py-3">
<CardTitle className="text-sm font-medium">Available Types</CardTitle>
</CardHeader>
<CardContent className="flex-1 min-h-0 p-0">
<ScrollArea className="h-full">
<div className="p-3 space-y-4">
{kindOrder.map(kind => {
const group = groupedTypes[kind];
if (!group || group.length === 0) return null;
return (
<div key={kind} className="space-y-1">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-2 mb-2">
{kind}
</h3>
<div className="space-y-0.5">
{group.map(t => (
<button
key={t.id}
onClick={() => selectType(t)}
className={`w-full text-left px-2 py-1.5 rounded-md text-xs transition-colors flex items-center justify-between ${selectedTypeId === t.id
? 'bg-secondary text-secondary-foreground font-medium'
: 'hover:bg-muted text-muted-foreground'
}`}
>
<span className="truncate">{t.name}</span>
{selectedTypeId === t.id && (
<div className="w-1 h-1 rounded-full bg-primary flex-shrink-0 ml-2" />
)}
</button>
))}
</div>
</div>
);
})}
</div>
</ScrollArea>
</CardContent>
</Card>
{/* Main Content Area */}
<div className={`${isBuilding ? 'col-span-12' : 'col-span-9'} flex flex-col min-h-0 overflow-hidden`}>
{isBuilding ? (
<TypeBuilder
onSave={handleBuilderSave}
onCancel={() => { setIsBuilding(false); setBuilderInitialData(undefined); if (types.length > 0 && selectedTypeId) selectType(types.find(t => t.id === selectedTypeId)!); }}
availableTypes={types}
initialData={builderInitialData}
/>
) : (
<Card className="flex flex-col h-full overflow-hidden">
{editedType ? (
<TypeRenderer
editedType={editedType}
types={types}
onSave={async (jsonSchemaString, uiSchemaString) => {
try {
const jsonSchema = JSON.parse(jsonSchemaString);
const uiSchema = JSON.parse(uiSchemaString);
await updateType(editedType.id, {
json_schema: jsonSchema,
meta: { ...editedType.meta, uiSchema }
});
toast.success("Type updated");
loadTypes();
} catch (e) {
console.error(e);
toast.error("Failed to update type");
}
}}
onDelete={async () => {
if (confirm("Are you sure you want to delete this type?")) {
try {
await deleteType(editedType.id);
toast.success("Type deleted");
setEditedType(null);
setSelectedTypeId(null);
loadTypes();
} catch (e) {
console.error(e);
toast.error("Failed to delete type");
}
}
}}
onVisualEdit={handleEditVisual}
/>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground flex-col gap-2 p-8 text-center">
<div className="bg-muted p-4 rounded-full mb-2">
<RefreshCw className="h-8 w-8 opacity-20" />
</div>
<h3 className="text-lg font-medium">No Type Selected</h3>
<p className="max-w-sm text-sm">Select a type from the sidebar to view its details, edit the schema, and preview the generated form.</p>
</div>
)}
</Card>
)}
</div>
</div>
)}
</div>
</div>
);
};
export default TypesPlayground;

View File

@ -0,0 +1,179 @@
import { supabase } from "@/integrations/supabase/client";
import { fetchWithDeduplication, invalidateCache } from "@/lib/db";
// Types corresponding to the server DTOs (approximate)
export interface TypeDefinition {
id: string;
name: string;
kind: 'primitive' | 'enum' | 'flags' | 'structure' | 'alias' | 'field';
parent_type_id: string | null;
description: string | null;
json_schema: any;
owner_id: string | null;
visibility: 'public' | 'private' | 'custom';
meta: any;
settings: any;
created_at?: string;
updated_at?: string;
// Children usually joined
enum_values?: any[];
flag_values?: any[];
structure_fields?: {
id?: string;
structure_type_id?: string;
field_name: string;
field_type_id: string;
required: boolean;
default_value?: any;
order: number;
}[];
}
export const fetchTypes = async (options?: {
kind?: TypeDefinition['kind'] | string; // Allow string for flexibility or specific enum
parentTypeId?: string;
visibility?: string;
}) => {
// Cache key based on options
const key = `types-${JSON.stringify(options || {})}`;
return fetchWithDeduplication(key, async () => {
let query = supabase
.from('types')
.select(`
*,
structure_fields:type_structure_fields!type_structure_fields_structure_type_id_fkey(*)
`)
.order('name');
if (options?.kind) {
query = query.eq('kind', options.kind as any);
}
if (options?.parentTypeId) {
query = query.eq('parent_type_id', options.parentTypeId);
}
if (options?.visibility) {
query = query.eq('visibility', options.visibility as any);
}
const { data, error } = await query;
if (error) throw error;
return data as TypeDefinition[];
}, 1); // 5 min cache
};
export const fetchTypeById = async (id: string) => {
const key = `type-${id}`;
return fetchWithDeduplication(key, async () => {
// We can call the API endpoint or Supabase directly.
// Using API might yield more enriched data if the server does heavy lifting.
// But for consistency with lib/db.ts, let's use supabase client directly or the API route?
// lib/db.ts uses supabase client directly mostly.
// However, the server does a nice join:
/*
.select(`
*,
enum_values:type_enum_values(*),
flag_values:type_flag_values(*),
structure_fields:type_structure_fields(*),
casts_from:type_casts!from_type_id(*),
casts_to:type_casts!to_type_id(*)
`)
*/
const { data, error } = await supabase
.from('types')
.select(`
*,
enum_values:type_enum_values(*),
flag_values:type_flag_values(*),
structure_fields:type_structure_fields(*),
casts_from:type_casts!from_type_id(*),
casts_to:type_casts!to_type_id(*)
`)
.eq('id', id)
.single();
if (error) throw error;
return data as TypeDefinition;
});
};
export const createType = async (typeData: any) => {
// Create usually requires API if we have complex logic globally,
// BUT checking server code `createTypeServer` it does multiple inserts.
// Client side transaction is not easy.
// Let's use the API endpoint: POST /api/types
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/types', {
method: 'POST',
headers,
body: JSON.stringify(typeData)
});
if (!res.ok) {
throw new Error(`Failed to create type: ${res.statusText}`);
}
const newType = await res.json();
// Invalidate list cache
invalidateCache(`types-{}`); // Invalidate default list
return newType;
};
export const updateType = async (id: string, updates: any) => {
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/types/${id}`, {
method: 'PATCH',
headers,
body: JSON.stringify(updates)
});
if (!res.ok) {
throw new Error(`Failed to update type: ${res.statusText}`);
}
const updated = await res.json();
// Invalidate specific type cache
invalidateCache(`type-${id}`);
return updated;
};
export const deleteType = async (id: string) => {
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/types/${id}`, {
method: 'DELETE',
headers
});
if (!res.ok) {
throw new Error(`Failed to delete type: ${res.statusText}`);
}
invalidateCache(`type-${id}`);
return true;
};

View File

@ -0,0 +1,71 @@
// Helper functions for generating random form data based on JSON schema
export const generateRandomData = (schema: any): any => {
if (!schema || !schema.properties) return {};
const data: any = {};
const sampleTexts = ['Lorem ipsum dolor sit amet', 'Sample text content', 'Example value here', 'Test data entry', 'Demo content item'];
const sampleNames = ['Alice Johnson', 'Bob Smith', 'Charlie Brown', 'Diana Prince', 'Eve Anderson'];
const sampleEmails = ['alice@example.com', 'bob@test.com', 'charlie@demo.org', 'diana@sample.net', 'eve@mail.com'];
Object.keys(schema.properties).forEach(key => {
const prop = schema.properties[key];
const type = prop.type;
const fieldName = key.toLowerCase();
switch (type) {
case 'string':
// Try to generate contextual data based on field name
if (fieldName.includes('email')) {
data[key] = sampleEmails[Math.floor(Math.random() * sampleEmails.length)];
} else if (fieldName.includes('name')) {
data[key] = sampleNames[Math.floor(Math.random() * sampleNames.length)];
} else if (fieldName.includes('phone')) {
data[key] = `+1-555-${Math.floor(Math.random() * 900) + 100}-${Math.floor(Math.random() * 9000) + 1000}`;
} else if (fieldName.includes('url') || fieldName.includes('link')) {
data[key] = `https://example.com/${key}`;
} else {
data[key] = sampleTexts[Math.floor(Math.random() * sampleTexts.length)];
}
break;
case 'number':
case 'integer':
// Generate contextual numbers
if (fieldName.includes('age')) {
data[key] = Math.floor(Math.random() * 50) + 18;
} else if (fieldName.includes('price') || fieldName.includes('cost')) {
data[key] = Math.floor(Math.random() * 10000) / 100;
} else if (fieldName.includes('quantity') || fieldName.includes('count')) {
data[key] = Math.floor(Math.random() * 20) + 1;
} else {
data[key] = Math.floor(Math.random() * 100) + 1;
}
break;
case 'boolean':
data[key] = Math.random() > 0.5;
break;
case 'array':
const itemCount = Math.floor(Math.random() * 3) + 1;
data[key] = Array.from({ length: itemCount }, (_, index) => {
if (prop.items) {
if (prop.items.type === 'object' && prop.items.properties) {
return generateRandomData(prop.items);
} else if (prop.items.type === 'string') {
return `Item ${index + 1}`;
} else if (prop.items.type === 'number') {
return Math.floor(Math.random() * 100);
}
}
return `Item ${index + 1}`;
});
break;
case 'object':
data[key] = prop.properties ? generateRandomData(prop) : {};
break;
default:
data[key] = null;
}
});
return data;
};

View File

@ -0,0 +1,348 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { fetchCategories, createCategory, updateCategory, deleteCategory, updatePageMeta, Category } from "@/lib/db";
import { toast } from "sonner";
import { Plus, Edit2, Trash2, FolderTree, Link as LinkIcon, Check, X, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { T } from "@/i18n";
interface CategoryManagerProps {
isOpen: boolean;
onClose: () => void;
currentPageId?: string; // If provided, allows linking page to category
currentPageMeta?: any;
onPageMetaUpdate?: (newMeta: any) => void;
}
export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMeta, onPageMetaUpdate }: CategoryManagerProps) => {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
// Selection state
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
// Editing/Creating state
const [editingCategory, setEditingCategory] = useState<Partial<Category> | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [creationParentId, setCreationParentId] = useState<string | null>(null);
// Initial linked category from page meta
const getLinkedCategoryIds = (): string[] => {
if (!currentPageMeta) return [];
if (Array.isArray(currentPageMeta.categoryIds)) return currentPageMeta.categoryIds;
if (currentPageMeta.categoryId) return [currentPageMeta.categoryId];
return [];
};
const linkedCategoryIds = getLinkedCategoryIds();
useEffect(() => {
if (isOpen) {
loadCategories();
}
}, [isOpen]);
const loadCategories = async () => {
setLoading(true);
try {
const data = await fetchCategories({ includeChildren: true });
setCategories(data);
} catch (error) {
console.error(error);
toast.error("Failed to load categories");
} finally {
setLoading(false);
}
};
const handleCreateStart = (parentId: string | null = null) => {
setIsCreating(true);
setCreationParentId(parentId);
setEditingCategory({
name: "",
slug: "",
description: "",
visibility: "public"
});
};
const handleEditStart = (category: Category) => {
setIsCreating(false);
setEditingCategory({ ...category });
};
const handleSave = async () => {
if (!editingCategory || !editingCategory.name || !editingCategory.slug) {
toast.error("Name and Slug are required");
return;
}
setActionLoading(true);
try {
if (isCreating) {
await createCategory({
...editingCategory,
parentId: creationParentId || undefined,
relationType: 'generalization'
});
toast.success("Category created");
} else if (editingCategory.id) {
await updateCategory(editingCategory.id, editingCategory);
toast.success("Category updated");
}
setEditingCategory(null);
loadCategories();
} catch (error) {
console.error(error);
toast.error(isCreating ? "Failed to create category" : "Failed to update category");
} finally {
setActionLoading(false);
}
};
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm("Are you sure you want to delete this category?")) return;
setActionLoading(true);
try {
await deleteCategory(id);
toast.success("Category deleted");
loadCategories();
if (selectedCategoryId === id) setSelectedCategoryId(null);
} catch (error) {
console.error(error);
toast.error("Failed to delete category");
} finally {
setActionLoading(false);
}
};
const handleLinkPage = async () => {
if (!currentPageId || !selectedCategoryId) return;
const currentIds = getLinkedCategoryIds();
if (currentIds.includes(selectedCategoryId)) return;
setActionLoading(true);
try {
const newIds = [...currentIds, selectedCategoryId];
// Clear legacy single ID if it exists to clean up, but prioritize setting array
await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null });
toast.success("Page added to category");
if (onPageMetaUpdate) {
onPageMetaUpdate({ ...currentPageMeta, categoryIds: newIds, categoryId: null });
}
} catch (error) {
console.error(error);
toast.error("Failed to link page");
} finally {
setActionLoading(false);
}
};
const handleUnlinkPage = async () => {
if (!currentPageId || !selectedCategoryId) return;
const currentIds = getLinkedCategoryIds();
if (!currentIds.includes(selectedCategoryId)) return;
setActionLoading(true);
try {
const newIds = currentIds.filter(id => id !== selectedCategoryId);
await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null });
toast.success("Page removed from category");
if (onPageMetaUpdate) {
onPageMetaUpdate({ ...currentPageMeta, categoryIds: newIds, categoryId: null });
}
} catch (error) {
console.error(error);
toast.error("Failed to unlink page");
} finally {
setActionLoading(false);
}
};
// Render Logic
const renderCategoryItem = (cat: Category, level: number = 0) => {
const isSelected = selectedCategoryId === cat.id;
const isLinked = linkedCategoryIds.includes(cat.id);
return (
<div key={cat.id}>
<div
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
)}
style={{ marginLeft: `${level * 16}px` }}
onClick={() => setSelectedCategoryId(cat.id)}
>
<div className="flex items-center gap-2">
{isLinked ? (
<Check className="h-4 w-4 text-primary" />
) : (
<FolderTree className="h-3 w-3 text-muted-foreground opacity-50" />
)}
<span className={cn("text-sm", isLinked && "font-semibold text-primary")}>{cat.name}</span>
<span className="text-xs text-muted-foreground">({cat.slug})</span>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); handleCreateStart(cat.id); }}>
<Plus className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); handleEditStart(cat); }}>
<Edit2 className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive" onClick={(e) => handleDelete(cat.id, e)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
{cat.children?.map(childRel => renderCategoryItem(childRel.child, level + 1))}
</div>
);
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle><T>Category Manager</T></DialogTitle>
<DialogDescription>
Manage categories and organize your content structure.
</DialogDescription>
</DialogHeader>
<div className="flex-1 flex gap-4 min-h-0">
{/* Left: Category Tree */}
<div className="flex-1 border rounded-md p-2 overflow-y-auto">
<div className="flex justify-between items-center mb-2 px-2">
<span className="text-sm font-semibold text-muted-foreground">Category Hierarchy</span>
<Button variant="ghost" size="sm" onClick={() => handleCreateStart(null)}>
<Plus className="h-3 w-3 mr-1" />
<T>Root Category</T>
</Button>
</div>
{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 => renderCategoryItem(cat))}
{categories.length === 0 && <div className="text-center text-sm text-muted-foreground py-8">No categories found.</div>}
</div>
)}
</div>
{/* Right: Editor or Actions */}
<div className="w-[300px] border rounded-md p-4 flex flex-col gap-4 overflow-y-auto bg-muted/10">
{editingCategory ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm">{isCreating ? "New Category" : "Edit Category"}</h3>
<Button variant="ghost" size="sm" onClick={() => setEditingCategory(null)}><X className="h-4 w-4" /></Button>
</div>
<div className="space-y-2">
<Label>Name</Label>
<Input
value={editingCategory.name}
onChange={(e) => {
const name = e.target.value;
const slug = isCreating ? name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') : editingCategory.slug;
setEditingCategory({ ...editingCategory, name, slug })
}}
/>
</div>
<div className="space-y-2">
<Label>Slug</Label>
<Input
value={editingCategory.slug}
onChange={(e) => setEditingCategory({ ...editingCategory, slug: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Visibility</Label>
<Select
value={editingCategory.visibility}
onValueChange={(val: any) => setEditingCategory({ ...editingCategory, visibility: val })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="public">Public</SelectItem>
<SelectItem value="unlisted">Unlisted</SelectItem>
<SelectItem value="private">Private</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input
value={editingCategory.description || ''}
onChange={(e) => setEditingCategory({ ...editingCategory, description: e.target.value })}
/>
</div>
<Button className="w-full" onClick={handleSave} disabled={actionLoading}>
{actionLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save
</Button>
</div>
) : selectedCategoryId ? (
<div className="space-y-4">
<div className="border-b pb-2">
<h3 className="font-semibold text-lg">{categories.find(c => c.id === selectedCategoryId)?.name || 'Selected'}</h3>
<p className="text-xs text-muted-foreground mb-1">{selectedCategoryId}</p>
{categories.find(c => c.id === selectedCategoryId)?.description && (
<p className="text-sm text-foreground/80 italic">
{categories.find(c => c.id === selectedCategoryId)?.description}
</p>
)}
</div>
{currentPageId && (
<div className="bg-background rounded p-3 border space-y-2">
<Label className="text-xs uppercase text-muted-foreground">Current Page Link</Label>
{linkedCategoryIds.includes(selectedCategoryId) ? (
<div className="text-sm">
<div className="flex items-center gap-2 text-green-600 mb-2">
<Check className="h-4 w-4" />
Page linked to this category
</div>
<Button size="sm" variant="outline" className="w-full" onClick={handleUnlinkPage} disabled={actionLoading}>
Remove from Category
</Button>
</div>
) : (
<Button size="sm" className="w-full" onClick={handleLinkPage} disabled={actionLoading}>
<LinkIcon className="mr-2 h-4 w-4" />
Add to Category
</Button>
)}
</div>
)}
<div className="text-xs text-muted-foreground">
Select a category to see actions or click edit/add icons in the tree.
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm text-center">
Select a category to manage or link.
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -41,19 +41,15 @@ export const WebSocketProvider: React.FC<WebSocketProviderProps> = ({ children,
// but simpler logic is often better.
const handleWsStatusChange = useCallback((status: WsStatus) => {
logger.info(`Context: WS Status Changed -> ${status}`);
setWsStatus(status);
setIsConnected(status === 'CONNECTED');
if (status === 'CONNECTED') {
logger.success('WebSocket Connected');
setConnecting(false);
} else {
if (status === 'ERROR') {
logger.error('WebSocket Connection Error');
setConnecting(false);
} else if (status === 'RECONNECTING') {
logger.warn('WebSocket Reconnecting...');
setConnecting(true);
} else if (status === 'DISCONNECTED') {
setConnecting(false);

View File

@ -117,8 +117,6 @@ export const useFeedData = ({
if (source === 'home' && !sourceId && currentPage === 0 && window.__INITIAL_STATE__?.feed) {
fetchedPosts = window.__INITIAL_STATE__.feed;
window.__INITIAL_STATE__.feed = undefined;
console.log('Hydrated feed', fetchedPosts);
}
// 2. API Fetch (Universal)
// Prioritize API if endpoint exists. Using API allows server-side handling of complicated logic.

View File

@ -327,4 +327,10 @@
.html-widget-container table {
max-width: 100% !important;
}
/* RJSF Textarea Dark Mode Fix */
/* Textareas inherit browser defaults which show white in dark mode */
textarea {
@apply bg-background text-foreground;
}
}

View File

@ -39,6 +39,75 @@ export type Database = {
}
public: {
Tables: {
categories: {
Row: {
created_at: string
description: string | null
id: string
name: string
owner_id: string | null
slug: string
updated_at: string
visibility: Database["public"]["Enums"]["category_visibility"]
}
Insert: {
created_at?: string
description?: string | null
id?: string
name: string
owner_id?: string | null
slug: string
updated_at?: string
visibility?: Database["public"]["Enums"]["category_visibility"]
}
Update: {
created_at?: string
description?: string | null
id?: string
name?: string
owner_id?: string | null
slug?: string
updated_at?: string
visibility?: Database["public"]["Enums"]["category_visibility"]
}
Relationships: []
}
category_relations: {
Row: {
child_category_id: string
created_at: string
parent_category_id: string
relation_type: Database["public"]["Enums"]["category_relation_type"]
}
Insert: {
child_category_id: string
created_at?: string
parent_category_id: string
relation_type: Database["public"]["Enums"]["category_relation_type"]
}
Update: {
child_category_id?: string
created_at?: string
parent_category_id?: string
relation_type?: Database["public"]["Enums"]["category_relation_type"]
}
Relationships: [
{
foreignKeyName: "category_relations_child_category_id_fkey"
columns: ["child_category_id"]
isOneToOne: false
referencedRelation: "categories"
referencedColumns: ["id"]
},
{
foreignKeyName: "category_relations_parent_category_id_fkey"
columns: ["parent_category_id"]
isOneToOne: false
referencedRelation: "categories"
referencedColumns: ["id"]
},
]
}
collection_pictures: {
Row: {
added_at: string
@ -390,6 +459,7 @@ export type Database = {
created_at: string
id: string
is_public: boolean
meta: Json | null
owner: string
parent: string | null
slug: string
@ -404,6 +474,7 @@ export type Database = {
created_at?: string
id?: string
is_public?: boolean
meta?: Json | null
owner: string
parent?: string | null
slug: string
@ -418,6 +489,7 @@ export type Database = {
created_at?: string
id?: string
is_public?: boolean
meta?: Json | null
owner?: string
parent?: string | null
slug?: string
@ -678,6 +750,201 @@ export type Database = {
}
Relationships: []
}
type_casts: {
Row: {
cast_kind: Database["public"]["Enums"]["cast_kind"]
description: string | null
from_type_id: string
to_type_id: string
}
Insert: {
cast_kind: Database["public"]["Enums"]["cast_kind"]
description?: string | null
from_type_id: string
to_type_id: string
}
Update: {
cast_kind?: Database["public"]["Enums"]["cast_kind"]
description?: string | null
from_type_id?: string
to_type_id?: string
}
Relationships: [
{
foreignKeyName: "type_casts_from_type_id_fkey"
columns: ["from_type_id"]
isOneToOne: false
referencedRelation: "types"
referencedColumns: ["id"]
},
{
foreignKeyName: "type_casts_to_type_id_fkey"
columns: ["to_type_id"]
isOneToOne: false
referencedRelation: "types"
referencedColumns: ["id"]
},
]
}
type_enum_values: {
Row: {
id: string
label: string
order: number
type_id: string
value: string
}
Insert: {
id?: string
label: string
order?: number
type_id: string
value: string
}
Update: {
id?: string
label?: string
order?: number
type_id?: string
value?: string
}
Relationships: [
{
foreignKeyName: "type_enum_values_type_id_fkey"
columns: ["type_id"]
isOneToOne: false
referencedRelation: "types"
referencedColumns: ["id"]
},
]
}
type_flag_values: {
Row: {
bit: number
id: string
name: string
type_id: string
}
Insert: {
bit: number
id?: string
name: string
type_id: string
}
Update: {
bit?: number
id?: string
name?: string
type_id?: string
}
Relationships: [
{
foreignKeyName: "type_flag_values_type_id_fkey"
columns: ["type_id"]
isOneToOne: false
referencedRelation: "types"
referencedColumns: ["id"]
},
]
}
type_structure_fields: {
Row: {
default_value: Json | null
field_name: string
field_type_id: string
id: string
order: number
required: boolean
structure_type_id: string
}
Insert: {
default_value?: Json | null
field_name: string
field_type_id: string
id?: string
order?: number
required?: boolean
structure_type_id: string
}
Update: {
default_value?: Json | null
field_name?: string
field_type_id?: string
id?: string
order?: number
required?: boolean
structure_type_id?: string
}
Relationships: [
{
foreignKeyName: "type_structure_fields_field_type_id_fkey"
columns: ["field_type_id"]
isOneToOne: false
referencedRelation: "types"
referencedColumns: ["id"]
},
{
foreignKeyName: "type_structure_fields_structure_type_id_fkey"
columns: ["structure_type_id"]
isOneToOne: false
referencedRelation: "types"
referencedColumns: ["id"]
},
]
}
types: {
Row: {
created_at: string
description: string | null
id: string
json_schema: Json | null
kind: Database["public"]["Enums"]["type_kind"]
meta: Json | null
name: string
owner_id: string | null
parent_type_id: string | null
settings: Json | null
updated_at: string
visibility: Database["public"]["Enums"]["type_visibility"]
}
Insert: {
created_at?: string
description?: string | null
id?: string
json_schema?: Json | null
kind: Database["public"]["Enums"]["type_kind"]
meta?: Json | null
name: string
owner_id?: string | null
parent_type_id?: string | null
settings?: Json | null
updated_at?: string
visibility?: Database["public"]["Enums"]["type_visibility"]
}
Update: {
created_at?: string
description?: string | null
id?: string
json_schema?: Json | null
kind?: Database["public"]["Enums"]["type_kind"]
meta?: Json | null
name?: string
owner_id?: string | null
parent_type_id?: string | null
settings?: Json | null
updated_at?: string
visibility?: Database["public"]["Enums"]["type_visibility"]
}
Relationships: [
{
foreignKeyName: "types_parent_type_id_fkey"
columns: ["parent_type_id"]
isOneToOne: false
referencedRelation: "types"
referencedColumns: ["id"]
},
]
}
user_filter_configs: {
Row: {
context: string
@ -954,7 +1221,18 @@ export type Database = {
| "comments.delete"
| "organization.manage"
app_role: "owner" | "admin" | "member" | "viewer"
cast_kind: "implicit" | "explicit" | "lossy"
category_relation_type:
| "generalization"
| "material_usage"
| "domain"
| "process_step"
| "standard"
| "other"
category_visibility: "public" | "unlisted" | "private"
collaborator_role: "viewer" | "editor" | "owner"
type_kind: "primitive" | "enum" | "flags" | "structure" | "alias"
type_visibility: "public" | "private" | "custom"
}
CompositeTypes: {
[_ in never]: never
@ -1101,7 +1379,19 @@ export const Constants = {
"organization.manage",
],
app_role: ["owner", "admin", "member", "viewer"],
cast_kind: ["implicit", "explicit", "lossy"],
category_relation_type: [
"generalization",
"material_usage",
"domain",
"process_step",
"standard",
"other",
],
category_visibility: ["public", "unlisted", "private"],
collaborator_role: ["viewer", "editor", "owner"],
type_kind: ["primitive", "enum", "flags", "structure", "alias"],
type_visibility: ["public", "private", "custom"],
},
},
} as const

View File

@ -1,973 +0,0 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "13.0.5"
}
public: {
Tables: {
collection_pictures: {
Row: {
added_at: string
collection_id: string
id: string
picture_id: string
}
Insert: {
added_at?: string
collection_id: string
id?: string
picture_id: string
}
Update: {
added_at?: string
collection_id?: string
id?: string
picture_id?: string
}
Relationships: [
{
foreignKeyName: "collection_pictures_collection_id_fkey"
columns: ["collection_id"]
isOneToOne: false
referencedRelation: "collections"
referencedColumns: ["id"]
},
{
foreignKeyName: "collection_pictures_picture_id_fkey"
columns: ["picture_id"]
isOneToOne: false
referencedRelation: "pictures"
referencedColumns: ["id"]
},
]
}
collections: {
Row: {
content: Json | null
created_at: string
description: string | null
id: string
is_public: boolean
layout: Json | null
name: string
slug: string
updated_at: string
user_id: string
}
Insert: {
content?: Json | null
created_at?: string
description?: string | null
id?: string
is_public?: boolean
layout?: Json | null
name: string
slug: string
updated_at?: string
user_id: string
}
Update: {
content?: Json | null
created_at?: string
description?: string | null
id?: string
is_public?: boolean
layout?: Json | null
name?: string
slug?: string
updated_at?: string
user_id?: string
}
Relationships: []
}
comment_likes: {
Row: {
comment_id: string
created_at: string
id: string
user_id: string
}
Insert: {
comment_id: string
created_at?: string
id?: string
user_id: string
}
Update: {
comment_id?: string
created_at?: string
id?: string
user_id?: string
}
Relationships: []
}
comments: {
Row: {
content: string
created_at: string
id: string
likes_count: number | null
parent_comment_id: string | null
picture_id: string
updated_at: string
user_id: string
}
Insert: {
content: string
created_at?: string
id?: string
likes_count?: number | null
parent_comment_id?: string | null
picture_id: string
updated_at?: string
user_id: string
}
Update: {
content?: string
created_at?: string
id?: string
likes_count?: number | null
parent_comment_id?: string | null
picture_id?: string
updated_at?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "comments_parent_fk"
columns: ["parent_comment_id"]
isOneToOne: false
referencedRelation: "comments"
referencedColumns: ["id"]
},
]
}
context_definitions: {
Row: {
created_at: string | null
default_filters: Json
default_templates: Json
description: string | null
display_name: string
icon: string | null
id: string
is_active: boolean | null
name: string
updated_at: string | null
}
Insert: {
created_at?: string | null
default_filters?: Json
default_templates?: Json
description?: string | null
display_name: string
icon?: string | null
id?: string
is_active?: boolean | null
name: string
updated_at?: string | null
}
Update: {
created_at?: string | null
default_filters?: Json
default_templates?: Json
description?: string | null
display_name?: string
icon?: string | null
id?: string
is_active?: boolean | null
name?: string
updated_at?: string | null
}
Relationships: []
}
filter_usage_logs: {
Row: {
context: string
created_at: string | null
error_message: string | null
filters_applied: string[] | null
id: string
input_length: number
model: string
output_length: number
processing_time_ms: number
provider: string
success: boolean
template_id: string | null
user_id: string | null
}
Insert: {
context: string
created_at?: string | null
error_message?: string | null
filters_applied?: string[] | null
id?: string
input_length: number
model: string
output_length: number
processing_time_ms: number
provider: string
success: boolean
template_id?: string | null
user_id?: string | null
}
Update: {
context?: string
created_at?: string | null
error_message?: string | null
filters_applied?: string[] | null
id?: string
input_length?: number
model?: string
output_length?: number
processing_time_ms?: number
provider?: string
success?: boolean
template_id?: string | null
user_id?: string | null
}
Relationships: [
{
foreignKeyName: "filter_usage_logs_template_id_fkey"
columns: ["template_id"]
isOneToOne: false
referencedRelation: "user_templates"
referencedColumns: ["id"]
},
]
}
likes: {
Row: {
created_at: string
id: string
picture_id: string
user_id: string
}
Insert: {
created_at?: string
id?: string
picture_id: string
user_id: string
}
Update: {
created_at?: string
id?: string
picture_id?: string
user_id?: string
}
Relationships: []
}
organizations: {
Row: {
created_at: string
id: string
name: string
slug: string
updated_at: string
}
Insert: {
created_at?: string
id?: string
name: string
slug: string
updated_at?: string
}
Update: {
created_at?: string
id?: string
name?: string
slug?: string
updated_at?: string
}
Relationships: []
}
pages: {
Row: {
content: Json | null
created_at: string
id: string
is_public: boolean
owner: string
parent: string | null
slug: string
tags: string[] | null
title: string
type: string | null
updated_at: string
visible: boolean
}
Insert: {
content?: Json | null
created_at?: string
id?: string
is_public?: boolean
owner: string
parent?: string | null
slug: string
tags?: string[] | null
title: string
type?: string | null
updated_at?: string
visible?: boolean
}
Update: {
content?: Json | null
created_at?: string
id?: string
is_public?: boolean
owner?: string
parent?: string | null
slug?: string
tags?: string[] | null
title?: string
type?: string | null
updated_at?: string
visible?: boolean
}
Relationships: [
{
foreignKeyName: "pages_parent_fkey"
columns: ["parent"]
isOneToOne: false
referencedRelation: "pages"
referencedColumns: ["id"]
},
]
}
pictures: {
Row: {
created_at: string
description: string | null
flags: string[] | null
id: string
image_url: string
is_selected: boolean
likes_count: number | null
meta: Json | null
organization_id: string | null
parent_id: string | null
tags: string[] | null
thumbnail_url: string | null
title: string
updated_at: string
user_id: string
visible: boolean
}
Insert: {
created_at?: string
description?: string | null
flags?: string[] | null
id?: string
image_url: string
is_selected?: boolean
likes_count?: number | null
meta?: Json | null
organization_id?: string | null
parent_id?: string | null
tags?: string[] | null
thumbnail_url?: string | null
title: string
updated_at?: string
user_id: string
visible?: boolean
}
Update: {
created_at?: string
description?: string | null
flags?: string[] | null
id?: string
image_url?: string
is_selected?: boolean
likes_count?: number | null
meta?: Json | null
organization_id?: string | null
parent_id?: string | null
tags?: string[] | null
thumbnail_url?: string | null
title?: string
updated_at?: string
user_id?: string
visible?: boolean
}
Relationships: [
{
foreignKeyName: "pictures_organization_id_fkey"
columns: ["organization_id"]
isOneToOne: false
referencedRelation: "organizations"
referencedColumns: ["id"]
},
{
foreignKeyName: "pictures_parent_id_fkey"
columns: ["parent_id"]
isOneToOne: false
referencedRelation: "pictures"
referencedColumns: ["id"]
},
]
}
profiles: {
Row: {
aimlapi_api_key: string | null
avatar_url: string | null
bio: string | null
bria_api_key: string | null
created_at: string
display_name: string | null
google_api_key: string | null
huggingface_api_key: string | null
id: string
openai_api_key: string | null
pages: Json | null
replicate_api_key: string | null
settings: Json | null
updated_at: string
user_id: string
username: string | null
}
Insert: {
aimlapi_api_key?: string | null
avatar_url?: string | null
bio?: string | null
bria_api_key?: string | null
created_at?: string
display_name?: string | null
google_api_key?: string | null
huggingface_api_key?: string | null
id?: string
openai_api_key?: string | null
pages?: Json | null
replicate_api_key?: string | null
settings?: Json | null
updated_at?: string
user_id: string
username?: string | null
}
Update: {
aimlapi_api_key?: string | null
avatar_url?: string | null
bio?: string | null
bria_api_key?: string | null
created_at?: string
display_name?: string | null
google_api_key?: string | null
huggingface_api_key?: string | null
id?: string
openai_api_key?: string | null
pages?: Json | null
replicate_api_key?: string | null
settings?: Json | null
updated_at?: string
user_id?: string
username?: string | null
}
Relationships: []
}
provider_configs: {
Row: {
base_url: string
created_at: string | null
display_name: string
id: string
is_active: boolean | null
models: Json
name: string
rate_limits: Json | null
settings: Json | null
updated_at: string | null
user_id: string | null
}
Insert: {
base_url: string
created_at?: string | null
display_name: string
id?: string
is_active?: boolean | null
models?: Json
name: string
rate_limits?: Json | null
settings?: Json | null
updated_at?: string | null
user_id?: string | null
}
Update: {
base_url?: string
created_at?: string | null
display_name?: string
id?: string
is_active?: boolean | null
models?: Json
name?: string
rate_limits?: Json | null
settings?: Json | null
updated_at?: string | null
user_id?: string | null
}
Relationships: []
}
role_permissions: {
Row: {
created_at: string
id: string
permission: Database["public"]["Enums"]["app_permission"]
role: Database["public"]["Enums"]["app_role"]
}
Insert: {
created_at?: string
id?: string
permission: Database["public"]["Enums"]["app_permission"]
role: Database["public"]["Enums"]["app_role"]
}
Update: {
created_at?: string
id?: string
permission?: Database["public"]["Enums"]["app_permission"]
role?: Database["public"]["Enums"]["app_role"]
}
Relationships: []
}
user_filter_configs: {
Row: {
context: string
created_at: string | null
custom_filters: Json | null
default_templates: string[] | null
id: string
is_default: boolean | null
model: string
provider: string
updated_at: string | null
user_id: string | null
variables: Json | null
}
Insert: {
context: string
created_at?: string | null
custom_filters?: Json | null
default_templates?: string[] | null
id?: string
is_default?: boolean | null
model?: string
provider?: string
updated_at?: string | null
user_id?: string | null
variables?: Json | null
}
Update: {
context?: string
created_at?: string | null
custom_filters?: Json | null
default_templates?: string[] | null
id?: string
is_default?: boolean | null
model?: string
provider?: string
updated_at?: string | null
user_id?: string | null
variables?: Json | null
}
Relationships: []
}
user_organizations: {
Row: {
created_at: string
id: string
organization_id: string
role: string
updated_at: string
user_id: string
}
Insert: {
created_at?: string
id?: string
organization_id: string
role?: string
updated_at?: string
user_id: string
}
Update: {
created_at?: string
id?: string
organization_id?: string
role?: string
updated_at?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "user_organizations_organization_id_fkey"
columns: ["organization_id"]
isOneToOne: false
referencedRelation: "organizations"
referencedColumns: ["id"]
},
]
}
user_roles: {
Row: {
created_at: string
id: string
organization_id: string | null
role: Database["public"]["Enums"]["app_role"]
updated_at: string
user_id: string
}
Insert: {
created_at?: string
id?: string
organization_id?: string | null
role: Database["public"]["Enums"]["app_role"]
updated_at?: string
user_id: string
}
Update: {
created_at?: string
id?: string
organization_id?: string | null
role?: Database["public"]["Enums"]["app_role"]
updated_at?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "user_roles_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["user_id"]
},
]
}
user_templates: {
Row: {
context: string
created_at: string | null
description: string | null
filters: string[] | null
format: string | null
id: string
is_public: boolean | null
model: string
name: string
prompt: string
provider: string
updated_at: string | null
usage_count: number | null
user_id: string | null
}
Insert: {
context: string
created_at?: string | null
description?: string | null
filters?: string[] | null
format?: string | null
id?: string
is_public?: boolean | null
model?: string
name: string
prompt: string
provider?: string
updated_at?: string | null
usage_count?: number | null
user_id?: string | null
}
Update: {
context?: string
created_at?: string | null
description?: string | null
filters?: string[] | null
format?: string | null
id?: string
is_public?: boolean | null
model?: string
name?: string
prompt?: string
provider?: string
updated_at?: string | null
usage_count?: number | null
user_id?: string | null
}
Relationships: []
}
videos: {
Row: {
created_at: string
description: string | null
flags: string[] | null
id: string
is_selected: boolean
likes_count: number | null
meta: Json | null
organization_id: string | null
parent_id: string | null
tags: string[] | null
thumbnail_url: string | null
title: string
updated_at: string
user_id: string
video_url: string
visible: boolean
}
Insert: {
created_at?: string
description?: string | null
flags?: string[] | null
id?: string
is_selected?: boolean
likes_count?: number | null
meta?: Json | null
organization_id?: string | null
parent_id?: string | null
tags?: string[] | null
thumbnail_url?: string | null
title: string
updated_at?: string
user_id: string
video_url: string
visible?: boolean
}
Update: {
created_at?: string
description?: string | null
flags?: string[] | null
id?: string
is_selected?: boolean
likes_count?: number | null
meta?: Json | null
organization_id?: string | null
parent_id?: string | null
tags?: string[] | null
thumbnail_url?: string | null
title?: string
updated_at?: string
user_id?: string
video_url?: string
visible?: boolean
}
Relationships: []
}
wizard_sessions: {
Row: {
created_at: string
generated_image_url: string | null
id: string
input_images: string[] | null
prompt: string
status: string
updated_at: string
user_id: string
}
Insert: {
created_at?: string
generated_image_url?: string | null
id?: string
input_images?: string[] | null
prompt?: string
status?: string
updated_at?: string
user_id: string
}
Update: {
created_at?: string
generated_image_url?: string | null
id?: string
input_images?: string[] | null
prompt?: string
status?: string
updated_at?: string
user_id?: string
}
Relationships: []
}
}
Views: {
[_ in never]: never
}
Functions: {
authorize: {
Args: {
_role: Database["public"]["Enums"]["app_role"]
_user_id: string
}
Returns: boolean
}
has_permission: {
Args: {
_permission: Database["public"]["Enums"]["app_permission"]
_user_id: string
}
Returns: boolean
}
}
Enums: {
app_permission:
| "pictures.read"
| "pictures.create"
| "pictures.update"
| "pictures.delete"
| "collections.read"
| "collections.create"
| "collections.update"
| "collections.delete"
| "comments.read"
| "comments.create"
| "comments.update"
| "comments.delete"
| "organization.manage"
app_role: "owner" | "admin" | "member" | "viewer"
}
CompositeTypes: {
[_ in never]: never
}
}
}
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
DefaultSchema["Views"])
? (DefaultSchema["Tables"] &
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof DatabaseWithoutInternals },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof DatabaseWithoutInternals },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never
export const Constants = {
public: {
Enums: {
app_permission: [
"pictures.read",
"pictures.create",
"pictures.update",
"pictures.delete",
"collections.read",
"collections.create",
"collections.update",
"collections.delete",
"comments.read",
"comments.create",
"comments.update",
"comments.delete",
"organization.manage",
],
app_role: ["owner", "admin", "member", "viewer"],
},
},
} as const

View File

@ -29,7 +29,7 @@ interface StoredCacheItem<T> {
timeout: number;
}
const fetchWithDeduplication = async <T>(
export const fetchWithDeduplication = async <T>(
key: string,
fetcher: () => Promise<T>,
timeout: number = 25000,
@ -43,13 +43,11 @@ const fetchWithDeduplication = async <T>(
try {
const item: StoredCacheItem<T> = JSON.parse(stored);
if (Date.now() - item.timestamp < item.timeout) {
console.debug(`[db] Local Cache HIT: ${key}`);
return item.value;
} else {
localStorage.removeItem(localKey); // Clean up expired
}
} catch (e) {
console.warn('Failed to parse persistent cache item', e);
localStorage.removeItem(localKey);
}
}
@ -57,7 +55,6 @@ const fetchWithDeduplication = async <T>(
// 2. Check Memory Cache (In-flight or recent)
if (!requestCache.has(key)) {
console.info(`[db] Cache MISS: ${key}`);
const promise = fetcher().then((data) => {
// Save to LocalStorage if requested and successful
if (storage === 'local' && typeof window !== 'undefined') {
@ -299,7 +296,6 @@ export const upsertPictures = async (pictures: Partial<PostMediaItem>[], client?
export const getUserSettings = async (userId: string, client?: SupabaseClient) => {
const supabase = client || defaultSupabase;
console.log('getUserSettings', userId);
return fetchWithDeduplication(`settings-${userId}`, async () => {
const { data, error } = await supabase
.from('profiles')
@ -399,7 +395,7 @@ export const fetchUserPage = async (userId: string, slug: string, client?: Supab
}
return await res.json();
}, 600000);
}, 10);
};
export const invalidateUserPageCache = (userId: string, slug: string) => {
@ -885,3 +881,103 @@ export const augmentFeedPosts = (posts: any[]): FeedPost[] => {
return p;
});
};
// --- Category Management ---
export interface Category {
id: string;
name: string;
slug: string;
description?: string;
owner_id?: string;
visibility: 'public' | 'unlisted' | 'private';
parent_category_id?: string;
created_at?: string;
children?: { child: Category }[];
}
export const fetchCategories = async (options?: { parentSlug?: string; includeChildren?: boolean }): Promise<Category[]> => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
const headers: HeadersInit = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const params = new URLSearchParams();
if (options?.parentSlug) params.append('parentSlug', options.parentSlug);
if (options?.includeChildren) params.append('includeChildren', String(options.includeChildren));
const res = await fetch(`/api/categories?${params.toString()}`, { headers });
if (!res.ok) throw new Error(`Failed to fetch categories: ${res.statusText}`);
return await res.json();
};
export const createCategory = async (category: Partial<Category> & { parentId?: string; relationType?: string }) => {
const { data: sessionData } = await defaultSupabase.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/categories', {
method: 'POST',
headers,
body: JSON.stringify(category)
});
if (!res.ok) throw new Error(`Failed to create category: ${res.statusText}`);
return await res.json();
};
export const updateCategory = async (id: string, updates: Partial<Category>) => {
const { data: sessionData } = await defaultSupabase.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/categories/${id}`, {
method: 'PATCH',
headers,
body: JSON.stringify(updates)
});
if (!res.ok) throw new Error(`Failed to update category: ${res.statusText}`);
return await res.json();
};
export const deleteCategory = async (id: string) => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
const headers: HeadersInit = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/api/categories/${id}`, {
method: 'DELETE',
headers
});
if (!res.ok) throw new Error(`Failed to delete category: ${res.statusText}`);
return await res.json();
};
export const updatePageMeta = async (pageId: string, metaUpdates: any) => {
// We need to merge with existing meta.
// Ideally we do this via a stored procedure or fetch-modify-save to avoid overwriting.
// Or Supabase jsonb_set / || operator.
// Using Supabase client directly since this interacts with 'pages' table
const { data: page, error: fetchError } = await defaultSupabase
.from('pages')
.select('meta')
.eq('id', pageId)
.single();
if (fetchError) throw fetchError;
const currentMeta = (page?.meta as any) || {};
const newMeta = { ...currentMeta, ...metaUpdates };
const { data, error } = await defaultSupabase
.from('pages')
.update({ meta: newMeta, updated_at: new Date().toISOString() })
.eq('id', pageId)
.select()
.single();
if (error) throw error;
return data;
};

View File

@ -1,409 +0,0 @@
/**
* Image Tools Usage Examples
*
* This file contains practical examples of using the LLM tool system
* for orchestrating AI workflows in the PM-Pics application.
*/
import { runTools, generateImageTool, optimizePromptTool, transcribeAudioTool } from '@/lib/openai';
import { supabase } from '@/integrations/supabase/client';
// ============================================================================
// Example 1: Simple Image Generation with Wizard Preset
// ============================================================================
export const generateImageWithWizard = async (prompt: string) => {
console.log('🎨 Generating image with wizard preset...');
const result = await runTools({
prompt: prompt,
preset: "image-wizard",
onToolCall: (toolCall) => {
if ('function' in toolCall) {
console.log('🔧 Tool called:', toolCall.function?.name);
}
},
onContent: (content) => {
console.log('💬 AI says:', content);
}
});
if (result.success) {
console.log('✅ Success! Generated', result.toolCalls.length, 'tool calls');
return result;
} else {
console.error('❌ Failed:', result.error);
throw new Error(result.error);
}
};
// Usage:
// const result = await generateImageWithWizard("A mystical forest with glowing mushrooms");
// ============================================================================
// Example 2: Speech to Image Workflow
// ============================================================================
export const generateImageFromSpeech = async (audioFile: File) => {
console.log('🎤 Starting speech-to-image workflow...');
// Get user's API key
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('User not authenticated');
const { data: secretData } = await supabase
.from('user_secrets')
.select('settings')
.eq('user_id', user.id)
.maybeSingle();
const settings = secretData?.settings as any;
const apiKey = settings?.api_keys?.openai_api_key;
if (!apiKey) {
throw new Error('Please add your OpenAI API key in profile settings');
}
const result = await runTools({
prompt: `I will provide an audio file. Please:
1. Transcribe the audio to text
2. Optimize the transcribed text as an image generation prompt
3. Generate an image using the optimized prompt
Audio file name: ${audioFile.name}`,
preset: "speech-to-image",
apiKey: apiKey,
onToolCall: (toolCall) => {
if ('function' in toolCall) {
const toolName = toolCall.function?.name || 'unknown';
console.log(`🔧 Step: ${toolName}`);
// Show user-friendly progress messages
const messages: Record<string, string> = {
'transcribe_audio': '🎤 Transcribing your speech...',
'optimize_prompt': '✨ Optimizing the prompt...',
'generate_image': '🎨 Generating your image...',
};
if (messages[toolName]) {
console.log(messages[toolName]);
}
}
}
});
return result;
};
// Usage:
// const audioFile = new File([audioBlob], 'recording.webm', { type: 'audio/webm' });
// const result = await generateImageFromSpeech(audioFile);
// ============================================================================
// Example 3: Custom Workflow with Multiple Models
// ============================================================================
export const generateImageWithCustomWorkflow = async (
prompt: string,
imageModel: string = 'google/gemini-3-pro-image-preview'
) => {
console.log('🎨 Custom workflow with model:', imageModel);
// Get API key
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('User not authenticated');
const { data: secretData } = await supabase
.from('user_secrets')
.select('settings')
.eq('user_id', user.id)
.maybeSingle();
const settings = secretData?.settings as any;
const apiKey = settings?.api_keys?.openai_api_key;
const result = await runTools({
prompt: prompt,
preset: {
name: 'Custom Image Generation',
description: 'Custom workflow with specific model selection',
model: 'gpt-4o-mini',
tools: [
optimizePromptTool(apiKey),
generateImageTool(apiKey),
],
systemPrompt: `You are an expert image generation assistant.
Always optimize prompts before generating images.
Use the ${imageModel} model for generation.
Provide clear explanations of what you're doing at each step.`,
},
apiKey,
onToolCall: (toolCall) => {
if ('function' in toolCall) {
console.log('🔧 Executing:', toolCall.function?.name);
console.log('📋 Arguments:', toolCall.function?.arguments);
}
},
maxIterations: 5
});
return result;
};
// Usage:
// const result = await generateImageWithCustomWorkflow(
// "A serene Japanese garden",
// "aimlapi/flux-pro/v1.1"
// );
// ============================================================================
// Example 4: Batch Image Generation
// ============================================================================
export const generateMultipleImages = async (prompts: string[]) => {
console.log('🎨 Generating', prompts.length, 'images...');
const results = [];
for (let i = 0; i < prompts.length; i++) {
console.log(`\n[${i + 1}/${prompts.length}] Processing: "${prompts[i]}"`);
try {
const result = await runTools({
prompt: prompts[i],
preset: 'text-to-image',
onToolCall: (toolCall) => {
if ('function' in toolCall) {
console.log(` 🔧 [${i + 1}] ${toolCall.function?.name}`);
}
}
});
results.push({
prompt: prompts[i],
success: result.success,
data: result,
});
} catch (error: any) {
console.error(` ❌ [${i + 1}] Failed:`, error.message);
results.push({
prompt: prompts[i],
success: false,
error: error.message,
});
}
}
const successCount = results.filter(r => r.success).length;
console.log(`\n✅ Completed: ${successCount}/${prompts.length} successful`);
return results;
};
// Usage:
// const prompts = [
// "A sunset over mountains",
// "A futuristic city skyline",
// "An underwater coral reef"
// ];
// const results = await generateMultipleImages(prompts);
// ============================================================================
// Example 5: React Hook for Image Generation
// ============================================================================
/**
* React hook for using image tools in components
*
* Usage in a component:
*
* const { generate, loading, result, error } = useImageTools();
*
* const handleClick = async () => {
* await generate("A beautiful landscape", "image-wizard");
* };
*/
import { useState, useCallback } from 'react';
interface UseImageToolsResult {
generate: (prompt: string, preset?: string) => Promise<void>;
loading: boolean;
result: any | null;
error: string | null;
progress: string;
}
export const useImageTools = (): UseImageToolsResult => {
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState<string>('');
const generate = useCallback(async (prompt: string, preset: string = 'image-wizard') => {
setLoading(true);
setError(null);
setProgress('Starting...');
try {
// Get user's API key
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('User not authenticated');
const { data: profile } = await supabase
.from('profiles')
.select('openai_api_key')
.eq('user_id', user.id)
.single();
const toolResult = await runTools({
prompt,
preset,
apiKey: profile?.openai_api_key,
onToolCall: (toolCall) => {
if ('function' in toolCall) {
const toolName = toolCall.function?.name || 'unknown';
setProgress(`Executing: ${toolName}`);
}
},
onContent: (content) => {
setProgress(content);
}
});
if (toolResult.success) {
setResult(toolResult);
setProgress('Completed!');
} else {
throw new Error(toolResult.error || 'Generation failed');
}
} catch (err: any) {
setError(err.message);
setProgress('');
} finally {
setLoading(false);
}
}, []);
return { generate, loading, result, error, progress };
};
// ============================================================================
// Example 6: Error Handling and Retry Logic
// ============================================================================
export const generateImageWithRetry = async (
prompt: string,
maxRetries: number = 3
) => {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`🔄 Attempt ${attempt}/${maxRetries}`);
const result = await runTools({
prompt,
preset: 'image-wizard',
onToolCall: (toolCall) => {
if ('function' in toolCall) {
console.log(` 🔧 ${toolCall.function?.name}`);
}
}
});
if (result.success) {
console.log(`✅ Success on attempt ${attempt}`);
return result;
} else {
throw new Error(result.error || 'Unknown error');
}
} catch (error: any) {
console.error(`❌ Attempt ${attempt} failed:`, error.message);
lastError = error;
if (attempt < maxRetries) {
// Wait before retry (exponential backoff)
const waitTime = Math.pow(2, attempt) * 1000;
console.log(`⏳ Waiting ${waitTime}ms before retry...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
}
}
throw new Error(`Failed after ${maxRetries} attempts: ${lastError?.message}`);
};
// Usage:
// try {
// const result = await generateImageWithRetry("A magical castle", 3);
// console.log('Image generated successfully!');
// } catch (error) {
// console.error('All attempts failed:', error.message);
// }
// ============================================================================
// Example 7: Integration with Supabase Storage
// ============================================================================
export const generateAndSaveImage = async (
prompt: string,
title: string,
userId: string
) => {
console.log('🎨 Generating and saving image...');
// Generate image
const result = await generateImageWithWizard(prompt);
if (!result.success) {
throw new Error('Image generation failed');
}
// Extract image data from tool results
const imageToolCall = result.toolCalls.find(
tc => 'function' in tc && tc.function?.name === 'generate_image'
);
if (!imageToolCall || !('function' in imageToolCall)) {
throw new Error('No image generated');
}
const imageResult = JSON.parse(imageToolCall.function.arguments);
// In a real implementation, you would:
// 1. Convert imageUrl/imageData to a Blob
// 2. Upload to Supabase Storage
// 3. Save metadata to pictures table
console.log('💾 Saving to database...');
const { data: picture, error } = await supabase
.from('pictures')
.insert({
user_id: userId,
title,
description: prompt,
image_url: imageResult.imageUrl, // This would be the Supabase Storage URL
meta: {
generated_with: 'image-tools',
original_prompt: prompt,
tool_calls: result.toolCalls.length,
}
})
.select()
.single();
if (error) throw error;
console.log('✅ Image saved!', picture.id);
return picture;
};
// Usage:
// const picture = await generateAndSaveImage(
// "A mystical forest",
// "Mystical Forest Scene",
// userId
// );

View File

@ -5,9 +5,12 @@ import { Navigate } from "react-router-dom";
import { T } from "@/i18n";
import { SidebarProvider } from "@/components/ui/sidebar";
import { AdminSidebar, AdminActiveSection } from "@/components/admin/AdminSidebar";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { Server, RefreshCw } from "lucide-react";
const AdminPage = () => {
const { user, loading, roles } = useAuth();
const { user, session, loading, roles } = useAuth();
const [activeSection, setActiveSection] = useState<AdminActiveSection>('users');
if (loading) {
@ -32,6 +35,7 @@ const AdminPage = () => {
<div className="max-w-7xl mx-auto">
{activeSection === 'users' && <UserManagerSection />}
{activeSection === 'dashboard' && <DashboardSection />}
{activeSection === 'server' && <ServerSection session={session} />}
</div>
</main>
</div>
@ -53,4 +57,66 @@ const DashboardSection = () => (
</div>
);
const ServerSection = ({ session }: { session: any }) => {
const [loading, setLoading] = useState(false);
const handleFlushCache = async () => {
try {
setLoading(true);
const res = await fetch(`${import.meta.env.VITE_SERVER_IMAGE_API_URL}/api/flush-cache`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${session?.access_token || ''}`
}
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to flush cache');
}
toast.success("Cache flushed successfully", {
description: "Access and Content caches have been cleared."
});
} catch (err: any) {
toast.error("Failed to flush cache", {
description: err.message
});
} finally {
setLoading(false);
}
};
return (
<div>
<div className="flex items-center gap-2 mb-6">
<Server className="h-6 w-6" />
<h1 className="text-2xl font-bold">Server Management</h1>
</div>
<div className="bg-card border rounded-lg p-6 max-w-2xl">
<h2 className="text-lg font-semibold mb-4">Cache Control</h2>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Flush System Cache</p>
<p className="text-sm text-muted-foreground mt-1">
Clears the server-side content cache (memory) and the disk-based image cache.
Use this if content is not updating correctly.
</p>
</div>
<Button
onClick={handleFlushCache}
disabled={loading}
variant="destructive"
>
{loading && <RefreshCw className="mr-2 h-4 w-4 animate-spin" />}
Flush Cache
</Button>
</div>
</div>
</div>
);
};
export default AdminPage;

View File

@ -1,4 +1,5 @@
import React from "react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/useAuth";
import { useNavigate, Navigate } from "react-router-dom";
import { CreationWizardPopup } from "@/components/CreationWizardPopup";
@ -6,7 +7,6 @@ import { toast } from "sonner";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
const NewPost = () => {
const { user, loading } = useAuth();
@ -22,81 +22,37 @@ const NewPost = () => {
setDebugLogs(prev => [...prev, new Date().toISOString().split('T')[1].split('.')[0] + ': ' + msg]);
};
React.useEffect(() => {
const checkShared = async () => {
const params = new URLSearchParams(window.location.search);
if (params.get('shared') === 'true') {
try {
// Dynamic import to avoid SSR/build issues if any, though likely fine as is
const { get, del } = await import('idb-keyval');
const data = await get('share-target');
if (data) {
console.log("Found shared data:", data);
const { files, title, text, url, items } = data; // Destructure items
let newImages = [];
// Handle virtual items (pre-processed in GlobalDragDrop)
if (items && items.length > 0) {
newImages = [...newImages, ...items];
}
// Handle raw files (legacy share target)
if (files && files.length > 0) {
const fileImages = Array.from(files).map((f: any) => ({
id: `shared-${Date.now()}-${Math.random()}`,
file: f,
src: URL.createObjectURL(f),
title: f.name,
selected: true,
isGenerated: false,
type: f.type.startsWith('video/') ? 'video' : 'image'
}));
newImages = [...newImages, ...fileImages];
}
// Handle raw URL share (Mobile Share Target)
if (newImages.length === 0 && (url || (text && (text.startsWith('http://') || text.startsWith('https://'))))) {
const targetUrl = url || text;
toast.info("Processing shared link...");
const virtualItem = await processSharedUrl(targetUrl);
newImages = [virtualItem];
}
if (newImages.length > 0) {
setSharedImages(newImages);
}
if (title) setSharedTitle(title);
let description = text || "";
if (url) description += (description ? "\n\n" : "") + url;
setSharedText(description);
// Clean up
await del('share-target');
}
} catch (err) {
console.error("Error reading shared data:", err);
}
}
};
checkShared();
}, []);
if (loading) {
return <div className="min-h-screen bg-background pt-14 flex items-center justify-center">Loading...</div>;
}
// Helper to process URL like GlobalDragDrop does
const processSharedUrl = async (url: string) => {
addLog(`Processing shared URL: ${url}`);
try {
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || 'http://localhost:3333';
addLog(`Fetching site info from: ${serverUrl}/api/serving/site-info`);
// Use relative path for API calls to support mobile/PWA proxying
// Fallback to localhost only if strictly needed in dev without proxy, but empty string is safer for Vite proxy
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || '';
addLog(`Fetching site info from: ${serverUrl || '/api'}/serving/site-info`);
const response = await fetch(`${serverUrl}/api/serving/site-info?url=${encodeURIComponent(url)}`);
const { data: { session } } = await supabase.auth.getSession();
const headers: Record<string, string> = {};
if (session?.access_token) {
headers['Authorization'] = `Bearer ${session.access_token}`;
}
// Ensure we have a valid URL protocol
let targetUrl = url;
if (!targetUrl.startsWith('http://') && !targetUrl.startsWith('https://')) {
// If just a domain or text, try to extract or prepend
const urlMatch = url.match(/(https?:\/\/[^\s]+)/g);
if (urlMatch && urlMatch.length > 0) {
targetUrl = urlMatch[0];
} else {
// fallback
targetUrl = 'https://' + url;
}
}
const response = await fetch(`${serverUrl}/api/serving/site-info?url=${encodeURIComponent(targetUrl)}`, {
headers
});
addLog(`Response status: ${response.status}`);
let siteInfo = {};
@ -141,6 +97,90 @@ const NewPost = () => {
}
};
React.useEffect(() => {
const checkShared = async () => {
const params = new URLSearchParams(window.location.search);
if (params.get('shared') === 'true') {
try {
// Dynamic import to avoid SSR/build issues if any, though likely fine as is
const { get, del } = await import('idb-keyval');
const data = await get('share-target');
if (data) {
console.log("Found shared data:", data);
const { files, title, text, url, items } = data; // Destructure items
let newImages = [];
// Handle virtual items (pre-processed in GlobalDragDrop)
if (items && items.length > 0) {
newImages = [...newImages, ...items];
}
// Handle raw files (legacy share target)
if (files && files.length > 0) {
const fileImages = Array.from(files).map((f: any) => ({
id: `shared-${Date.now()}-${Math.random()}`,
file: f,
src: URL.createObjectURL(f),
title: f.name,
selected: true,
isGenerated: false,
type: f.type.startsWith('video/') ? 'video' : 'image'
}));
newImages = [...newImages, ...fileImages];
}
// Handle raw URL share (Mobile Share Target)
// TikTok/others often share text like "Check this out https://vm.tiktok.com/..."
const urlRegex = /(https?:\/\/[^\s]+)/g;
const textHasUrl = text && urlRegex.test(text);
if (newImages.length === 0 && (url || textHasUrl)) {
let targetUrl = url;
if (!targetUrl && textHasUrl) {
const match = text.match(urlRegex);
if (match && match.length > 0) targetUrl = match[0];
}
if (targetUrl) {
toast.info("Processing shared link...");
const virtualItem = await processSharedUrl(targetUrl);
newImages = [virtualItem];
// If we extracted the URL from the text, we might want to clean up the sharedText
// or keep it as the description?
// Let's keep the full text as description for context,
// but if the text was ONLY the URL, we might want to clear it?
// For now, keep as is.
}
}
if (newImages.length > 0) {
setSharedImages(newImages);
}
if (title) setSharedTitle(title);
let description = text || "";
if (url) description += (description ? "\n\n" : "") + url;
setSharedText(description);
// Clean up
await del('share-target');
}
} catch (err) {
console.error("Error reading shared data:", err);
}
}
};
checkShared();
}, []);
if (loading) {
return <div className="min-h-screen bg-background pt-14 flex items-center justify-center">Loading...</div>;
}
if (!user) {
return <Navigate to="/auth" replace />;
}

View File

@ -5,7 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ArrowLeft, FileText, Calendar, Eye, EyeOff, Edit, Edit3, Check, X, Plus, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { ArrowLeft, FileText, Calendar, Eye, EyeOff, Edit, Edit3, Check, X, Plus, PanelLeftClose, PanelLeftOpen, FolderTree } from "lucide-react";
import { T, translate } from "@/i18n";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
@ -37,6 +37,17 @@ interface Page {
visible: boolean;
created_at: string;
updated_at: string;
meta?: any;
category_paths?: {
id: string;
name: string;
slug: string;
}[][];
categories?: { // Legacy/fallback support
id: string;
name: string;
slug: string;
}[];
}
interface UserProfile {
@ -528,6 +539,31 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
})}
</div>
</div>
{(() => {
// Normalize paths from either new category_paths or legacy categories field (for cache compat)
const displayPaths = page.category_paths ||
(page.categories?.map(c => [c]) || []);
if (displayPaths.length === 0) return null;
return (
<div className="flex flex-col gap-1 mt-1">
{displayPaths.map((path, pathIdx) => (
<div key={pathIdx} className="flex items-center gap-2 text-sm text-muted-foreground">
<FolderTree className="h-4 w-4 shrink-0" />
{path.map((cat, idx) => (
<span key={cat.id} className="flex items-center">
{idx > 0 && <span className="mx-1 text-muted-foreground/50">/</span>}
<Link to={`/categories/${cat.slug}`} className="hover:text-primary transition-colors hover:underline">
{cat.name}
</Link>
</span>
))}
</div>
))}
</div>
);
})()}
</div>

View File

@ -196,7 +196,6 @@ class ModbusService {
const connectingState = (this.status === 'DISCONNECTED' || this.status === 'ERROR') ? 'CONNECTING' : 'RECONNECTING';
this.updateStatus(connectingState);
console.log(`Attempting WebSocket connection to ${this.wsUrl}...`);
try {
const newWs = new WebSocket(this.wsUrl);
@ -204,7 +203,6 @@ class ModbusService {
this.ws = newWs;
newWs.onopen = () => {
console.log('WebSocket Connected!');
this.updateStatus('CONNECTED');
if ((window as any).markAppAsReady) {
(window as any).markAppAsReady();

View File

@ -64,6 +64,29 @@ 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) => {