sw | layout | types | categories
This commit is contained in:
parent
e8af7b8dd0
commit
dbf0b0d3c2
@ -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 */}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -117,7 +117,7 @@ export const ListLayout = ({
|
||||
sortBy
|
||||
});
|
||||
|
||||
console.log('posts', feedPosts);
|
||||
// console.log('posts', feedPosts);
|
||||
|
||||
const handleItemClick = (item: any) => {
|
||||
if (isMobile) {
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import React, { useMemo, useEffect, useRef } from 'react';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import React, { useMemo, useEffect, useRef, useState, Suspense } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import HashtagText from './HashtagText';
|
||||
import Prism from 'prismjs';
|
||||
import ResponsiveImage from './ResponsiveImage';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
// Import type from Post page (assuming relative path from src/components to src/pages/Post/types.ts)
|
||||
import { PostMediaItem } from '../pages/Post/types';
|
||||
|
||||
import 'prismjs/components/prism-typescript';
|
||||
import 'prismjs/components/prism-javascript';
|
||||
@ -12,15 +15,77 @@ import 'prismjs/components/prism-css';
|
||||
import 'prismjs/components/prism-markup';
|
||||
import '../styles/prism-custom-theme.css';
|
||||
|
||||
// Lazy load SmartLightbox to avoid circular deps or heavy bundle on initial load
|
||||
const SmartLightbox = React.lazy(() => import('../pages/Post/components/SmartLightbox'));
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Helper function to format URL display text (ported from previous implementation)
|
||||
const formatUrlDisplay = (url: string): string => {
|
||||
try {
|
||||
// Remove protocol
|
||||
let displayUrl = url.replace(/^https?:\/\//, '');
|
||||
|
||||
// Remove www. if present
|
||||
displayUrl = displayUrl.replace(/^www\./, '');
|
||||
|
||||
// Truncate if too long (keep domain + some path)
|
||||
if (displayUrl.length > 40) {
|
||||
const parts = displayUrl.split('/');
|
||||
const domain = parts[0];
|
||||
const path = parts.slice(1).join('/');
|
||||
|
||||
if (path.length > 20) {
|
||||
displayUrl = `${domain}/${path.substring(0, 15)}...`;
|
||||
} else {
|
||||
displayUrl = `${domain}/${path}`;
|
||||
}
|
||||
}
|
||||
|
||||
return displayUrl;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper for slugifying headings
|
||||
const slugify = (text: string) => {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
};
|
||||
|
||||
const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRendererProps) => {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const { user } = useAuth();
|
||||
|
||||
// Memoize content analysis
|
||||
// Lightbox state
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
||||
|
||||
// Extract all images from content for navigation
|
||||
const allImages = useMemo(() => {
|
||||
const images: { src: string, alt: string }[] = [];
|
||||
const regex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
let match;
|
||||
// We clone the regex to avoid stateful issues if reuse happens, though local var is fine
|
||||
const localRegex = new RegExp(regex);
|
||||
while ((match = localRegex.exec(content)) !== null) {
|
||||
images.push({
|
||||
alt: match[1],
|
||||
src: match[2]
|
||||
});
|
||||
}
|
||||
return images;
|
||||
}, [content]);
|
||||
|
||||
// Memoize content analysis (keep existing logic for simple hashtag views)
|
||||
const contentAnalysis = useMemo(() => {
|
||||
const hasHashtags = /#[a-zA-Z0-9_]+/.test(content);
|
||||
const hasMarkdownLinks = /\[.*?\]\(.*?\)/.test(content);
|
||||
@ -33,8 +98,50 @@ const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRender
|
||||
};
|
||||
}, [content]);
|
||||
|
||||
// Apply syntax highlighting after render
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
Prism.highlightAllUnder(containerRef.current);
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
const handleImageClick = (src: string) => {
|
||||
const index = allImages.findIndex(img => img.src === src);
|
||||
if (index !== -1) {
|
||||
setCurrentImageIndex(index);
|
||||
setLightboxOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigate = (direction: 'prev' | 'next') => {
|
||||
if (direction === 'prev') {
|
||||
setCurrentImageIndex(prev => (prev > 0 ? prev - 1 : prev));
|
||||
} else {
|
||||
setCurrentImageIndex(prev => (prev < allImages.length - 1 ? prev + 1 : prev));
|
||||
}
|
||||
};
|
||||
|
||||
// Mock MediaItem for SmartLightbox
|
||||
const mockMediaItem = useMemo((): PostMediaItem | null => {
|
||||
const selectedImage = allImages[currentImageIndex];
|
||||
if (!selectedImage) return null;
|
||||
return {
|
||||
id: 'md-' + btoa(selectedImage.src).substring(0, 10), // stable ID based on SRC
|
||||
title: selectedImage.alt || 'Image',
|
||||
description: '',
|
||||
image_url: selectedImage.src,
|
||||
thumbnail_url: selectedImage.src,
|
||||
user_id: user?.id || 'unknown',
|
||||
type: 'image',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
position: 0,
|
||||
likes_count: 0,
|
||||
post_id: null
|
||||
};
|
||||
}, [currentImageIndex, allImages, user]);
|
||||
|
||||
// Only use HashtagText if content has hashtags but NO markdown syntax at all
|
||||
// This preserves hashtag linking for simple text while allowing markdown to render properly
|
||||
if (contentAnalysis.hasHashtags && !contentAnalysis.hasMarkdownLinks && !contentAnalysis.hasMarkdownSyntax) {
|
||||
return (
|
||||
<div className={`prose prose-sm max-w-none dark:prose-invert ${className}`}>
|
||||
@ -43,100 +150,118 @@ const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRender
|
||||
);
|
||||
}
|
||||
|
||||
// Memoize the expensive HTML processing
|
||||
const htmlContent = useMemo(() => {
|
||||
// Decode HTML entities first if present
|
||||
const decodedContent = content
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
|
||||
|
||||
// Configure marked options
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
});
|
||||
|
||||
// Custom renderer to add IDs to headings
|
||||
const renderer = new marked.Renderer();
|
||||
renderer.heading = ({ text, depth }: { text: string; depth: number }) => {
|
||||
const slug = text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return `<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
|
||||
const formatUrlDisplay = (url: string): string => {
|
||||
try {
|
||||
// Remove protocol
|
||||
let displayUrl = url.replace(/^https?:\/\//, '');
|
||||
|
||||
// Remove www. if present
|
||||
displayUrl = displayUrl.replace(/^www\./, '');
|
||||
|
||||
// Truncate if too long (keep domain + some path)
|
||||
if (displayUrl.length > 40) {
|
||||
const parts = displayUrl.split('/');
|
||||
const domain = parts[0];
|
||||
const path = parts.slice(1).join('/');
|
||||
|
||||
if (path.length > 20) {
|
||||
displayUrl = `${domain}/${path.substring(0, 15)}...`;
|
||||
} else {
|
||||
displayUrl = `${domain}/${path}`;
|
||||
}
|
||||
}
|
||||
|
||||
return displayUrl;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
// Post-process to add target="_blank", styling, and format link text
|
||||
const processedHtml = rawHtml.replace(
|
||||
/<a 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;
|
||||
|
||||
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>`;
|
||||
}
|
||||
);
|
||||
|
||||
return DOMPurify.sanitize(processedHtml, {
|
||||
ADD_ATTR: ['target', 'rel', 'class'] // Allow target, rel, and class attributes for links
|
||||
});
|
||||
}, [content]);
|
||||
|
||||
// Apply syntax highlighting after render
|
||||
React.useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
Prism.highlightAllUnder(containerRef.current);
|
||||
}
|
||||
}, [htmlContent]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`prose prose-sm max-w-none dark:prose-invert ${className}`}
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`prose prose-sm max-w-none dark:prose-invert ${className}`}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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;
|
||||
269
packages/ui/src/components/types/RJSFTemplates.tsx
Normal file
269
packages/ui/src/components/types/RJSFTemplates.tsx
Normal 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,
|
||||
};
|
||||
598
packages/ui/src/components/types/TypeBuilder.tsx
Normal file
598
packages/ui/src/components/types/TypeBuilder.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
291
packages/ui/src/components/types/TypeRenderer.tsx
Normal file
291
packages/ui/src/components/types/TypeRenderer.tsx
Normal 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;
|
||||
342
packages/ui/src/components/types/TypesPlayground.tsx
Normal file
342
packages/ui/src/components/types/TypesPlayground.tsx
Normal 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;
|
||||
179
packages/ui/src/components/types/db.ts
Normal file
179
packages/ui/src/components/types/db.ts
Normal 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;
|
||||
};
|
||||
71
packages/ui/src/components/types/randomDataGenerator.ts
Normal file
71
packages/ui/src/components/types/randomDataGenerator.ts
Normal 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;
|
||||
};
|
||||
348
packages/ui/src/components/widgets/CategoryManager.tsx
Normal file
348
packages/ui/src/components/widgets/CategoryManager.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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
|
||||
// );
|
||||
|
||||
@ -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) {
|
||||
@ -21,36 +24,99 @@ const AdminPage = () => {
|
||||
}
|
||||
|
||||
if (!roles.includes('admin')) {
|
||||
return <Navigate to="/" replace />;
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="min-h-screen flex w-full bg-background pt-14">
|
||||
<AdminSidebar activeSection={activeSection} onSectionChange={setActiveSection} />
|
||||
<main className="flex-1 p-8 overflow-auto">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{activeSection === 'users' && <UserManagerSection />}
|
||||
{activeSection === 'dashboard' && <DashboardSection />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="min-h-screen flex w-full bg-background pt-14">
|
||||
<AdminSidebar activeSection={activeSection} onSectionChange={setActiveSection} />
|
||||
<main className="flex-1 p-8 overflow-auto">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{activeSection === 'users' && <UserManagerSection />}
|
||||
{activeSection === 'dashboard' && <DashboardSection />}
|
||||
{activeSection === 'server' && <ServerSection session={session} />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const UserManagerSection = () => (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">User Management</h1>
|
||||
<UserManager />
|
||||
<h1 className="text-2xl font-bold mb-4">User Management</h1>
|
||||
<UserManager />
|
||||
</div>
|
||||
);
|
||||
|
||||
const DashboardSection = () => (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Dashboard</h1>
|
||||
<p>Welcome to the admin dashboard. More features coming soon!</p>
|
||||
<h1 className="text-2xl font-bold mb-4">Dashboard</h1>
|
||||
<p>Welcome to the admin dashboard. More features coming soon!</p>
|
||||
</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;
|
||||
|
||||
@ -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 />;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -676,7 +712,7 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>);
|
||||
</div >);
|
||||
};
|
||||
|
||||
export default UserPage;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user