html widget | i18n | fixes

This commit is contained in:
lovebird 2026-02-12 19:12:08 +01:00
parent c5db29352b
commit 8164a960df
46 changed files with 2696 additions and 4005 deletions

View File

@ -27,7 +27,7 @@ import UserProfile from "./pages/UserProfile";
import UserCollections from "./pages/UserCollections"; import UserCollections from "./pages/UserCollections";
import Collections from "./pages/Collections"; import Collections from "./pages/Collections";
import NewCollection from "./pages/NewCollection"; import NewCollection from "./pages/NewCollection";
import UserPage from "./pages/UserPage"; const UserPage = React.lazy(() => import("./pages/UserPage"));
import NewPage from "./pages/NewPage"; import NewPage from "./pages/NewPage";
import TagPage from "./pages/TagPage"; import TagPage from "./pages/TagPage";
import SearchResults from "./pages/SearchResults"; import SearchResults from "./pages/SearchResults";
@ -35,6 +35,8 @@ import Wizard from "./pages/Wizard";
import NewPost from "./pages/NewPost"; import NewPost from "./pages/NewPost";
import Organizations from "./pages/Organizations"; import Organizations from "./pages/Organizations";
import LogsPage from "./components/logging/LogsPage";
const ProviderSettings = React.lazy(() => import("./pages/ProviderSettings")); const ProviderSettings = React.lazy(() => import("./pages/ProviderSettings"));
const PlaygroundEditor = React.lazy(() => import("./pages/PlaygroundEditor")); const PlaygroundEditor = React.lazy(() => import("./pages/PlaygroundEditor"));
const PlaygroundEditorLLM = React.lazy(() => import("./pages/PlaygroundEditorLLM")); const PlaygroundEditorLLM = React.lazy(() => import("./pages/PlaygroundEditorLLM"));
@ -49,7 +51,9 @@ const VideoGenPlayground = React.lazy(() => import("./pages/VideoGenPlayground")
const PlaygroundCanvas = React.lazy(() => import("./pages/PlaygroundCanvas")); const PlaygroundCanvas = React.lazy(() => import("./pages/PlaygroundCanvas"));
const TypesPlayground = React.lazy(() => import("./components/types/TypesPlayground")); const TypesPlayground = React.lazy(() => import("./components/types/TypesPlayground"));
const Tetris = React.lazy(() => import("./apps/tetris/Tetris")); const Tetris = React.lazy(() => import("./apps/tetris/Tetris"));
import LogsPage from "./components/logging/LogsPage"; const I18nPlayground = React.lazy(() => import("./components/playground/I18nPlayground"));
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@ -79,7 +83,7 @@ const AppWrapper = () => {
<Route path="/user/:userId" element={<UserProfile />} /> <Route path="/user/:userId" element={<UserProfile />} />
<Route path="/user/:userId/collections" element={<UserCollections />} /> <Route path="/user/:userId/collections" element={<UserCollections />} />
<Route path="/user/:userId/pages/new" element={<NewPage />} /> <Route path="/user/:userId/pages/new" element={<NewPage />} />
<Route path="/user/:username/pages/:slug" element={<UserPage />} /> <Route path="/user/:username/pages/:slug" element={<React.Suspense fallback={<div>Loading...</div>}><UserPage /></React.Suspense>} />
<Route path="/collections/new" element={<NewCollection />} /> <Route path="/collections/new" element={<NewCollection />} />
<Route path="/collections/:userId/:slug" element={<Collections />} /> <Route path="/collections/:userId/:slug" element={<Collections />} />
<Route path="/tags/:tag" element={<TagPage />} /> <Route path="/tags/:tag" element={<TagPage />} />
@ -112,7 +116,7 @@ const AppWrapper = () => {
<Route path="/org/:orgSlug/user/:userId" element={<UserProfile />} /> <Route path="/org/:orgSlug/user/:userId" element={<UserProfile />} />
<Route path="/org/:orgSlug/user/:userId/collections" element={<UserCollections />} /> <Route path="/org/:orgSlug/user/:userId/collections" element={<UserCollections />} />
<Route path="/org/:orgSlug/user/:userId/pages/new" element={<NewPage />} /> <Route path="/org/:orgSlug/user/:userId/pages/new" element={<NewPage />} />
<Route path="/org/:orgSlug/user/:username/pages/:slug" element={<UserPage />} /> <Route path="/org/:orgSlug/user/:username/pages/:slug" element={<React.Suspense fallback={<div>Loading...</div>}><UserPage /></React.Suspense>} />
<Route path="/org/:orgSlug/collections/new" element={<NewCollection />} /> <Route path="/org/:orgSlug/collections/new" element={<NewCollection />} />
<Route path="/org/:orgSlug/collections/:userId/:slug" element={<Collections />} /> <Route path="/org/:orgSlug/collections/:userId/:slug" element={<Collections />} />
<Route path="/org/:orgSlug/tags/:tag" element={<TagPage />} /> <Route path="/org/:orgSlug/tags/:tag" element={<TagPage />} />
@ -138,7 +142,9 @@ const AppWrapper = () => {
<Route path="/playground/image-editor" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundImageEditor /></React.Suspense>} /> <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/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/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="/types-editor" element={<React.Suspense fallback={<div>Loading...</div>}><TypesPlayground /></React.Suspense>} />
<Route path="/playground/i18n" element={<React.Suspense fallback={<div>Loading...</div>}><I18nPlayground /></React.Suspense>} />
<Route path="/org/:orgSlug/types-editor" element={<React.Suspense fallback={<div>Loading...</div>}><TypesPlayground /></React.Suspense>} />
<Route path="/test-cache/:id" element={<CacheTest />} /> <Route path="/test-cache/:id" element={<CacheTest />} />
{/* Logs */} {/* Logs */}
@ -167,44 +173,53 @@ import { StreamInvalidator } from "@/components/StreamInvalidator";
// ... (imports) // ... (imports)
import { ActionProvider } from "@/actions/ActionProvider";
import { HelmetProvider } from 'react-helmet-async';
// ... previous imports ...
const App = () => { const App = () => {
React.useEffect(() => { React.useEffect(() => {
initFormatDetection(); initFormatDetection();
}, []); }, []);
return ( return (
<SWRConfig value={{ provider: () => new Map() }}> <HelmetProvider>
<QueryClientProvider client={queryClient}> <SWRConfig value={{ provider: () => new Map() }}>
<AuthProvider> <QueryClientProvider client={queryClient}>
<LogProvider> <AuthProvider>
<PostNavigationProvider> <LogProvider>
<MediaRefreshProvider> <PostNavigationProvider>
<LayoutProvider> <MediaRefreshProvider>
<TooltipProvider> <LayoutProvider>
<Toaster /> <TooltipProvider>
<Sonner /> <Toaster />
<BrowserRouter> <Sonner />
<OrganizationProvider> <ActionProvider>
<ProfilesProvider> <BrowserRouter>
<WebSocketProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}> <OrganizationProvider>
<StreamProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}> <ProfilesProvider>
<StreamInvalidator /> <WebSocketProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
<FeedCacheProvider> <StreamProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
<AppWrapper /> <StreamInvalidator />
</FeedCacheProvider> <FeedCacheProvider>
</StreamProvider> <AppWrapper />
</WebSocketProvider> </FeedCacheProvider>
</ProfilesProvider> </StreamProvider>
</OrganizationProvider> </WebSocketProvider>
</BrowserRouter> </ProfilesProvider>
</TooltipProvider> </OrganizationProvider>
</LayoutProvider> </BrowserRouter>
</MediaRefreshProvider> </ActionProvider>
</PostNavigationProvider> </TooltipProvider>
</LogProvider> </LayoutProvider>
</AuthProvider> </MediaRefreshProvider>
</QueryClientProvider> </PostNavigationProvider>
</SWRConfig> </LogProvider>
</AuthProvider>
</QueryClientProvider>
</SWRConfig>
</HelmetProvider>
); );
}; };

View File

@ -15,8 +15,5 @@ export const ActionProvider: React.FC<ActionProviderProps> = ({ children }) => {
registerAction(action); registerAction(action);
}); });
}, [registerAction]); }, [registerAction]);
// TODO: Add keyboard shortcut listener here
return <>{children}</>; return <>{children}</>;
}; };

View File

@ -12,7 +12,7 @@ export const useActionStore = create<ActionStore>((set, get) => ({
// Prevent duplicate registration if not needed, or overwrite // Prevent duplicate registration if not needed, or overwrite
// For now, we overwrite based on ID // For now, we overwrite based on ID
if (state.actions[action.id]) { if (state.actions[action.id]) {
console.warn(`Action with id ${action.id} already exists. Overwriting.`); // console.warn(`Action with id ${action.id} already exists. Overwriting.`);
} }
return { return {
actions: { actions: {

View File

@ -1,4 +1,4 @@
import { useState } from "react"; import React, { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Eye, EyeOff, Edit3, Trash2, Share2, Link as LinkIcon, FileText, Download, FolderTree, FileJson, LayoutTemplate } from "lucide-react"; import { Eye, EyeOff, Edit3, Trash2, Share2, Link as LinkIcon, FileText, Download, FolderTree, FileJson, LayoutTemplate } from "lucide-react";
@ -12,7 +12,8 @@ import {
DropdownMenuGroup DropdownMenuGroup
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { T, translate } from "@/i18n"; import { T, translate } from "@/i18n";
import { CategoryManager } from "./widgets/CategoryManager"; // import { CategoryManager } from "./widgets/CategoryManager"; // Lazy loaded below
const CategoryManager = React.lazy(() => import("./widgets/CategoryManager").then(module => ({ default: module.CategoryManager })));
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Database } from '@/integrations/supabase/types'; import { Database } from '@/integrations/supabase/types';
@ -467,15 +468,19 @@ draft: ${!page.visible}
{showLabels && <span className="ml-2 hidden md:inline"><T>Categories</T></span>} {showLabels && <span className="ml-2 hidden md:inline"><T>Categories</T></span>}
</Button> </Button>
<CategoryManager <React.Suspense fallback={null}>
isOpen={showCategoryManager} {showCategoryManager && (
onClose={() => setShowCategoryManager(false)} <CategoryManager
currentPageId={page.id} isOpen={showCategoryManager}
currentPageMeta={page.meta} onClose={() => setShowCategoryManager(false)}
onPageMetaUpdate={handleMetaUpdate} currentPageId={page.id}
filterByType="pages" currentPageMeta={page.meta}
defaultMetaType="pages" onPageMetaUpdate={handleMetaUpdate}
/> filterByType="pages"
defaultMetaType="pages"
/>
)}
</React.Suspense>

View File

@ -1,4 +1,4 @@
import { Heart, Download, Share2, User, MessageCircle, Edit3, Trash2, Maximize, Layers } from "lucide-react"; import { Heart, Download, Share2, User, MessageCircle, Edit3, Trash2, Maximize, Layers, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
@ -41,6 +41,7 @@ interface PhotoCardProps {
variant?: 'grid' | 'feed'; variant?: 'grid' | 'feed';
apiUrl?: string; apiUrl?: string;
versionCount?: number; versionCount?: number;
isExternal?: boolean;
} }
const PhotoCard = ({ const PhotoCard = ({
@ -66,7 +67,8 @@ const PhotoCard = ({
responsive, responsive,
variant = 'grid', variant = 'grid',
apiUrl, apiUrl,
versionCount versionCount,
isExternal = false
}: PhotoCardProps) => { }: PhotoCardProps) => {
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
@ -104,6 +106,8 @@ const PhotoCard = ({
const handleLike = async (e: React.MouseEvent) => { const handleLike = async (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (isExternal) return;
if (!user) { if (!user) {
toast.error(translate('Please sign in to like pictures')); toast.error(translate('Please sign in to like pictures'));
return; return;
@ -141,6 +145,8 @@ const PhotoCard = ({
const handleDelete = async (e: React.MouseEvent) => { const handleDelete = async (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (isExternal) return;
if (!user || !isOwner) { if (!user || !isOwner) {
toast.error(translate('You can only delete your own images')); toast.error(translate('You can only delete your own images'));
return; return;
@ -297,6 +303,11 @@ const PhotoCard = ({
}; };
const handlePublish = async (option: 'overwrite' | 'new', imageUrl: string, newTitle: string, description?: string) => { const handlePublish = async (option: 'overwrite' | 'new', imageUrl: string, newTitle: string, description?: string) => {
if (isExternal) {
toast.error(translate('Cannot publish external images'));
return;
}
if (!user) { if (!user) {
toast.error(translate('Please sign in to publish images')); toast.error(translate('Please sign in to publish images'));
return; return;
@ -409,6 +420,13 @@ const PhotoCard = ({
data={responsive} data={responsive}
apiUrl={apiUrl} apiUrl={apiUrl}
/> />
{/* Helper Badge for External Images */}
{isExternal && (
<div className="absolute top-2 left-2 bg-black/60 text-white text-[10px] px-1.5 py-0.5 rounded flex items-center gap-1">
<ExternalLink className="w-3 h-3" />
External
</div>
)}
</div> </div>
{/* Desktop Hover Overlay - hidden on mobile, and hidden in feed variant */} {/* Desktop Hover Overlay - hidden on mobile, and hidden in feed variant */}
@ -428,27 +446,31 @@ const PhotoCard = ({
</div> </div>
)} )}
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<Button {!isExternal && (
size="sm" <>
variant="ghost" <Button
onClick={handleLike} size="sm"
className={`h-8 w-8 p-0 ${localIsLiked ? "text-red-500" : "text-white hover:text-red-500" variant="ghost"
}`} onClick={handleLike}
> className={`h-8 w-8 p-0 ${localIsLiked ? "text-red-500" : "text-white hover:text-red-500"
<Heart className="h-4 w-4" fill={localIsLiked ? "currentColor" : "none"} /> }`}
</Button> >
{localLikes > 0 && <span className="text-white text-sm">{localLikes}</span>} <Heart className="h-4 w-4" fill={localIsLiked ? "currentColor" : "none"} />
</Button>
{localLikes > 0 && <span className="text-white text-sm">{localLikes}</span>}
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-8 w-8 p-0 text-white hover:text-blue-400 ml-2" className="h-8 w-8 p-0 text-white hover:text-blue-400 ml-2"
> >
<MessageCircle className="h-4 w-4" /> <MessageCircle className="h-4 w-4" />
</Button> </Button>
<span className="text-white text-sm">{comments}</span> <span className="text-white text-sm">{comments}</span>
</>
)}
{isOwner && ( {isOwner && !isExternal && (
<> <>
<Button <Button
size="sm" size="sm"
@ -527,13 +549,15 @@ const PhotoCard = ({
<Button size="sm" variant="secondary" className="h-6 w-6 p-0 bg-white/20 hover:bg-white/30 border-0 text-white"> <Button size="sm" variant="secondary" className="h-6 w-6 p-0 bg-white/20 hover:bg-white/30 border-0 text-white">
<Share2 className="h-2.5 w-2.5" /> <Share2 className="h-2.5 w-2.5" />
</Button> </Button>
<MagicWizardButton {!isExternal && (
imageUrl={image} <MagicWizardButton
imageTitle={title} imageUrl={image}
size="sm" imageTitle={title}
variant="ghost" size="sm"
className="h-6 px-2 text-xs bg-white/20 hover:bg-white/30 border-0 text-white" variant="ghost"
/> className="h-6 px-2 text-xs bg-white/20 hover:bg-white/30 border-0 text-white"
/>
)}
</div> </div>
</div> </div>
</div> </div>
@ -555,27 +579,31 @@ const PhotoCard = ({
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button {!isExternal && (
size="icon" <>
variant="ghost" <Button
onClick={handleLike} size="icon"
className={localIsLiked ? "text-red-500 hover:text-red-600" : ""} variant="ghost"
> onClick={handleLike}
<Heart className="h-6 w-6" fill={localIsLiked ? "currentColor" : "none"} /> className={localIsLiked ? "text-red-500 hover:text-red-600" : ""}
</Button> >
{localLikes > 0 && ( <Heart className="h-6 w-6" fill={localIsLiked ? "currentColor" : "none"} />
<span className="text-sm font-medium text-foreground mr-1">{localLikes}</span> </Button>
)} {localLikes > 0 && (
<span className="text-sm font-medium text-foreground mr-1">{localLikes}</span>
)}
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="text-foreground" className="text-foreground"
> >
<MessageCircle className="h-6 w-6 -rotate-90" /> <MessageCircle className="h-6 w-6 -rotate-90" />
</Button> </Button>
{comments > 0 && ( {comments > 0 && (
<span className="text-sm font-medium text-foreground mr-1">{comments}</span> <span className="text-sm font-medium text-foreground mr-1">{comments}</span>
)}
</>
)} )}
<Button <Button
@ -590,14 +618,16 @@ const PhotoCard = ({
<Download className="h-6 w-6" /> <Download className="h-6 w-6" />
</Button> </Button>
<MagicWizardButton {!isExternal && (
imageUrl={image} <MagicWizardButton
imageTitle={title} imageUrl={image}
size="icon" imageTitle={title}
variant="ghost" size="icon"
className="text-foreground hover:text-primary" variant="ghost"
/> className="text-foreground hover:text-primary"
{isOwner && ( />
)}
{isOwner && !isExternal && (
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
@ -641,7 +671,7 @@ const PhotoCard = ({
</div> </div>
)} )}
{showEditModal && ( {showEditModal && !isExternal && (
<EditImageModal <EditImageModal
open={showEditModal} open={showEditModal}
onOpenChange={setShowEditModal} onOpenChange={setShowEditModal}
@ -668,8 +698,8 @@ const PhotoCard = ({
onPublish={handlePublish} onPublish={handlePublish}
isGenerating={isGenerating} isGenerating={isGenerating}
isPublishing={isPublishing} isPublishing={isPublishing}
showPrompt={true} showPrompt={!isExternal} // Hide prompt/edit for external
showPublish={!!generatedImageUrl} showPublish={!!generatedImageUrl && !isExternal}
generatedImageUrl={generatedImageUrl || undefined} generatedImageUrl={generatedImageUrl || undefined}
currentIndex={navigationData?.currentIndex} currentIndex={navigationData?.currentIndex}
totalCount={navigationData?.posts.length} totalCount={navigationData?.posts.length}

View File

@ -0,0 +1,41 @@
import React from 'react';
import { Helmet } from 'react-helmet-async';
interface SEOProps {
title?: string;
description?: string;
image?: string;
type?: string;
twitterCard?: string;
}
export const SEO: React.FC<SEOProps> = ({
title,
description,
image,
type = 'website',
twitterCard = 'summary_large_image'
}) => {
const siteTitle = 'Polymech Pictures'; // Or get from env
const fullTitle = title || siteTitle;
return (
<Helmet>
{/* Standard metadata tags */}
<title>{fullTitle}</title>
{description && <meta name='description' content={description} />}
{/* Open Graph tags */}
<meta property='og:title' content={fullTitle} />
{description && <meta property='og:description' content={description} />}
<meta property='og:type' content={type} />
{image && <meta property='og:image' content={image} />}
{/* Twitter tags */}
<meta name='twitter:card' content={twitterCard} />
<meta name='twitter:title' content={fullTitle} />
{description && <meta name='twitter:description' content={description} />}
{image && <meta name='twitter:image' content={image} />}
</Helmet>
);
};

View File

@ -100,7 +100,7 @@ const TopNavigation = () => {
{/* Logo / Brand */} {/* Logo / Brand */}
<Link to="/" className="flex items-center space-x-2"> <Link to="/" className="flex items-center space-x-2">
<Camera className="h-6 w-6 text-primary" /> <Camera className="h-6 w-6 text-primary" />
<span className="font-bold text-lg hidden sm:inline-block">PixelHub</span> <span className="font-bold text-lg hidden sm:inline-block">PolyMech</span>
</Link> </Link>
{/* Search Bar - Center */} {/* Search Bar - Center */}

View File

@ -3,9 +3,9 @@ import { Button } from "@/components/ui/button";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner"; import { toast } from "sonner";
import { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import MarkdownRenderer from "@/components/MarkdownRenderer"; import MarkdownRenderer from "@/components/MarkdownRenderer";
import { defaultLayoutIcons, DefaultVideoLayout } from '@vidstack/react/player/layouts/default'; import { defaultLayoutIcons } from '@vidstack/react/player/layouts/default';
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { T, translate } from "@/i18n"; import { T, translate } from "@/i18n";
import type { MuxResolution } from "@/types"; import type { MuxResolution } from "@/types";
@ -14,13 +14,17 @@ import { detectMediaType, MEDIA_TYPES } from "@/lib/mediaRegistry";
import UserAvatarBlock from "@/components/UserAvatarBlock"; import UserAvatarBlock from "@/components/UserAvatarBlock";
import { formatDate, isLikelyFilename } from "@/utils/textUtils"; import { formatDate, isLikelyFilename } from "@/utils/textUtils";
import { // import {
MediaPlayer, MediaProvider, type MediaPlayerInstance // MediaPlayer, MediaProvider, type MediaPlayerInstance
} from '@vidstack/react'; // } from '@vidstack/react';
import type { MediaPlayerInstance } from '@vidstack/react';
// Import Vidstack styles // Import Vidstack styles
import '@vidstack/react/player/styles/default/theme.css'; // import '@vidstack/react/player/styles/default/theme.css';
import '@vidstack/react/player/styles/default/layouts/video.css'; // import '@vidstack/react/player/styles/default/layouts/video.css';
// Lazy load Vidstack implementation
const VidstackPlayer = React.lazy(() => import('../player/components/VidstackPlayerImpl').then(module => ({ default: module.VidstackPlayerImpl })));
interface VideoCardProps { interface VideoCardProps {
videoId: string; videoId: string;
@ -469,28 +473,30 @@ const VideoCard = ({
</> </>
) : ( ) : (
// Show MediaPlayer when playing // Show MediaPlayer when playing
<MediaPlayer <React.Suspense fallback={<div className="w-full h-full bg-black animate-pulse flex items-center justify-center"><Loader2 className="w-8 h-8 animate-spin text-white" /></div>}>
key={videoId} <VidstackPlayer
ref={player} key={videoId}
title={title} ref={player}
src={ title={title}
playbackUrl.includes('.m3u8') src={
? { src: playbackUrl, type: 'application/x-mpegurl' } playbackUrl.includes('.m3u8')
: (job?.resultUrl && job.status === 'completed') ? { src: playbackUrl, type: 'application/x-mpegurl' }
? { src: job.resultUrl, type: 'application/x-mpegurl' } : (job?.resultUrl && job.status === 'completed')
: playbackUrl.includes('/api/videos/jobs') ? { src: job.resultUrl, type: 'application/x-mpegurl' }
? { src: playbackUrl, type: 'video/mp4' } : playbackUrl.includes('/api/videos/jobs')
: playbackUrl ? { src: playbackUrl, type: 'video/mp4' }
} : playbackUrl
poster={posterUrl} }
fullscreenOrientation="any" poster={posterUrl}
controls fullscreenOrientation="any"
playsInline controls
className={`w-full ${variant === 'grid' ? "h-full" : ""}`} playsInline
> className={`w-full ${variant === 'grid' ? "h-full" : ""}`}
<MediaProvider /> layoutProps={{
<DefaultVideoLayout icons={defaultLayoutIcons} /> icons: defaultLayoutIcons
</MediaPlayer> }}
/>
</React.Suspense>
)} )}
</div> </div>

View File

@ -14,13 +14,14 @@ interface GenericCanvasProps {
showControls?: boolean; showControls?: boolean;
className?: string; className?: string;
selectedWidgetId?: string | null; selectedWidgetId?: string | null;
onSelectWidget?: (widgetId: string) => void; onSelectWidget?: (widgetId: string, pageId?: string) => void;
selectedContainerId?: string | null; selectedContainerId?: string | null;
onSelectContainer?: (containerId: string | null) => void; onSelectContainer?: (containerId: string | null, pageId?: string) => void;
initialLayout?: any; initialLayout?: any;
editingWidgetId?: string | null; editingWidgetId?: string | null;
onEditWidget?: (widgetId: string | null) => void; onEditWidget?: (widgetId: string | null) => void;
newlyAddedWidgetId?: string | null; newlyAddedWidgetId?: string | null;
contextVariables?: Record<string, any>;
} }
const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
@ -36,7 +37,8 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
initialLayout, initialLayout,
editingWidgetId, editingWidgetId,
onEditWidget, onEditWidget,
newlyAddedWidgetId newlyAddedWidgetId,
contextVariables
}) => { }) => {
const { const {
loadedPages, loadedPages,
@ -72,13 +74,14 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
const [internalSelectedContainer, setInternalSelectedContainer] = useState<string | null>(null); const [internalSelectedContainer, setInternalSelectedContainer] = useState<string | null>(null);
const selectedContainer = propSelectedContainerId !== undefined ? propSelectedContainerId : internalSelectedContainer; const selectedContainer = propSelectedContainerId !== undefined ? propSelectedContainerId : internalSelectedContainer;
const setSelectedContainer = (id: string | null) => { const setSelectedContainer = (id: string | null, pageId?: string) => {
if (propOnSelectContainer) { if (propOnSelectContainer) {
propOnSelectContainer(id); propOnSelectContainer(id, pageId);
} else { } else {
setInternalSelectedContainer(id); setInternalSelectedContainer(id);
} }
}; };
const [showWidgetPalette, setShowWidgetPalette] = useState(false); const [showWidgetPalette, setShowWidgetPalette] = useState(false);
const [targetContainerId, setTargetContainerId] = useState<string | null>(null); const [targetContainerId, setTargetContainerId] = useState<string | null>(null);
const [targetColumn, setTargetColumn] = useState<number | undefined>(undefined); const [targetColumn, setTargetColumn] = useState<number | undefined>(undefined);
@ -96,8 +99,8 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
); );
} }
const handleSelectContainer = (containerId: string) => { const handleSelectContainer = (containerId: string, pageId?: string) => {
setSelectedContainer(containerId); setSelectedContainer(containerId, pageId);
}; };
const handleAddWidget = (containerId: string, columnIndex?: number) => { const handleAddWidget = (containerId: string, columnIndex?: number) => {
@ -313,6 +316,7 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
editingWidgetId={editingWidgetId} editingWidgetId={editingWidgetId}
onEditWidget={onEditWidget} onEditWidget={onEditWidget}
newlyAddedWidgetId={newlyAddedWidgetId} newlyAddedWidgetId={newlyAddedWidgetId}
contextVariables={contextVariables}
onRemoveWidget={async (widgetId) => { onRemoveWidget={async (widgetId) => {
try { try {
await removeWidgetFromPage(pageId, widgetId); await removeWidgetFromPage(pageId, widgetId);

View File

@ -15,7 +15,7 @@ interface LayoutContainerProps {
isEditMode: boolean; isEditMode: boolean;
pageId: string; pageId: string;
selectedContainerId?: string | null; selectedContainerId?: string | null;
onSelect?: (containerId: string) => void; onSelect?: (containerId: string, pageId?: string) => void;
onAddWidget?: (containerId: string, targetColumn?: number) => void; onAddWidget?: (containerId: string, targetColumn?: number) => void;
onRemoveWidget?: (widgetInstanceId: string) => void; onRemoveWidget?: (widgetInstanceId: string) => void;
onMoveWidget?: (widgetInstanceId: string, direction: 'up' | 'down' | 'left' | 'right') => void; onMoveWidget?: (widgetInstanceId: string, direction: 'up' | 'down' | 'left' | 'right') => void;
@ -27,12 +27,13 @@ interface LayoutContainerProps {
canMoveContainerUp?: boolean; canMoveContainerUp?: boolean;
canMoveContainerDown?: boolean; canMoveContainerDown?: boolean;
selectedWidgetId?: string | null; selectedWidgetId?: string | null;
onSelectWidget?: (widgetId: string) => void; onSelectWidget?: (widgetId: string, pageId?: string) => void;
depth?: number; depth?: number;
isCompactMode?: boolean; isCompactMode?: boolean;
editingWidgetId?: string | null; editingWidgetId?: string | null;
onEditWidget?: (widgetId: string | null) => void; onEditWidget?: (widgetId: string | null) => void;
newlyAddedWidgetId?: string | null; newlyAddedWidgetId?: string | null;
contextVariables?: Record<string, any>;
} }
const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
@ -58,6 +59,7 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
editingWidgetId, editingWidgetId,
onEditWidget, onEditWidget,
newlyAddedWidgetId, newlyAddedWidgetId,
contextVariables,
}) => { }) => {
const maxDepth = 3; // Limit nesting depth const maxDepth = 3; // Limit nesting depth
const canNest = depth < maxDepth; const canNest = depth < maxDepth;
@ -116,7 +118,7 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
isEditMode={isEditMode} isEditMode={isEditMode}
pageId={pageId} pageId={pageId}
isSelected={selectedWidgetId === widget.id} isSelected={selectedWidgetId === widget.id}
onSelect={() => onSelectWidget?.(widget.id)} onSelect={() => onSelectWidget?.(widget.id, pageId)}
canMoveUp={index > 0} canMoveUp={index > 0}
canMoveDown={index < container.widgets.length - 1} canMoveDown={index < container.widgets.length - 1}
onRemove={onRemoveWidget} onRemove={onRemoveWidget}
@ -124,6 +126,10 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
isEditing={editingWidgetId === widget.id} isEditing={editingWidgetId === widget.id}
onEditWidget={onEditWidget} onEditWidget={onEditWidget}
isNew={newlyAddedWidgetId === widget.id} isNew={newlyAddedWidgetId === widget.id}
selectedWidgetId={selectedWidgetId}
onSelectWidget={onSelectWidget}
editingWidgetId={editingWidgetId}
contextVariables={contextVariables}
/> />
))} ))}
@ -182,6 +188,7 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
editingWidgetId={editingWidgetId} editingWidgetId={editingWidgetId}
onEditWidget={onEditWidget} onEditWidget={onEditWidget}
newlyAddedWidgetId={newlyAddedWidgetId} newlyAddedWidgetId={newlyAddedWidgetId}
contextVariables={contextVariables}
/> />
</div> </div>
))} ))}
@ -196,7 +203,7 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
)} )}
onDoubleClick={isEditMode ? (e) => { onDoubleClick={isEditMode ? (e) => {
e.stopPropagation(); e.stopPropagation();
onSelect?.(container.id); onSelect?.(container.id, pageId);
setTimeout(() => onAddWidget?.(container.id), 100); // Small delay to ensure selection happens first, no column = append setTimeout(() => onAddWidget?.(container.id), 100); // Small delay to ensure selection happens first, no column = append
} : undefined} } : undefined}
title={isEditMode ? "Double-click to add widget" : undefined} title={isEditMode ? "Double-click to add widget" : undefined}
@ -375,7 +382,7 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (isEditMode) { if (isEditMode) {
onSelect?.(container.id); onSelect?.(container.id, pageId);
} }
}} }}
> >
@ -463,6 +470,10 @@ interface WidgetItemProps {
isEditing?: boolean; isEditing?: boolean;
onEditWidget?: (widgetId: string | null) => void; onEditWidget?: (widgetId: string | null) => void;
isNew?: boolean; isNew?: boolean;
selectedWidgetId?: string | null;
onSelectWidget?: (widgetId: string, pageId?: string) => void;
editingWidgetId?: string | null;
contextVariables?: Record<string, any>;
} }
const WidgetItem: React.FC<WidgetItemProps> = ({ const WidgetItem: React.FC<WidgetItemProps> = ({
@ -477,7 +488,11 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
onSelect, onSelect,
isEditing, isEditing,
onEditWidget, onEditWidget,
isNew isNew,
selectedWidgetId,
onSelectWidget,
editingWidgetId,
contextVariables,
}) => { }) => {
const widgetDefinition = widgetRegistry.get(widget.widgetId); const widgetDefinition = widgetRegistry.get(widget.widgetId);
const { updateWidgetProps, renameWidget } = useLayout(); const { updateWidgetProps, renameWidget } = useLayout();
@ -639,6 +654,11 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
console.error('Failed to update widget props:', error); console.error('Failed to update widget props:', error);
} }
}} }}
selectedWidgetId={selectedWidgetId}
onSelectWidget={onSelectWidget}
editingWidgetId={editingWidgetId}
onEditWidget={onEditWidget}
contextVariables={contextVariables}
/> />
</div> </div>

View File

@ -0,0 +1,228 @@
import React, { useState, useEffect } from 'react';
import { translateText, fetchGlossaries, createGlossary, deleteGlossary, TargetLanguageCodeSchema, Glossary } from '@/lib/db';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Loader2, Trash2, Plus } from 'lucide-react';
import { toast } from 'sonner';
// Safely extract options from ZodUnion of ZodEnums
const TARGET_LANGS = [
...TargetLanguageCodeSchema.options[0].options,
...TargetLanguageCodeSchema.options[1].options
].sort();
export default function I18nPlayground() {
// Translation State
const [srcLang, setSrcLang] = useState('en');
const [dstLang, setDstLang] = useState('fr');
const [text, setText] = useState('');
const [translation, setTranslation] = useState('');
const [selectedGlossaryId, setSelectedGlossaryId] = useState<string>('');
const [isTranslating, setIsTranslating] = useState(false);
// Glossary State
const [glossaries, setGlossaries] = useState<Glossary[]>([]);
const [loadingGlossaries, setLoadingGlossaries] = useState(false);
// New Glossary State
const [newGlossaryName, setNewGlossaryName] = useState('');
const [newGlossarySrc, setNewGlossarySrc] = useState('en');
const [newGlossaryDst, setNewGlossaryDst] = useState('fr');
const [newGlossaryEntries, setNewGlossaryEntries] = useState(''); // CSV format: term,translation
useEffect(() => {
loadGlossaries();
}, []);
const loadGlossaries = async () => {
setLoadingGlossaries(true);
try {
const data = await fetchGlossaries();
setGlossaries(data);
} catch (e) {
toast.error('Failed to load glossaries');
} finally {
setLoadingGlossaries(false);
}
};
const handleTranslate = async () => {
if (!text) return;
setIsTranslating(true);
try {
const res = await translateText(text, srcLang, dstLang, selectedGlossaryId === 'none' ? undefined : selectedGlossaryId);
setTranslation(res.translation);
} catch (e) {
console.error(e);
toast.error('Translation failed');
} finally {
setIsTranslating(false);
}
};
const handleCreateGlossary = async () => {
if (!newGlossaryName || !newGlossaryEntries) return;
const entries: Record<string, string> = {};
newGlossaryEntries.split('\n').forEach(line => {
const parts = line.split(',');
if (parts.length >= 2) {
const term = parts[0].trim();
const trans = parts.slice(1).join(',').trim(); // Handle commas in translation? Simple CSV logic.
if (term && trans) entries[term] = trans;
}
});
if (Object.keys(entries).length === 0) {
toast.error('No valid entries found');
return;
}
try {
await createGlossary(newGlossaryName, newGlossarySrc, newGlossaryDst, entries);
toast.success('Glossary created');
loadGlossaries();
setNewGlossaryName('');
setNewGlossaryEntries('');
} catch (e: any) {
toast.error(`Failed to create glossary: ${e.message}`);
}
};
const handleDeleteGlossary = async (id: string) => {
try {
await deleteGlossary(id);
toast.success('Glossary deleted');
loadGlossaries();
if (selectedGlossaryId === id) setSelectedGlossaryId('');
} catch (e) {
toast.error('Failed to delete glossary');
}
};
return (
<div className="container mx-auto p-6 space-y-8">
<h1 className="text-3xl font-bold">i18n / DeepL Playground</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Translation Section */}
<Card>
<CardHeader>
<CardTitle>Translation</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex space-x-2">
<div className="flex-1">
<label className="text-sm font-medium">Source Language</label>
<Input value={srcLang} onChange={e => setSrcLang(e.target.value)} placeholder="en" />
</div>
<div className="flex-1">
<label className="text-sm font-medium">Target Language</label>
<Select value={dstLang} onValueChange={setDstLang}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TARGET_LANGS.map(lang => (
<SelectItem key={lang} value={lang}>{lang}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<label className="text-sm font-medium">Glossary (Optional)</label>
<Select value={selectedGlossaryId} onValueChange={setSelectedGlossaryId}>
<SelectTrigger>
<SelectValue placeholder="Select a glossary" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{glossaries
.filter(g => g.source_lang === srcLang && g.target_lang === dstLang)
.map(g => (
<SelectItem key={g.glossary_id} value={g.glossary_id}>
{g.name} ({g.entry_count} entries)
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Textarea
placeholder="Enter text to translate..."
value={text}
onChange={e => setText(e.target.value)}
rows={4}
/>
<Button onClick={handleTranslate} disabled={isTranslating || !text}>
{isTranslating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Translate
</Button>
{translation && (
<div className="p-4 bg-muted rounded-md mt-4">
<h3 className="text-sm font-medium mb-1">Result:</h3>
<p>{translation}</p>
</div>
)}
</CardContent>
</Card>
{/* Glossary Management Section */}
<Card>
<CardHeader>
<CardTitle>Glossaries</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* List */}
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{loadingGlossaries ? (
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
) : glossaries.length === 0 ? (
<p className="text-muted-foreground text-center">No glossaries found.</p>
) : (
glossaries.map(g => (
<div key={g.glossary_id} className="flex items-center justify-between p-2 border rounded-md">
<div>
<p className="font-medium">{g.name}</p>
<p className="text-xs text-muted-foreground">{g.source_lang} -&gt; {g.target_lang} {g.entry_count} entries</p>
</div>
<Button variant="ghost" size="icon" onClick={() => handleDeleteGlossary(g.glossary_id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))
)}
</div>
<div className="border-t pt-4 space-y-4">
<h3 className="text-lg font-medium">Create New Glossary</h3>
<div className="grid grid-cols-2 gap-2">
<Input placeholder="Name" value={newGlossaryName} onChange={e => setNewGlossaryName(e.target.value)} />
<div className="flex gap-2">
<Input placeholder="Src" value={newGlossarySrc} onChange={e => setNewGlossarySrc(e.target.value)} className="w-16" />
<Input placeholder="Dst" value={newGlossaryDst} onChange={e => setNewGlossaryDst(e.target.value)} className="w-16" />
</div>
</div>
<Textarea
placeholder="Entries (term, translation per line)"
value={newGlossaryEntries}
onChange={e => setNewGlossaryEntries(e.target.value)}
rows={3}
/>
<Button onClick={handleCreateGlossary} className="w-full">
<Plus className="mr-2 h-4 w-4" /> Create Glossary
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -1,9 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { ChevronRight, ChevronDown, Box, LayoutGrid } from 'lucide-react'; import { ChevronRight, ChevronDown, Box, LayoutGrid, Layers, Settings } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { LayoutContainer, WidgetInstance } from '@/lib/unifiedLayoutManager'; import { LayoutContainer, WidgetInstance } from '@/lib/unifiedLayoutManager';
import { widgetRegistry } from '@/lib/widgetRegistry'; import { widgetRegistry } from '@/lib/widgetRegistry';
import { useLayout } from '@/contexts/LayoutContext';
interface HierarchyNodeProps { interface HierarchyNodeProps {
label: string; label: string;
@ -16,6 +17,7 @@ interface HierarchyNodeProps {
hasChildren?: boolean; hasChildren?: boolean;
onToggleExpand?: () => void; onToggleExpand?: () => void;
isExpanded?: boolean; isExpanded?: boolean;
onSettings?: (e: React.MouseEvent) => void;
} }
const TreeNode = ({ const TreeNode = ({
@ -27,7 +29,8 @@ const TreeNode = ({
depth = 0, depth = 0,
hasChildren = false, hasChildren = false,
onToggleExpand, onToggleExpand,
isExpanded = false isExpanded = false,
onSettings
}: HierarchyNodeProps) => { }: HierarchyNodeProps) => {
return ( return (
@ -56,7 +59,23 @@ const TreeNode = ({
</div> </div>
<Icon className={cn("h-3.5 w-3.5 shrink-0", isSelected ? "opacity-100" : "opacity-60")} /> <Icon className={cn("h-3.5 w-3.5 shrink-0", isSelected ? "opacity-100" : "opacity-60")} />
<span className="truncate text-xs">{label}</span> <span className="truncate text-xs flex-1">{label}</span>
{onSettings && (
<div
role="button"
className={cn(
"opacity-0 group-hover:opacity-100 transition-opacity p-0.5 rounded-sm hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground",
isSelected && "opacity-70 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800"
)}
onClick={(e) => {
e.stopPropagation();
onSettings(e);
}}
>
<Settings className="h-3 w-3" />
</div>
)}
</div> </div>
{hasChildren && isExpanded && ( {hasChildren && isExpanded && (
<div>{children}</div> <div>{children}</div>
@ -71,6 +90,8 @@ interface HierarchyTreeProps {
selectedContainerId?: string | null; selectedContainerId?: string | null;
onSelectWidget: (id: string) => void; onSelectWidget: (id: string) => void;
onSelectContainer: (id: string) => void; onSelectContainer: (id: string) => void;
onSettingsClick?: (id: string, type: 'widget' | 'container', layoutId: string) => void;
layoutId: string;
} }
export const HierarchyTree = ({ export const HierarchyTree = ({
@ -78,23 +99,62 @@ export const HierarchyTree = ({
selectedWidgetId, selectedWidgetId,
selectedContainerId, selectedContainerId,
onSelectWidget, onSelectWidget,
onSelectContainer onSelectContainer,
onSettingsClick,
layoutId
}: HierarchyTreeProps) => { }: HierarchyTreeProps) => {
// Manage expansion state locally // Manage expansion state locally
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set()); const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const { loadedPages, loadPageLayout } = useLayout();
const requestedLayoutsRef = React.useRef<Set<string>>(new Set());
// Traverse and load nested layouts
React.useEffect(() => {
const checkAndLoadLayouts = (items: LayoutContainer[]) => {
items.forEach(c => {
c.widgets.forEach(w => {
const def = widgetRegistry.get(w.widgetId);
const nestedLayouts = def?.getNestedLayouts?.(w.props || {}) || [];
nestedLayouts.forEach(layoutInfo => {
if (layoutInfo.layoutId && !loadedPages.has(layoutInfo.layoutId) && !requestedLayoutsRef.current.has(layoutInfo.layoutId)) {
requestedLayoutsRef.current.add(layoutInfo.layoutId);
loadPageLayout(layoutInfo.layoutId, layoutInfo.label).catch(console.error);
}
});
});
if (c.children) checkAndLoadLayouts(c.children);
});
};
if (containers) checkAndLoadLayouts(containers);
}, [containers, loadedPages, loadPageLayout]);
// Auto-expand all containers on load/change // Auto-expand all containers on load/change
React.useEffect(() => { React.useEffect(() => {
const allIds = new Set<string>(); const allIds = new Set<string>();
const traverse = (items: LayoutContainer[]) => { const traverse = (items: LayoutContainer[]) => {
items.forEach(c => { items.forEach(c => {
allIds.add(c.id); allIds.add(c.id);
// Also expand widgets with children (nested layouts)
c.widgets.forEach(w => {
const def = widgetRegistry.get(w.widgetId);
const nestedLayouts = def?.getNestedLayouts?.(w.props || {}) || [];
if (nestedLayouts.length > 0) {
allIds.add(w.id);
}
});
if (c.children) traverse(c.children); if (c.children) traverse(c.children);
}); });
}; };
if (containers) traverse(containers); if (containers) traverse(containers);
setExpandedNodes(allIds); setExpandedNodes(prev => {
const next = new Set(prev);
allIds.forEach(id => next.add(id));
return next;
});
}, [containers]); }, [containers]);
const toggleNode = (id: string) => { const toggleNode = (id: string) => {
@ -109,10 +169,66 @@ export const HierarchyTree = ({
// Auto-expand path to selection would be nice, but simple toggle for now // Auto-expand path to selection would be nice, but simple toggle for now
const renderWidget = (widget: WidgetInstance, depth: number) => {
const renderWidget = (widget: WidgetInstance, depth: number, currentLayoutId: string) => {
const def = widgetRegistry.get(widget.widgetId); const def = widgetRegistry.get(widget.widgetId);
const name = def?.metadata.name || widget.widgetId; const name = def?.metadata.name || widget.widgetId;
const Icon = def?.metadata.icon || Box; const Icon = def?.metadata.icon || Box;
const isExpanded = expandedNodes.has(widget.id);
// Check for nested layouts via registry definition
let nestedItems: React.ReactNode = null;
let hasChildren = false;
const nestedLayouts = def?.getNestedLayouts?.(widget.props || {}) || [];
if (nestedLayouts.length > 0) {
hasChildren = true;
nestedItems = nestedLayouts.map(layoutInfo => {
const nestedLayoutId = layoutInfo.layoutId;
const layout = loadedPages.get(nestedLayoutId);
const expansionKey = nestedLayoutId || layoutInfo.id;
const isTabExpanded = expandedNodes.has(expansionKey);
return (
<TreeNode
key={expansionKey}
label={layoutInfo.label}
icon={Layers}
isSelected={false}
onClick={(e) => {
e.stopPropagation();
toggleNode(expansionKey);
}}
depth={depth + 1}
hasChildren={true}
isExpanded={isTabExpanded}
onToggleExpand={() => toggleNode(expansionKey)}
onSettings={(e) => {
e.stopPropagation();
if (onSettingsClick) {
// Select parent widget (in current layout)
onSettingsClick(widget.id, 'widget', currentLayoutId);
} else {
onSelectWidget(widget.id);
}
}}
>
{!layout ? (
<div className="py-1 px-2 text-xs text-muted-foreground opacity-50" style={{ paddingLeft: `${(depth + 2) * 12 + 4}px` }}>
(loading...)
</div>
) : (
layout.containers.map(c => renderContainer(c, depth + 2, nestedLayoutId))
)}
</TreeNode>
);
});
}
// Generic 'layout-container' widget support if it exists and works similarly?
// For now just tabs.
return ( return (
<TreeNode <TreeNode
@ -122,14 +238,30 @@ export const HierarchyTree = ({
isSelected={selectedWidgetId === widget.id} isSelected={selectedWidgetId === widget.id}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
// If it's a tab widget, we might want to select it.
// But if we click a nested widget, onSelectWidget is called with that ID.
onSelectWidget(widget.id); onSelectWidget(widget.id);
}} }}
depth={depth} depth={depth}
/> hasChildren={hasChildren}
isExpanded={isExpanded}
onToggleExpand={() => toggleNode(widget.id)}
onSettings={(e) => {
e.stopPropagation();
if (onSettingsClick) {
onSettingsClick(widget.id, 'widget', currentLayoutId);
} else {
onSelectWidget(widget.id);
}
}}
>
{/* Render Nested Content */}
{isExpanded && nestedItems}
</TreeNode>
); );
}; };
const renderContainer = (container: LayoutContainer, depth: number) => { const renderContainer = (container: LayoutContainer, depth: number, currentLayoutId: string) => {
const title = container.settings?.title || `Container (${container.columns} col)`; const title = container.settings?.title || `Container (${container.columns} col)`;
const hasChildren = container.widgets.length > 0 || container.children.length > 0; const hasChildren = container.widgets.length > 0 || container.children.length > 0;
const isExpanded = expandedNodes.has(container.id); const isExpanded = expandedNodes.has(container.id);
@ -145,12 +277,20 @@ export const HierarchyTree = ({
hasChildren={hasChildren} hasChildren={hasChildren}
isExpanded={isExpanded} isExpanded={isExpanded}
onToggleExpand={() => toggleNode(container.id)} onToggleExpand={() => toggleNode(container.id)}
onSettings={(e) => {
e.stopPropagation();
if (onSettingsClick) {
onSettingsClick(container.id, 'container', currentLayoutId);
} else {
onSelectContainer(container.id);
}
}}
> >
{/* Render Content */} {/* Render Content */}
{isExpanded && ( {isExpanded && (
<> <>
{container.widgets.map(w => renderWidget(w, depth + 1))} {container.widgets.map(w => renderWidget(w, depth + 1, currentLayoutId))}
{container.children.map(c => renderContainer(c, depth + 1))} {container.children.map(c => renderContainer(c, depth + 1, currentLayoutId))}
</> </>
)} )}
</TreeNode> </TreeNode>
@ -169,7 +309,7 @@ export const HierarchyTree = ({
return ( return (
<div className="pb-2"> <div className="pb-2">
{containers.map(c => renderContainer(c, 0))} {containers.map(c => renderContainer(c, 0, layoutId))}
</div> </div>
); );
}; };

View File

@ -62,7 +62,7 @@ function TocItemRenderer({
React.useEffect(() => { React.useEffect(() => {
if (isActive && itemRef.current) { if (isActive && itemRef.current) {
itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); // itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} }
}, [isActive]); }, [isActive]);

View File

@ -34,13 +34,7 @@ export interface BuilderElement {
uiSchema?: 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 DraggablePaletteItem = ({ item }: { item: BuilderElement }) => {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
@ -81,8 +75,8 @@ const CanvasElement = ({
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Check if this is a primitive type or a custom type // Check if this is a primitive type or a custom type
const primitiveTypes = ['string', 'number', 'boolean', 'array', 'object']; const primitiveTypes = ['string', 'number', 'int', 'float', 'boolean', 'bool', 'array', 'object'];
const isPrimitive = primitiveTypes.includes(element.type); const isPrimitive = primitiveTypes.includes(element.type.toLowerCase());
return ( return (
<div <div
@ -113,7 +107,7 @@ const CanvasElement = ({
<AlertDialogDescription asChild> <AlertDialogDescription asChild>
<div> <div>
{isPrimitive ? ( {isPrimitive ? (
<>This will remove the field "{element.title || element.name}" from the structure and delete it from the database. This action cannot be undone.</> <>This will remove the field "{element.title || element.name}" from the structure.</>
) : ( ) : (
<> <>
Choose how to remove the field "{element.title || element.name}": Choose how to remove the field "{element.title || element.name}":
@ -128,28 +122,42 @@ const CanvasElement = ({
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
{!isPrimitive && onRemoveOnly && ( {isPrimitive ? (
<AlertDialogAction <AlertDialogAction
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onRemoveOnly(); if (onRemoveOnly) onRemoveOnly();
setShowDeleteDialog(false); setShowDeleteDialog(false);
}} }}
className="bg-secondary text-secondary-foreground hover:bg-secondary/90"
> >
Remove Only Remove
</AlertDialogAction> </AlertDialogAction>
) : (
<>
{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>
</>
)} )}
<AlertDialogAction
onClick={(e) => {
e.stopPropagation();
onDelete();
setShowDeleteDialog(false);
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete in Database
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
@ -158,9 +166,12 @@ const CanvasElement = ({
}; };
function getIconForType(type: string) { function getIconForType(type: string) {
switch (type) { switch (type.toLowerCase()) {
case 'string': return TypeIcon; case 'string': return TypeIcon;
case 'int':
case 'float':
case 'number': return Hash; case 'number': return Hash;
case 'bool':
case 'boolean': return ToggleLeft; case 'boolean': return ToggleLeft;
case 'object': return Box; case 'object': return Box;
case 'array': return List; case 'array': return List;
@ -210,9 +221,31 @@ const TypeBuilderContent: React.FC<{
id: 'canvas', id: 'canvas',
}); });
const primitivePaletteItems = React.useMemo(() => {
const order = ['string', 'int', 'float', 'bool', 'object', 'array'];
return availableTypes
.filter(t => t.kind === 'primitive')
.sort((a, b) => {
const ia = order.indexOf(a.name);
const ib = order.indexOf(b.name);
if (ia !== -1 && ib !== -1) return ia - ib;
if (ia !== -1) return -1;
if (ib !== -1) return 1;
return a.name.localeCompare(b.name);
})
.map(t => ({
id: `primitive-${t.id}`,
type: t.name,
name: t.name.charAt(0).toUpperCase() + t.name.slice(1),
title: `New ${t.name.charAt(0).toUpperCase() + t.name.slice(1)}`,
description: t.description || undefined,
refId: t.id
} as BuilderElement & { refId?: string }));
}, [availableTypes]);
const customPaletteItems = React.useMemo(() => { const customPaletteItems = React.useMemo(() => {
return availableTypes return availableTypes
.filter(t => t.kind !== 'field') // Exclude field types from palette .filter(t => t.kind !== 'primitive' && t.kind !== 'field') // Exclude primitives (handled above) and fields
.map(t => ({ .map(t => ({
id: `type-${t.id}`, id: `type-${t.id}`,
type: t.name, // Use name as type reference for now? Or ID? ID is better for strictness, Name for display. type: t.name, // Use name as type reference for now? Or ID? ID is better for strictness, Name for display.
@ -236,13 +269,17 @@ const TypeBuilderContent: React.FC<{
<CardHeader className="py-3 px-4 border-b"> <CardHeader className="py-3 px-4 border-b">
<CardTitle className="text-sm font-medium">Palette</CardTitle> <CardTitle className="text-sm font-medium">Palette</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex-1 p-3 space-y-6 overflow-y-auto"> <CardContent className="flex-1 p-3 space-y-6 overflow-y-auto scrollbar-custom">
<div> <div>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Primitives</div> <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Primitives</div>
<div className="space-y-2"> <div className="space-y-2">
{PALETTE_ITEMS.map(item => ( {primitivePaletteItems.length > 0 ? (
<DraggablePaletteItem key={item.id} item={item} /> primitivePaletteItems.map(item => (
))} <DraggablePaletteItem key={item.id} item={item} />
))
) : (
<div className="text-xs text-muted-foreground italic">No primitive types found.</div>
)}
</div> </div>
</div> </div>
@ -263,9 +300,8 @@ const TypeBuilderContent: React.FC<{
<Card className={`flex-1 flex flex-col transition-colors ${isOver ? 'bg-muted/30 border-primary/50 ring-2 ring-primary/20' : ''}`}> <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"> <CardHeader className="py-3 px-4 border-b flex flex-row justify-between items-center">
<div className="flex items-center gap-4"> <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]"> <Tabs value={mode} onValueChange={(v) => { setMode(v as BuilderMode); setElements([]); }} className="w-[200px]">
<TabsList className="grid w-full grid-cols-2 h-7"> <TabsList className="grid grid-cols-2 h-7">
<TabsTrigger value="structure" className="text-xs">Structure</TabsTrigger> <TabsTrigger value="structure" className="text-xs">Structure</TabsTrigger>
<TabsTrigger value="alias" className="text-xs">Single Type</TabsTrigger> <TabsTrigger value="alias" className="text-xs">Single Type</TabsTrigger>
</TabsList> </TabsList>
@ -278,7 +314,7 @@ const TypeBuilderContent: React.FC<{
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<div ref={setCanvasRef} className="flex-1 p-6 bg-muted/10 overflow-y-auto min-h-[300px] transition-colors relative"> <div ref={setCanvasRef} className="flex-1 p-4 bg-muted/10 overflow-y-auto min-h-[300px] transition-colors relative">
{isOver && ( {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="absolute inset-0 bg-primary/5 rounded-none border-2 border-primary/20 border-dashed pointer-events-none z-0" />
)} )}
@ -342,31 +378,27 @@ const TypeBuilderContent: React.FC<{
<Select <Select
value={elements[0]?.type || ''} value={elements[0]?.type || ''}
onValueChange={(val) => { onValueChange={(val) => {
if (elements.length > 0) { const foundType = availableTypes.find(t => t.name === val);
const updated = { ...elements[0], type: val, name: 'value', title: val + ' Alias' }; if (!foundType) return;
setElements([updated]);
setSelectedId(updated.id); const newItemId = `field-${Date.now()}`;
} else { const newItem: BuilderElement = {
// Create new if empty? Or just wait for drag? id: elements.length > 0 ? elements[0].id : newItemId,
// Let's allow creating via select if empty type: val,
const newItemId = `field-${Date.now()}`; name: 'value',
const newItem: BuilderElement = { title: val + ' Alias',
id: newItemId, uiSchema: {},
type: val, ...(foundType && { refId: foundType.id } as any)
name: 'value', };
title: val + ' Alias', setElements([newItem]);
uiSchema: {} setSelectedId(newItem.id);
};
setElements([newItem]);
setSelectedId(newItemId);
}
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a primitive type" /> <SelectValue placeholder="Select a primitive type" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{PALETTE_ITEMS.map(p => ( {primitivePaletteItems.map(p => (
<SelectItem key={p.id} value={p.type}>{p.name}</SelectItem> <SelectItem key={p.id} value={p.type}>{p.name}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -464,12 +496,12 @@ const TypeBuilderContent: React.FC<{
); );
}; };
export const TypeBuilder: React.FC<{ export const TypeBuilder = React.forwardRef<TypeBuilderRef, {
onSave: (data: BuilderOutput) => void, onSave: (data: BuilderOutput) => void,
onCancel: () => void, onCancel: () => void,
availableTypes: TypeDefinition[], availableTypes: TypeDefinition[],
initialData?: BuilderOutput initialData?: BuilderOutput
}> = ({ onSave, onCancel, availableTypes, initialData }) => { }>(({ onSave, onCancel, availableTypes, initialData }, ref) => {
const [mode, setMode] = useState<BuilderMode>(initialData?.mode || 'structure'); const [mode, setMode] = useState<BuilderMode>(initialData?.mode || 'structure');
const [elements, setElements] = useState<BuilderElement[]>(initialData?.elements || []); const [elements, setElements] = useState<BuilderElement[]>(initialData?.elements || []);
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
@ -503,11 +535,11 @@ export const TypeBuilder: React.FC<{
const newItemId = `field-${Date.now()}-${Math.floor(Math.random() * 10000)}`; const newItemId = `field-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
// Determine the refId for this element // Determine the refId for this element
// If template already has refId (custom type from palette), use it // If template already has refId (custom type from palette or primitive from DB), use it
// Otherwise, look up primitive type by mapped name
let refId = (template as any).refId; let refId = (template as any).refId;
// Fallback for legacy palette items (shouldn't be hit now, but good for safety)
if (!refId) { if (!refId) {
// Map JSON Schema type names to database primitive type names
const typeNameMap: Record<string, string> = { const typeNameMap: Record<string, string> = {
'number': 'int', 'boolean': 'bool', 'string': 'string', 'array': 'array', 'object': 'object' 'number': 'int', 'boolean': 'bool', 'string': 'string', 'array': 'array', 'object': 'object'
}; };
@ -554,6 +586,16 @@ export const TypeBuilder: React.FC<{
if (selectedId === id) setSelectedId(null); if (selectedId === id) setSelectedId(null);
}; };
React.useImperativeHandle(ref, () => ({
triggerSave: () => {
if (!typeName.trim()) {
// Maybe validate here or show error
return;
}
onSave({ mode, elements, name: typeName, description: typeDescription, fieldsToDelete });
}
}));
return ( return (
<DndContext <DndContext
sensors={sensors} sensors={sensors}
@ -600,4 +642,8 @@ export const TypeBuilder: React.FC<{
)} )}
</DndContext> </DndContext>
); );
}; });
export interface TypeBuilderRef {
triggerSave: () => void;
}

View File

@ -0,0 +1,56 @@
import React from 'react';
import { useActions } from '@/actions/useActions';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface TypeEditorActionsProps {
actionIds?: string[];
className?: string;
}
export const TypeEditorActions: React.FC<TypeEditorActionsProps> = ({
actionIds = ['types.new', 'types.edit.visual', 'types.preview.toggle', 'types.delete', 'types.save'],
className
}) => {
const { actions, executeAction } = useActions();
const renderActionButton = (actionId: string) => {
const action = actions[actionId];
if (!action) return null;
// Respect visible property if present, default to true
if (action.visible === false) return null;
const Icon = action.icon;
// Determine variant based on metadata or conventions
let variant: "default" | "destructive" | "outline" | "secondary" | "ghost" = "outline";
if (actionId === 'types.save') variant = 'default';
if (actionId === 'types.delete') variant = 'destructive';
if (actionId === 'types.cancel') variant = 'ghost';
if (action.metadata?.variant) variant = action.metadata.variant;
if (action.metadata?.active) variant = 'secondary'; // Or default?
return (
<Button
key={actionId}
variant={variant}
size="sm"
onClick={() => executeAction(actionId)}
disabled={action.disabled}
className={cn("gap-2", action.metadata?.className)}
title={action.tooltip}
>
{Icon && <Icon className="h-4 w-4" />}
{action.label}
</Button>
);
};
return (
<div className={cn("flex items-center gap-2", className)}>
{actionIds.map(id => renderActionButton(id))}
</div>
);
};

View File

@ -1,9 +1,9 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo, useImperativeHandle, forwardRef } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { RefreshCw, Save, Trash2, Play } from 'lucide-react'; import { RefreshCw } from 'lucide-react';
import Form from '@rjsf/core'; import Form from '@rjsf/core';
import validator from '@rjsf/validator-ajv8'; import validator from '@rjsf/validator-ajv8';
import { customWidgets, customTemplates } from './RJSFTemplates'; import { customWidgets, customTemplates } from './RJSFTemplates';
@ -11,21 +11,22 @@ import { generateRandomData } from './randomDataGenerator';
import { TypeDefinition } from './db'; import { TypeDefinition } from './db';
import { toast } from 'sonner'; import { toast } from 'sonner';
export interface TypeRendererRef {
triggerSave: () => Promise<void>;
triggerPreview: () => void;
}
interface TypeRendererProps { interface TypeRendererProps {
editedType: TypeDefinition; editedType: TypeDefinition;
types: TypeDefinition[]; types: TypeDefinition[];
onSave: (jsonSchema: string, uiSchema: string) => Promise<void>; onSave: (jsonSchema: string, uiSchema: string) => Promise<void>;
onDelete: () => Promise<void>;
onVisualEdit: () => void;
} }
export const TypeRenderer: React.FC<TypeRendererProps> = ({ export const TypeRenderer = forwardRef<TypeRendererRef, TypeRendererProps>(({
editedType, editedType,
types, types,
onSave, onSave,
onDelete, }, ref) => {
onVisualEdit
}) => {
const [jsonSchemaString, setJsonSchemaString] = useState('{}'); const [jsonSchemaString, setJsonSchemaString] = useState('{}');
const [uiSchemaString, setUiSchemaString] = useState('{}'); const [uiSchemaString, setUiSchemaString] = useState('{}');
const [showPreview, setShowPreview] = useState(false); const [showPreview, setShowPreview] = useState(false);
@ -184,13 +185,16 @@ export const TypeRenderer: React.FC<TypeRendererProps> = ({
await onSave(jsonSchemaString, uiSchemaString); await onSave(jsonSchemaString, uiSchemaString);
}; };
const handlePreviewToggle = () => { useImperativeHandle(ref, () => ({
if (!showPreview && editedType?.json_schema) { triggerSave: handleSave,
const randomData = generateRandomData(previewSchema); triggerPreview: () => {
setPreviewFormData(randomData); if (!showPreview && editedType?.json_schema) {
const randomData = generateRandomData(previewSchema);
setPreviewFormData(randomData);
}
setShowPreview(prev => !prev);
} }
setShowPreview(!showPreview); }));
};
const handleRegenerateData = () => { const handleRegenerateData = () => {
if (previewSchema) { if (previewSchema) {
@ -214,34 +218,6 @@ export const TypeRenderer: React.FC<TypeRendererProps> = ({
{editedType.description || "No description"} {editedType.description || "No description"}
</CardDescription> </CardDescription>
</div> </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> </div>
</CardHeader> </CardHeader>
<CardContent className="flex-1 min-h-0 p-0 overflow-hidden"> <CardContent className="flex-1 min-h-0 p-0 overflow-hidden">
@ -341,6 +317,6 @@ export const TypeRenderer: React.FC<TypeRendererProps> = ({
</CardContent> </CardContent>
</> </>
); );
}; });
export default TypeRenderer; export default TypeRenderer;

View File

@ -0,0 +1,349 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { TypeDefinition, updateType, createType } from './db';
import { Card } from '@/components/ui/card';
import { toast } from "sonner";
import { TypeBuilder, BuilderOutput, BuilderElement, BuilderMode, TypeBuilderRef } from './TypeBuilder';
import { TypeRenderer, TypeRendererRef } from './TypeRenderer';
import { RefreshCw, Save, Trash2, X, Play } from "lucide-react";
import { useActions } from '@/actions/useActions';
import { Action } from '@/actions/types';
export interface TypesEditorProps {
types: TypeDefinition[];
selectedType: TypeDefinition | null;
isBuilding: boolean;
onIsBuildingChange: (isBuilding: boolean) => void;
onSave: () => void; // Callback to reload types
onDeleteRaw: (id: string) => Promise<void | boolean>; // Renamed to clarify it's the raw delete function
}
export const TypesEditor: React.FC<TypesEditorProps> = ({
types,
selectedType,
isBuilding,
onIsBuildingChange,
onSave,
onDeleteRaw
}) => {
const [builderInitialData, setBuilderInitialData] = useState<BuilderOutput | undefined>(undefined);
const rendererRef = useRef<TypeRendererRef>(null);
const builderRef = useRef<TypeBuilderRef>(null);
const { registerAction, updateAction, unregisterAction } = useActions();
const handleEditVisual = useCallback(() => {
if (!selectedType) return;
// Convert current type to builder format
const builderData: BuilderOutput = {
mode: (selectedType.kind === 'structure' || selectedType.kind === 'alias') ? selectedType.kind : 'structure',
name: selectedType.name,
description: selectedType.description || '',
elements: []
};
// For structures, convert structure_fields to builder elements
if (selectedType.kind === 'structure' && selectedType.structure_fields) {
builderData.elements = selectedType.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);
onIsBuildingChange(true);
}, [selectedType, types, onIsBuildingChange]);
const getBuilderData = useCallback(() => {
if (!selectedType) return undefined;
// Convert current type to builder format
const builderData: BuilderOutput = {
mode: (selectedType.kind === 'structure' || selectedType.kind === 'alias') ? selectedType.kind : 'structure',
name: selectedType.name,
description: selectedType.description || '',
elements: []
};
// For structures, convert structure_fields to builder elements
if (selectedType.kind === 'structure' && selectedType.structure_fields) {
builderData.elements = selectedType.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;
});
}
return builderData;
}, [selectedType, types]);
// Handler for Renderer Save
const handleRendererSave = useCallback(async (jsonSchemaString: string, uiSchemaString: string) => {
if (!selectedType) return;
try {
const jsonSchema = JSON.parse(jsonSchemaString);
const uiSchema = JSON.parse(uiSchemaString);
await updateType(selectedType.id, {
json_schema: jsonSchema,
meta: { ...selectedType.meta, uiSchema }
});
toast.success("Type updated");
onSave();
} catch (e) {
console.error(e);
toast.error("Failed to update type");
}
}, [selectedType, onSave]);
// Handler for Builder Save
const handleBuilderSave = useCallback(async (output: BuilderOutput) => {
if (selectedType) {
// Editing existing type
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 === `${selectedType.name}.${el.name}` && t.kind === 'field');
// Find the parent type for this field (could be primitive or custom)
const parentType = (el as any).refId
? types.find(t => t.id === (el as any).refId)
: types.find(t => t.name === el.type);
if (!parentType) {
console.error(`Parent type not found: ${el.type} (refId: ${(el as any).refId})`);
return null;
}
const fieldTypeData = {
name: `${selectedType.name}.${el.name}`,
kind: 'field' as const,
description: el.description || `Field ${el.name}`,
parent_type_id: parentType.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;
}
}));
// Update the structure with new structure_fields
// Filter nulls strictly
const validFieldTypes = fieldUpdates.filter((f): f is TypeDefinition => f !== null && f !== undefined);
const structureFields = output.elements.map((el, idx) => ({
field_name: el.name,
field_type_id: validFieldTypes[idx]?.id || '',
required: false,
order: idx
}));
await updateType(selectedType.id, {
name: output.name,
description: output.description,
structure_fields: structureFields
});
} else {
// Update non-structure types
await updateType(selectedType.id, {
name: output.name,
description: output.description,
});
}
toast.success("Type updated");
setBuilderInitialData(undefined);
onIsBuildingChange(false);
onSave();
} 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 parentType = (el as any).refId
? types.find(t => t.id === (el as any).refId)
: types.find(t => t.name === el.type);
if (!parentType) {
throw new Error(`Parent type not found: ${el.type} (refId: ${(el as any).refId})`);
}
return await createType({
name: `${output.name}.${el.name}`,
kind: 'field',
description: el.description || `Field ${el.name}`,
parent_type_id: parentType.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");
setBuilderInitialData(undefined);
onIsBuildingChange(false);
onSave();
} catch (error) {
console.error("Failed to create type", error);
toast.error("Failed to create type");
}
}
}, [selectedType, types, onIsBuildingChange, onSave]);
// Register Actions
useEffect(() => {
const actionsDef: Action[] = [
{
id: 'types.save',
label: 'Save Changes',
icon: Save,
group: 'types',
handler: async () => {
if (isBuilding) {
builderRef.current?.triggerSave();
} else {
rendererRef.current?.triggerSave();
}
},
shortcut: 'mod+s'
},
{
id: 'types.delete',
label: 'Delete',
icon: Trash2,
group: 'types',
handler: async () => {
if (selectedType && confirm("Are you sure you want to delete this type?")) {
try {
await onDeleteRaw(selectedType.id);
toast.success("Type deleted");
onSave(); // Reload
} catch (e) {
console.error(e);
toast.error("Failed to delete type");
}
}
}
},
{
id: 'types.cancel',
label: 'Cancel',
icon: X,
group: 'types',
handler: () => {
onIsBuildingChange(false);
}
},
{
id: 'types.edit.visual',
label: 'Visual Edit',
icon: RefreshCw,
group: 'types',
handler: handleEditVisual
},
{
id: 'types.preview.toggle',
label: 'Preview',
icon: Play,
group: 'types',
handler: () => {
rendererRef.current?.triggerPreview();
},
metadata: { active: false }
}
];
actionsDef.forEach(registerAction);
return () => {
actionsDef.forEach(a => unregisterAction(a.id));
};
}, [registerAction, unregisterAction, isBuilding, selectedType, onSave, onDeleteRaw, handleEditVisual]);
// Update action states/visibility
useEffect(() => {
updateAction('types.save', { disabled: (!selectedType && !isBuilding) });
updateAction('types.delete', { disabled: !selectedType, visible: !isBuilding });
updateAction('types.edit.visual', {
visible: !isBuilding && selectedType?.kind === 'structure',
disabled: false
});
updateAction('types.cancel', { visible: isBuilding });
updateAction('types.preview.toggle', {
visible: !isBuilding && selectedType?.kind === 'structure'
});
}, [selectedType, isBuilding, updateAction]);
if (isBuilding) {
return (
<div className="h-full flex flex-col min-h-0 overflow-hidden">
<TypeBuilder
ref={builderRef}
onSave={handleBuilderSave}
onCancel={() => {
onIsBuildingChange(false);
setBuilderInitialData(undefined);
}}
availableTypes={types}
initialData={builderInitialData || getBuilderData()}
/>
</div>
);
}
return (
<Card className="flex bg-muted/30 flex-col h-full overflow-hidden border-0 shadow-none rounded-none w-full">
{selectedType ? (
<TypeRenderer
ref={rendererRef}
editedType={selectedType}
types={types}
onSave={handleRendererSave}
/>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground flex-col gap-2 p-8 text-center bg-card">
<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>
);
};

View File

@ -0,0 +1,77 @@
import React, { useMemo } from 'react';
import { TypeDefinition } from './db';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
interface TypesListProps {
types: TypeDefinition[];
selectedTypeId: string | null;
onSelect: (type: TypeDefinition) => void;
className?: string;
onDelete?: (typeId: string) => void; // Optional if we want to support delete from list context
}
export const TypesList: React.FC<TypesListProps> = ({
types,
selectedTypeId,
onSelect,
className
}) => {
// 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 (
<Card className={`flex flex-col min-h-0 ${className}`}>
<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={() => onSelect(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>
);
};

View File

@ -1,30 +1,31 @@
import React, { useEffect, useState, useMemo } from 'react'; import React, { useEffect, useState } from 'react';
import { fetchTypes, updateType, createType, deleteType, TypeDefinition } from './db'; import { fetchTypes, deleteType, TypeDefinition } from './db';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Loader2, Plus } from "lucide-react";
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 { toast } from "sonner";
import { TypeBuilder, BuilderOutput, BuilderElement, BuilderMode } from './TypeBuilder'; import { TypesList } from './TypesList';
import TypeRenderer from './TypeRenderer'; import { TypesEditor } from './TypesEditor';
import { useActions } from '@/actions/useActions';
import { Action } from '@/actions/types';
import { TypeEditorActions } from './TypeEditorActions';
const TypesPlayground: React.FC = () => { const TypesPlayground: React.FC = () => {
const [types, setTypes] = useState<TypeDefinition[]>([]); const [types, setTypes] = useState<TypeDefinition[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedTypeId, setSelectedTypeId] = useState<string | null>(null); const [selectedTypeId, setSelectedTypeId] = useState<string | null>(null);
const [editedType, setEditedType] = useState<TypeDefinition | null>(null);
const [isBuilding, setIsBuilding] = useState(false); const [isBuilding, setIsBuilding] = useState(false);
const [builderInitialData, setBuilderInitialData] = useState<BuilderOutput | undefined>(undefined);
const loadTypes = async () => { const { registerAction, updateAction, unregisterAction } = useActions();
const loadTypes = React.useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const data = await fetchTypes(); const data = await fetchTypes();
console.log('types', data); // console.log('types', data);
setTypes(data); setTypes(data);
if (selectedTypeId) { if (selectedTypeId) {
const refreshed = data.find(t => t.id === selectedTypeId); // Ensure selection is valid
if (refreshed) selectType(refreshed); const exists = data.find(t => t.id === selectedTypeId);
if (!exists) setSelectedTypeId(null);
} }
} catch (error) { } catch (error) {
console.error("Failed to fetch types", error); console.error("Failed to fetch types", error);
@ -32,202 +33,72 @@ const TypesPlayground: React.FC = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [selectedTypeId]);
useEffect(() => { useEffect(() => {
loadTypes(); loadTypes();
}, [loadTypes]);
const handleCreateNew = React.useCallback(() => {
setSelectedTypeId(null);
setIsBuilding(true);
}, []); }, []);
const selectType = (t: TypeDefinition) => { const handleSelectType = React.useCallback((t: TypeDefinition) => {
setIsBuilding(false); setIsBuilding(false);
setSelectedTypeId(t.id); setSelectedTypeId(t.id);
setEditedType(t); }, []);
};
const handleCreateNew = () => { // Register "New Type" action
setSelectedTypeId(null); useEffect(() => {
setEditedType(null); const action: Action = {
setBuilderInitialData(undefined); id: 'types.new',
setIsBuilding(true); label: 'New Type',
}; icon: Plus,
group: 'types',
const handleEditVisual = () => { handler: handleCreateNew,
if (!editedType) return; shortcut: 'mod+n' // Or something else
// 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 registerAction(action);
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); return () => unregisterAction('types.new');
setIsBuilding(true); }, [registerAction, unregisterAction]);
};
const handleBuilderSave = async (output: BuilderOutput) => { // Update "New Type" state
console.log('Builder output:', output); useEffect(() => {
updateAction('types.new', {
disabled: isBuilding || loading,
visible: !isBuilding // Hide when building to keep toolbar clean? Or just disable?
// User requested "Gray out buttons", so maybe just disabled.
// But if we hide it, we make space for other actions.
// Let's decide to keep it visible but disabled if building, or hide it if we want to focus on editor actions.
// Logic in TypesEditor hides some actions.
// Let's hide 'types.new' when building to match 'cancel' appearing.
});
}, [isBuilding, loading, updateAction]);
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 parent type for this field (could be primitive or custom)
// First check if element has a refId (for custom types dragged from palette)
let parentType = (el as any).refId
? types.find(t => t.id === (el as any).refId)
: types.find(t => t.name === el.type);
if (!parentType) {
console.error(`Parent type not found: ${el.type} (refId: ${(el as any).refId})`);
return null;
}
const fieldTypeData = {
name: `${editedType.name}.${el.name}`,
kind: 'field' as const,
description: el.description || `Field ${el.name}`,
parent_type_id: parentType.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) => {
// Find the parent type (could be primitive or custom)
const parentType = (el as any).refId
? types.find(t => t.id === (el as any).refId)
: types.find(t => t.name === el.type);
if (!parentType) {
throw new Error(`Parent type not found: ${el.type} (refId: ${(el as any).refId})`);
}
return await createType({
name: `${output.name}.${el.name}`,
kind: 'field',
description: el.description || `Field ${el.name}`,
parent_type_id: parentType.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 ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
{/* Header */} {/* Header */}
<div className="border-b px-6 py-4 flex items-center justify-between"> <div className="border-b px-6 py-4 flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold">Types Playground</h1> <h1 className="text-2xl font-bold">Types Editor</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Manage and preview your type definitions Manage and preview your type definitions
</p> </p>
</div> </div>
<Button onClick={handleCreateNew} size="sm"> <TypeEditorActions
<Plus className="mr-2 h-4 w-4" /> actionIds={[
New Type 'types.new',
</Button> 'types.edit.visual',
'types.preview.toggle',
'types.delete',
'types.cancel',
'types.save'
]}
/>
</div> </div>
{/* Main Content */} {/* Main Content */}
@ -239,111 +110,29 @@ const TypesPlayground: React.FC = () => {
) : ( ) : (
<div className="grid grid-cols-12 gap-6 h-full"> <div className="grid grid-cols-12 gap-6 h-full">
{/* List Sidebar */} {/* List Sidebar */}
<Card className={`flex flex-col min-h-0 ${isBuilding ? 'hidden' : 'col-span-3'}`}> <TypesList
<CardHeader className="pb-3 border-b px-4 py-3"> types={types}
<CardTitle className="text-sm font-medium">Available Types</CardTitle> selectedTypeId={selectedTypeId}
</CardHeader> onSelect={handleSelectType}
<CardContent className="flex-1 min-h-0 p-0"> className={`col-span-3 ${isBuilding ? 'hidden' : ''}`}
<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 */} {/* Main Content Area */}
<div className={`${isBuilding ? 'col-span-12' : 'col-span-9'} flex flex-col min-h-0 overflow-hidden`}> <div className={`${isBuilding ? 'col-span-12' : 'col-span-9'} flex flex-col min-h-0 overflow-hidden`}>
{isBuilding ? ( <TypesEditor
<TypeBuilder types={types}
onSave={handleBuilderSave} selectedType={types.find(t => t.id === selectedTypeId) || null}
onCancel={() => { setIsBuilding(false); setBuilderInitialData(undefined); if (types.length > 0 && selectedTypeId) selectType(types.find(t => t.id === selectedTypeId)!); }} isBuilding={isBuilding}
availableTypes={types} onIsBuildingChange={setIsBuilding}
initialData={builderInitialData} onSave={loadTypes}
/> onDeleteRaw={deleteType}
) : ( />
<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>
)} )}
</div> </div>
</div> </div>
); );
}; };
export default TypesPlayground; export default TypesPlayground;

View File

@ -31,7 +31,7 @@ export interface TypeDefinition {
} }
export const fetchTypes = async (options?: { export const fetchTypes = async (options?: {
kind?: TypeDefinition['kind'] | string; // Allow string for flexibility or specific enum kind?: TypeDefinition['kind'] | string;
parentTypeId?: string; parentTypeId?: string;
visibility?: string; visibility?: string;
}) => { }) => {
@ -39,64 +39,39 @@ export const fetchTypes = async (options?: {
const key = `types-${JSON.stringify(options || {})}`; const key = `types-${JSON.stringify(options || {})}`;
return fetchWithDeduplication(key, async () => { return fetchWithDeduplication(key, async () => {
let query = supabase const params = new URLSearchParams();
.from('types') if (options?.kind) params.append('kind', options.kind);
.select(` if (options?.parentTypeId) params.append('parentTypeId', options.parentTypeId);
*, if (options?.visibility) params.append('visibility', options.visibility);
structure_fields:type_structure_fields!type_structure_fields_structure_type_id_fkey(*)
`)
.order('name');
if (options?.kind) { const { data: sessionData } = await supabase.auth.getSession();
query = query.eq('kind', options.kind as any); const token = sessionData.session?.access_token;
} const headers: HeadersInit = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
if (options?.parentTypeId) { const res = await fetch(`/api/types?${params.toString()}`, { headers });
query = query.eq('parent_type_id', options.parentTypeId); if (!res.ok) throw new Error(`Failed to fetch types: ${res.statusText}`);
}
if (options?.visibility) { const data = await res.json();
query = query.eq('visibility', options.visibility as any);
}
const { data, error } = await query;
if (error) throw error;
return data as TypeDefinition[]; return data as TypeDefinition[];
}, 1); // 5 min cache }, 1); // 5 min cache (client side)
}; };
export const fetchTypeById = async (id: string) => { export const fetchTypeById = async (id: string) => {
const key = `type-${id}`; const key = `type-${id}`;
return fetchWithDeduplication(key, async () => { return fetchWithDeduplication(key, async () => {
// We can call the API endpoint or Supabase directly. const { data: sessionData } = await supabase.auth.getSession();
// Using API might yield more enriched data if the server does heavy lifting. const token = sessionData.session?.access_token;
// But for consistency with lib/db.ts, let's use supabase client directly or the API route? const headers: HeadersInit = {};
// lib/db.ts uses supabase client directly mostly. if (token) headers['Authorization'] = `Bearer ${token}`;
// 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; const res = await fetch(`/api/types/${id}`, { headers });
if (!res.ok) {
if (res.status === 404) return null;
throw new Error(`Failed to fetch type ${id}: ${res.statusText}`);
}
const data = await res.json();
return data as TypeDefinition; return data as TypeDefinition;
}); });
}; };

View File

@ -7,7 +7,7 @@ import { T, translate } from "@/i18n";
import { toast } from "sonner"; import { toast } from "sonner";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
import { invalidateUserPageCache } from "@/lib/db"; import { invalidateUserPageCache } from "@/lib/db";
import { PageActions } from "@/components/PageActions"; const PageActions = React.lazy(() => import("@/components/PageActions").then(module => ({ default: module.PageActions })));
import { import {
FileText, Check, X, Calendar, FolderTree, EyeOff, Plus FileText, Check, X, Calendar, FolderTree, EyeOff, Plus
} from "lucide-react"; } from "lucide-react";
@ -356,19 +356,21 @@ export const UserPageDetails: React.FC<UserPageDetailsProps> = ({
{/* PageActions - Only visible in View Mode (Edit Mode uses PageRibbonBar) */} {/* PageActions - Only visible in View Mode (Edit Mode uses PageRibbonBar) */}
{!isEditMode && ( {!isEditMode && (
<PageActions <React.Suspense fallback={<div className="h-9 w-24 bg-muted animate-pulse rounded" />}>
page={page} <PageActions
isOwner={isOwner} page={page}
isEditMode={isEditMode} isOwner={isOwner}
onToggleEditMode={() => { isEditMode={isEditMode}
onToggleEditMode(); onToggleEditMode={() => {
if (isEditMode) onWidgetRename(null); onToggleEditMode();
}} if (isEditMode) onWidgetRename(null);
onPageUpdate={onPageUpdate} }}
onMetaUpdated={() => userId && page.slug && invalidateUserPageCache(userId, page.slug)} // Simple invalidation trigger onPageUpdate={onPageUpdate}
templates={templates} onMetaUpdated={() => userId && page.slug && invalidateUserPageCache(userId, page.slug)} // Simple invalidation trigger
onLoadTemplate={onLoadTemplate} templates={templates}
/> onLoadTemplate={onLoadTemplate}
/>
</React.Suspense>
)} )}
</div> </div>

View File

@ -4,12 +4,17 @@ import validator from '@rjsf/validator-ajv8';
import { TypeDefinition, fetchTypes } from '../types/db'; import { TypeDefinition, fetchTypes } from '../types/db';
import { generateSchemaForType, generateUiSchemaForType } from '@/lib/schema-utils'; import { generateSchemaForType, generateUiSchemaForType } from '@/lib/schema-utils';
import { customWidgets, customTemplates } from '../types/RJSFTemplates'; import { customWidgets, customTemplates } from '../types/RJSFTemplates';
import { useQuery } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useLayout } from '@/contexts/LayoutContext'; import { useLayout } from '@/contexts/LayoutContext';
import { UpdatePageMetaCommand } from '@/lib/page-commands/commands'; import { UpdatePageMetaCommand } from '@/lib/page-commands/commands';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Accordion, AccordionContent, AccordionItem } from "@/components/ui/accordion";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Loader2, Settings, ChevronDown } from 'lucide-react';
import { TypesEditor } from '../types/TypesEditor';
import { TypeEditorActions } from '../types/TypeEditorActions';
interface UserPageTypeFieldsProps { interface UserPageTypeFieldsProps {
pageId: string; pageId: string;
@ -77,6 +82,37 @@ export const UserPageTypeFields: React.FC<UserPageTypeFieldsProps> = ({
await executeCommand(command); await executeCommand(command);
}; };
const [editingType, setEditingType] = useState<TypeDefinition | null>(null);
const [isBuilding, setIsBuilding] = useState(false);
// We need to import these dynamically or at top level?
// They are lazy loaded in UserPageEdit, so let's import them at top level for now,
// or use lazy here too if we want to code split.
// Since this component is already lazy loaded, direct import is fine.
// However, for circular dependency avoidance (if any), let's check.
// TypesEditor depends on TypesBuilder/Renderer.
// Let's assume direct import is safe.
const handleEditType = (e: React.MouseEvent, type: TypeDefinition) => {
e.stopPropagation();
setEditingType(type);
setIsBuilding(true);
};
const queryClient = useQueryClient();
const handleTypeSave = () => {
// Invalidate types query to refresh schema
queryClient.invalidateQueries({ queryKey: ['types', 'all'] });
// Also need to refresh the specific type in the list if name/desc changed
// assignedTypes prop comes from parent, which comes from fetching page.
// If type definition changes, the page might not need update, but the schema does.
// We might need to force re-render or refetch types.
// The useQuery['types', 'all'] invalidation should trigger re-render of this component
// because we use `allTypes` from that query.
};
if (assignedTypes.length === 0) return null; if (assignedTypes.length === 0) return null;
if (typesLoading) { if (typesLoading) {
@ -87,7 +123,7 @@ export const UserPageTypeFields: React.FC<UserPageTypeFieldsProps> = ({
<div className="space-y-6 mt-8"> <div className="space-y-6 mt-8">
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Type Properties</h2> <h2 className="text-lg font-semibold border-b pb-2 mb-4">Type Properties</h2>
<Accordion type="multiple" className="w-full"> <Accordion type="multiple" className="w-full" defaultValue={assignedTypes.map(t => t.id)} key={assignedTypes.map(t => t.id).join(',')}>
{assignedTypes.map(type => { {assignedTypes.map(type => {
const schema = generateSchemaForType(type.id, allTypes); const schema = generateSchemaForType(type.id, allTypes);
// Ensure schema is object type for form rendering // Ensure schema is object type for form rendering
@ -107,16 +143,39 @@ export const UserPageTypeFields: React.FC<UserPageTypeFieldsProps> = ({
return ( return (
<AccordionItem key={type.id} value={type.id} className="border-b last:border-0 border-border"> <AccordionItem key={type.id} value={type.id} className="border-b last:border-0 border-border">
<AccordionTrigger className="px-4 py-3 hover:no-underline hover:bg-muted/50 transition-colors"> {/*
<div className="flex flex-col items-start text-left"> Fix for nested button warning:
<span className="font-semibold text-sm tracking-tight">{type.name}</span> Use an overlay pattern where the Trigger is absolute and sits behind the content.
{type.description && ( The content is pointer-events-none, except for the interactive buttons which are pointer-events-auto.
<span className="text-[10px] text-muted-foreground font-normal mt-0.5 max-w-[200px] truncate"> */}
{type.description} <AccordionPrimitive.Header className="relative flex items-center group hover:bg-muted/50 transition-colors">
</span> <AccordionPrimitive.Trigger className="peer absolute inset-0 w-full h-full z-0 cursor-pointer" />
)}
<div className="relative z-10 flex items-center justify-between w-full px-4 py-3 pointer-events-none">
<div className="flex flex-col items-start text-left">
<span className="font-semibold text-sm tracking-tight">{type.name}</span>
{type.description && (
<span className="text-[10px] text-muted-foreground font-normal mt-0.5 max-w-[200px] truncate">
{type.description}
</span>
)}
</div>
<div className="flex items-center">
{isEditMode && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity mr-2 pointer-events-auto"
onClick={(e) => handleEditType(e, type)}
title="Edit Type Definition"
>
<Settings className="h-4 w-4 text-muted-foreground" />
</Button>
)}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200 text-muted-foreground peer-data-[state=open]:rotate-180" />
</div>
</div> </div>
</AccordionTrigger> </AccordionPrimitive.Header>
<AccordionContent className="px-4 pb-4 pt-2"> <AccordionContent className="px-4 pb-4 pt-2">
<Form <Form
schema={schema} schema={schema}
@ -143,6 +202,41 @@ export const UserPageTypeFields: React.FC<UserPageTypeFieldsProps> = ({
); );
})} })}
</Accordion> </Accordion>
{/* Edit Type Dialog */}
<Dialog open={!!editingType} onOpenChange={(open) => !open && setEditingType(null)}>
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-6 py-4 border-b flex flex-row items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<DialogTitle>Edit Type: {editingType?.name}</DialogTitle>
</div>
<div className="flex items-center gap-2 mr-8">
<TypeEditorActions
actionIds={[
'types.save',
'types.edit.visual',
'types.preview.toggle',
'types.cancel'
]}
/>
</div>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-hidden bg-muted/10 p-4">
{editingType && (
<TypesEditor
types={allTypes}
selectedType={editingType}
isBuilding={isBuilding}
onIsBuildingChange={setIsBuilding}
onSave={handleTypeSave}
onDeleteRaw={async () => {
toast.error("Deleting currently used types is not recommended here.");
}}
/>
)}
</div>
</DialogContent>
</Dialog>
</div> </div>
); );
}; };

View File

@ -1,4 +1,5 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
@ -67,6 +68,16 @@ interface Page {
slug: string; slug: string;
} | null; } | null;
meta?: any; meta?: any;
categories?: {
id: string;
name: string;
slug: string;
}[];
category_paths?: {
id: string;
name: string;
slug: string;
}[][];
} }
interface PageRibbonBarProps { interface PageRibbonBarProps {
@ -221,6 +232,17 @@ export const PageRibbonBar = ({
hasTypeFields hasTypeFields
}: PageRibbonBarProps) => { }: PageRibbonBarProps) => {
const { executeCommand, saveToApi, loadPageLayout, clearHistory } = useLayout(); const { executeCommand, saveToApi, loadPageLayout, clearHistory } = useLayout();
const navigate = useNavigate();
const { orgSlug } = useParams();
const handleOpenTypes = React.useCallback(() => {
if (orgSlug) {
navigate(`/org/${orgSlug}/types-editor`);
} else {
navigate('/types-editor');
}
}, [navigate, orgSlug]);
const [activeTab, setActiveTab] = useState<'page' | 'widgets' | 'layouts' | 'view' | 'advanced'>('page'); const [activeTab, setActiveTab] = useState<'page' | 'widgets' | 'layouts' | 'view' | 'advanced'>('page');
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -734,6 +756,12 @@ export const PageRibbonBar = ({
{activeTab === 'advanced' && ( {activeTab === 'advanced' && (
<> <>
<RibbonGroup label="Developer"> <RibbonGroup label="Developer">
<RibbonItemLarge
icon={Type}
label="Types"
onClick={handleOpenTypes}
iconColor="text-indigo-600 dark:text-indigo-400"
/>
<RibbonItemLarge <RibbonItemLarge
icon={FileJson} icon={FileJson}
label="Dump JSON" label="Dump JSON"
@ -753,7 +781,7 @@ export const PageRibbonBar = ({
onClose={() => setShowCategoryManager(false)} onClose={() => setShowCategoryManager(false)}
currentPageId={page.id} currentPageId={page.id}
currentPageMeta={page.meta} currentPageMeta={page.meta}
onPageMetaUpdate={async (newMeta) => { onPageMetaUpdate={async (newMeta, newCategories) => {
// Use UpdatePageMetaCommand for undo/redo support // Use UpdatePageMetaCommand for undo/redo support
const pageId = `page-${page.id}`; const pageId = `page-${page.id}`;
@ -767,12 +795,28 @@ export const PageRibbonBar = ({
try { try {
await executeCommand(new UpdatePageMetaCommand( await executeCommand(new UpdatePageMetaCommand(
pageId, pageId,
oldMeta, { meta: oldMeta },
newMeta, { meta: newMeta },
(meta) => { (meta) => {
// Update local state for immediate feedback // Update local state for immediate feedback
// Note: LayoutContext also updates its pendingMetadata // Note: LayoutContext also updates its pendingMetadata
onPageUpdate({ ...page, meta: { ...page.meta, ...meta } }); // The command callback receives the payload we passed ({ meta: newMeta })
// We need to extract the inner meta for local state update if needed,
// but onPageUpdate expects the full page object.
// Since we wrapped it, 'meta' here is { meta: { ... } }
// So we need to access meta.meta
if (meta.meta) {
const updatedPage = { ...page, meta: { ...page.meta, ...meta.meta } };
// If we have resolved categories, update the page categories to show valid data immediately
// We also clear category_paths to force fallback to 'categories' in UserPageDetails
if (newCategories) {
updatedPage.categories = newCategories;
updatedPage.category_paths = undefined;
}
onPageUpdate(updatedPage);
}
if (onMetaUpdated) onMetaUpdated(); if (onMetaUpdated) onMetaUpdated();
} }
)); ));

View File

@ -17,7 +17,7 @@ interface CategoryManagerProps {
onClose: () => void; onClose: () => void;
currentPageId?: string; // If provided, allows linking page to category currentPageId?: string; // If provided, allows linking page to category
currentPageMeta?: any; currentPageMeta?: any;
onPageMetaUpdate?: (newMeta: any) => void; onPageMetaUpdate?: (newMeta: any, newCategories?: Category[]) => void;
filterByType?: string; // Filter categories by meta.type (e.g., 'layout', 'page', 'email') filterByType?: string; // Filter categories by meta.type (e.g., 'layout', 'page', 'email')
defaultMetaType?: string; // Default type to set in meta when creating new categories defaultMetaType?: string; // Default type to set in meta when creating new categories
} }
@ -147,6 +147,37 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
} }
}; };
// Helper to find category in tree
const findCategory = (id: string, cats: Category[]): Category | null => {
for (const cat of cats) {
if (cat.id === id) return cat;
if (cat.children) {
const found = cat.children.find(rel => rel.child.id === id)?.child;
if (found) return found; // Direct child match
// Deep search in children's children not strictly needed if flattened?
// Using recursive search on children
const deep = cat.children.map(c => findCategory(id, [c.child])).find(c => c);
if (deep) return deep;
}
}
return null;
};
// Flatten categories for easy lookup
const getAllCategoriesFlattened = (cats: Category[]): Category[] => {
let flat: Category[] = [];
cats.forEach(c => {
flat.push(c);
if (c.children) {
c.children.forEach(childRel => {
flat = [...flat, ...getAllCategoriesFlattened([childRel.child])];
});
}
});
return flat;
};
const handleLinkPage = async () => { const handleLinkPage = async () => {
if (!currentPageId || !selectedCategoryId) return; if (!currentPageId || !selectedCategoryId) return;
@ -158,9 +189,13 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
const newIds = [...currentIds, selectedCategoryId]; const newIds = [...currentIds, selectedCategoryId];
const newMeta = { ...currentPageMeta, categoryIds: newIds, categoryId: null }; const newMeta = { ...currentPageMeta, categoryIds: newIds, categoryId: null };
// Resolve category objects
const allCats = getAllCategoriesFlattened(categories);
const resolvedCategories = newIds.map(id => findCategory(id, categories) || allCats.find(c => c.id === id)).filter(Boolean) as Category[];
// Use callback if provided, otherwise fall back to updatePageMeta // Use callback if provided, otherwise fall back to updatePageMeta
if (onPageMetaUpdate) { if (onPageMetaUpdate) {
await onPageMetaUpdate(newMeta); await onPageMetaUpdate(newMeta, resolvedCategories);
} else { } else {
await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null }); await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null });
} }
@ -185,9 +220,13 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
const newIds = currentIds.filter(id => id !== selectedCategoryId); const newIds = currentIds.filter(id => id !== selectedCategoryId);
const newMeta = { ...currentPageMeta, categoryIds: newIds, categoryId: null }; const newMeta = { ...currentPageMeta, categoryIds: newIds, categoryId: null };
// Resolve category objects
const allCats = getAllCategoriesFlattened(categories);
const resolvedCategories = newIds.map(id => findCategory(id, categories) || allCats.find(c => c.id === id)).filter(Boolean) as Category[];
// Use callback if provided, otherwise fall back to updatePageMeta // Use callback if provided, otherwise fall back to updatePageMeta
if (onPageMetaUpdate) { if (onPageMetaUpdate) {
await onPageMetaUpdate(newMeta); await onPageMetaUpdate(newMeta, resolvedCategories);
} else { } else {
await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null }); await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null });
} }

View File

@ -1,13 +1,17 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { Gallery } from '@/pages/Post/renderers/components/Gallery'; import { Gallery } from '@/pages/Post/renderers/components/Gallery';
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog'; import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
import SmartLightbox from '@/pages/Post/components/SmartLightbox'; import SmartLightbox from '@/pages/Post/components/SmartLightbox';
import { T } from '@/i18n'; import { T, translate } from '@/i18n';
import { ImageIcon, Plus, Settings } from 'lucide-react'; import { ImageIcon, Plus, Settings, Upload, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { PostMediaItem } from '@/pages/Post/types'; import { PostMediaItem } from '@/pages/Post/types';
import { isVideoType, normalizeMediaType, detectMediaType } from '@/lib/mediaRegistry'; import { isVideoType, normalizeMediaType, detectMediaType } from '@/lib/mediaRegistry';
import { supabase } from '@/integrations/supabase/client';
import { uploadImage } from '@/lib/uploadUtils';
import { toast } from 'sonner';
const { fetchMediaItemsByIds } = await import('@/lib/db'); const { fetchMediaItemsByIds } = await import('@/lib/db');
interface GalleryWidgetProps { interface GalleryWidgetProps {
@ -52,6 +56,10 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
const [showPicker, setShowPicker] = useState(false); const [showPicker, setShowPicker] = useState(false);
const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxOpen, setLightboxOpen] = useState(false);
// Drag and drop state
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
// Sync local state with props // Sync local state with props
useEffect(() => { useEffect(() => {
const normalizedProps = normalizePictureIds(propPictureIds); const normalizedProps = normalizePictureIds(propPictureIds);
@ -90,7 +98,9 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
setMediaItems(postMediaItems); setMediaItems(postMediaItems);
// Always set first item as selected when items change // Always set first item as selected when items change
if (postMediaItems.length > 0) { // Only if we don't have a selected item or the selected item is no longer in the list
// preserving selection across updates if possible is nice, but simple logic is safer
if (postMediaItems.length > 0 && !selectedItem) {
setSelectedItem(postMediaItems[0]); setSelectedItem(postMediaItems[0]);
} }
} catch (error) { } catch (error) {
@ -136,19 +146,136 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
console.log('Open in wizard:', selectedItem); console.log('Open in wizard:', selectedItem);
}; };
// Drag and Drop Handlers
const handleDragEnter = useCallback((e: React.DragEvent) => {
if (!isEditMode) return;
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes('Files')) {
setIsDragging(true);
}
}, [isEditMode]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
if (!isEditMode) return;
e.preventDefault();
e.stopPropagation();
if (e.currentTarget.contains(e.relatedTarget as Node)) {
return;
}
setIsDragging(false);
}, [isEditMode]);
const handleDragOver = useCallback((e: React.DragEvent) => {
if (!isEditMode) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
}, [isEditMode]);
const handleDrop = useCallback(async (e: React.DragEvent) => {
if (!isEditMode) return;
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
const imageFiles = files.filter(f => f.type.startsWith('image/'));
if (imageFiles.length === 0) {
if (files.length > 0) {
toast.error(translate('Please drop image files'));
}
return;
}
await processDroppedImages(imageFiles);
}, [isEditMode, pictureIds]);
const processDroppedImages = async (files: File[]) => {
setIsUploading(true);
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
toast.error(translate('You must be logged in to upload images'));
return;
}
const newPictureIds: string[] = [];
let successfulUploads = 0;
for (const file of files) {
try {
// Upload
const { publicUrl, meta } = await uploadImage(file, user.id);
// Create Record
const { data: pictureData, error: insertError } = await supabase
.from('pictures')
.insert({
user_id: user.id,
title: file.name.split('.')[0] || 'Uploaded Image',
description: null,
image_url: publicUrl,
type: 'supabase-image',
meta: meta || {},
})
.select()
.single();
if (insertError) throw insertError;
if (pictureData) {
newPictureIds.push(pictureData.id);
successfulUploads++;
}
} catch (err) {
console.error(`Failed to upload ${file.name}:`, err);
}
}
if (newPictureIds.length > 0) {
const updatedIds = [...(pictureIds || []), ...newPictureIds];
handlePicturesSelected(updatedIds);
toast.success(translate(`Uploaded ${successfulUploads} images`));
} else {
toast.error(translate('Failed to upload images'));
}
} catch (error) {
console.error('Error processing dropped images:', error);
toast.error(translate('Error processing uploads'));
} finally {
setIsUploading(false);
}
};
// Empty state // Empty state
if (!pictureIds || pictureIds.length === 0) { if (!pictureIds || pictureIds.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center h-full min-h-[400px] bg-muted/30 border-1 border-dashed"> <div
className={`flex flex-col items-center justify-center h-full min-h-[400px] bg-muted/30 border-1 border-dashed relative transition-all duration-200 ${isDragging ? 'ring-4 ring-primary ring-inset rounded-lg scale-[0.99]' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<ImageIcon className="w-16 h-16 text-muted-foreground mb-4" /> <ImageIcon className="w-16 h-16 text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
<T>No pictures selected</T> <T>No pictures selected</T>
</p> </p>
{isEditMode && ( {isEditMode && (
<Button onClick={() => setShowPicker(true)} variant="outline"> <>
<Plus className="w-4 h-4 mr-2" /> <Button onClick={() => setShowPicker(true)} variant="outline">
<T>Select Pictures</T> <Plus className="w-4 h-4 mr-2" />
</Button> <T>Select Pictures</T>
</Button>
<p className="text-xs text-muted-foreground mt-4">
<T>or drag and drop images here</T>
</p>
</>
)} )}
{showPicker && ( {showPicker && (
<ImagePickerDialog <ImagePickerDialog
@ -159,12 +286,33 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
currentValues={pictureIds} currentValues={pictureIds}
/> />
)} )}
{/* Drop Overlay for Empty State */}
{isDragging && (
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex flex-col items-center justify-center rounded-lg z-50 border-2 border-dashed border-primary pointer-events-none">
<div className="bg-primary/10 p-6 rounded-full mb-4">
<Upload className="h-10 w-10 text-primary animate-bounce" />
</div>
<p className="text-lg font-semibold text-primary">
<T>Drop images to add</T>
</p>
</div>
)}
{/* Uploading Overlay */}
{isUploading && (
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex flex-col items-center justify-center rounded-lg z-50">
<Loader2 className="h-10 w-10 text-primary animate-spin mb-4" />
<p className="text-lg font-semibold text-foreground">
<T>Uploading images...</T>
</p>
</div>
)}
</div> </div>
); );
} }
// Loading state // Loading state
if (loading) { if (loading && !isUploading) { // Show normal loading only if not uploading (upload shows its own overlay)
return ( return (
<div className="flex items-center justify-center h-full min-h-[400px]"> <div className="flex items-center justify-center h-full min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
@ -174,11 +322,26 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
// Gallery view // Gallery view
if (!selectedItem || mediaItems.length === 0) { if (!selectedItem || mediaItems.length === 0) {
return null; // Fallback if IDs exist but fetch failed or returned nothing
// Should probably show empty state or error
// But for now, null or empty state
return (
<div className="flex flex-col items-center justify-center h-full min-h-[400px] bg-muted/30 border-1 border-dashed">
<p className="text-muted-foreground">
<T>Loading gallery items...</T>
</p>
</div>
);
} }
return ( return (
<div className="relative w-full aspect-video flex flex-col"> <div
className={`relative w-full aspect-video flex flex-col transition-all duration-200 ${isDragging ? 'ring-4 ring-primary ring-inset rounded-lg scale-[0.99]' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Edit Mode Controls */} {/* Edit Mode Controls */}
{isEditMode && ( {isEditMode && (
<div className="absolute top-2 right-2 z-50"> <div className="absolute top-2 right-2 z-50">
@ -213,6 +376,28 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
/> />
</div> </div>
{/* Drop Overlay */}
{isDragging && (
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex flex-col items-center justify-center rounded-lg z-50 border-2 border-dashed border-primary pointer-events-none">
<div className="bg-primary/10 p-6 rounded-full mb-4">
<Upload className="h-10 w-10 text-primary animate-bounce" />
</div>
<p className="text-lg font-semibold text-primary">
<T>Drop images to add</T>
</p>
</div>
)}
{/* Uploading Overlay */}
{isUploading && (
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex flex-col items-center justify-center rounded-lg z-50">
<Loader2 className="h-10 w-10 text-primary animate-spin mb-4" />
<p className="text-lg font-semibold text-foreground">
<T>Uploading images...</T>
</p>
</div>
)}
{/* Image Picker Dialog */} {/* Image Picker Dialog */}
{showPicker && ( {showPicker && (
<ImagePickerDialog <ImagePickerDialog

View File

@ -0,0 +1,117 @@
import React, { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { T } from '@/i18n';
import { AIPageGenerator } from '@/components/AIPageGenerator';
import { generateHtmlSnippet } from '@/lib/openai';
import { toast } from 'sonner';
import { usePromptHistory } from '@/hooks/usePromptHistory';
interface HtmlGeneratorWizardProps {
isOpen: boolean;
onClose: () => void;
onHtmlGenerated: (html: string) => void;
initialPrompt?: string;
contextVariables?: Record<string, any>;
pageContext?: any;
}
export const HtmlGeneratorWizard: React.FC<HtmlGeneratorWizardProps> = ({
isOpen,
onClose,
onHtmlGenerated,
initialPrompt = '',
contextVariables = {},
pageContext
}) => {
const [isGenerating, setIsGenerating] = useState(false);
const [includePageContext, setIncludePageContext] = useState(false);
// Reuse the prompt history hook for consistent behavior
const {
prompt,
setPrompt,
promptHistory,
historyIndex,
navigateHistory,
addPromptToHistory
} = usePromptHistory();
// Reset prompt when opening with new initialPrompt
useEffect(() => {
if (isOpen && initialPrompt) {
setPrompt(initialPrompt);
} else if (isOpen) {
setPrompt('');
}
}, [isOpen, initialPrompt, setPrompt]);
const handleGenerate = async () => {
if (!prompt.trim()) return;
setIsGenerating(true);
addPromptToHistory(prompt);
try {
const html = await generateHtmlSnippet(
prompt,
contextVariables,
includePageContext ? pageContext : null
);
if (html) {
onHtmlGenerated(html);
onClose();
toast.success('HTML snippet generated successfully!');
} else {
toast.error('Failed to generate HTML snippet.');
}
} catch (error) {
console.error('HTML generation error:', error);
toast.error('An error occurred during generation.');
} finally {
setIsGenerating(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle><T>Generate HTML Code</T></DialogTitle>
<DialogDescription>
<T>Describe the UI component or section you want to create. The AI will generate pure HTML using Tailwind CSS.</T>
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
{pageContext && (
<div className="flex items-center space-x-2">
<Switch
id="include-page-context"
checked={includePageContext}
onCheckedChange={setIncludePageContext}
/>
<Label htmlFor="include-page-context" className="text-sm font-medium">
<T>Include Page Context (JSON)</T>
</Label>
</div>
)}
<AIPageGenerator
prompt={prompt}
onPromptChange={setPrompt}
onGenerate={handleGenerate}
isGenerating={isGenerating}
generationStatus={isGenerating ? 'generating' : 'idle'}
onCancel={() => setIsGenerating(false)}
promptHistory={promptHistory}
historyIndex={historyIndex}
onNavigateHistory={navigateHistory}
// Disable image-related features for HTML generation
disabled={isGenerating}
/>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -8,6 +8,8 @@ import { marked } from 'marked';
interface HtmlWidgetProps { interface HtmlWidgetProps {
src?: string; src?: string;
html?: string; html?: string;
content?: string; // New content prop
variables?: string | Record<string, any>; // JSON string or object
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
isEditMode?: boolean; isEditMode?: boolean;
@ -18,14 +20,18 @@ interface HtmlWidgetProps {
export const HtmlWidget: React.FC<HtmlWidgetProps> = ({ export const HtmlWidget: React.FC<HtmlWidgetProps> = ({
src, src,
html: initialHtml, html: initialHtml,
content: initialContent,
variables,
className = '', className = '',
style, style,
isEditMode, isEditMode,
onPropsChange, onPropsChange,
...restProps ...restProps
}) => { }) => {
const [content, setContent] = useState<string | null>(initialHtml || null); // Prioritize content over html
const [processedContent, setProcessedContent] = useState<string | null>(initialHtml || null); const sourceHtml = initialContent || initialHtml;
const [content, setContent] = useState<string | null>(sourceHtml || null);
const [processedContent, setProcessedContent] = useState<string | null>(sourceHtml || null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -36,8 +42,8 @@ export const HtmlWidget: React.FC<HtmlWidgetProps> = ({
const [currentImageValue, setCurrentImageValue] = useState<string | null>(null); const [currentImageValue, setCurrentImageValue] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (initialHtml) { if (sourceHtml) {
setContent(initialHtml); setContent(sourceHtml);
return; return;
} }
@ -58,7 +64,7 @@ export const HtmlWidget: React.FC<HtmlWidgetProps> = ({
setLoading(false); setLoading(false);
}); });
} }
}, [src, initialHtml]); }, [src, sourceHtml]);
// Apply substitutions whenever content or props change // Apply substitutions whenever content or props change
useEffect(() => { useEffect(() => {
@ -69,8 +75,30 @@ export const HtmlWidget: React.FC<HtmlWidgetProps> = ({
} }
try { try {
// Pre-process props for markdown // Parse variables if needed
const finalProps = { ...restProps }; let parsedVariables: Record<string, any> = {};
if (typeof variables === 'string') {
try {
parsedVariables = JSON.parse(variables);
} catch (e) {
console.warn('Failed to parse variables JSON', e);
}
} else if (typeof variables === 'object') {
parsedVariables = variables || {};
}
// Merge props: contextVariables + restProps + variables
// Priority:
// 1. variables prop (explicitly set for this widget)
// 2. restProps (legacy or passed props)
// 3. contextVariables (global page variables) - LEAST priority to allow override?
// Actually, usually local overrides global. So contextVariables should be base.
const finalProps = {
...((restProps as any).contextVariables || {}), // From context
...restProps,
...parsedVariables
};
const markdownKeys = new Set<string>(); const markdownKeys = new Set<string>();
if (finalProps.widgetDefId) { if (finalProps.widgetDefId) {
@ -173,7 +201,7 @@ export const HtmlWidget: React.FC<HtmlWidgetProps> = ({
}; };
processContent(); processContent();
}, [content, restProps, isEditMode]); }, [content, restProps, variables, isEditMode]);
// Event Delegation for Inline Editing // Event Delegation for Inline Editing

View File

@ -1,10 +1,12 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import PhotoCard from '@/components/PhotoCard'; import PhotoCard from '@/components/PhotoCard';
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog'; import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
import { T } from '@/i18n'; import { T, translate } from '@/i18n';
import { ImageIcon, Plus } from 'lucide-react'; import { ImageIcon, Plus, Upload, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { uploadImage } from '@/lib/uploadUtils';
import { toast } from 'sonner';
interface PhotoCardWidgetProps { interface PhotoCardWidgetProps {
isEditMode?: boolean; isEditMode?: boolean;
@ -30,6 +32,7 @@ interface Picture {
interface UserProfile { interface UserProfile {
display_name: string | null; display_name: string | null;
username: string | null; username: string | null;
avatar_url?: string | null;
} }
const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
@ -48,6 +51,10 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
// Drag and drop state
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
// Sync local state with props // Sync local state with props
useEffect(() => { useEffect(() => {
setPictureId(propPictureId); setPictureId(propPictureId);
@ -63,7 +70,31 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
const fetchPictureData = async () => { const fetchPictureData = async () => {
if (!pictureId) return; if (!pictureId) return;
// Validate that pictureId is a UUID, not an image URL // Check if pictureId is a URL
const isUrl = pictureId.startsWith('http://') || pictureId.startsWith('https://');
if (isUrl) {
// Create dummy picture object for external URL
setPicture({
id: pictureId,
title: 'External Image',
description: null,
image_url: pictureId,
likes_count: 0,
created_at: new Date().toISOString(),
user_id: 'external'
});
setUserProfile({
display_name: 'External Source',
username: 'external',
avatar_url: null
});
setCommentsCount(0);
setLoading(false);
return;
}
// Validate that pictureId is a UUID
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(pictureId)) { if (!uuidRegex.test(pictureId)) {
console.error('Invalid picture ID format. Expected UUID, got:', pictureId); console.error('Invalid picture ID format. Expected UUID, got:', pictureId);
@ -87,7 +118,7 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
// Fetch user profile // Fetch user profile
const { data: profileData } = await supabase const { data: profileData } = await supabase
.from('profiles') .from('profiles')
.select('display_name, username') .select('display_name, username, avatar_url')
.eq('user_id', pictureData.user_id) .eq('user_id', pictureData.user_id)
.single(); .single();
@ -127,11 +158,125 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
} }
}; };
if (!pictureId) { // Drag and Drop Handlers
return ( const handleDragEnter = useCallback((e: React.DragEvent) => {
<> if (!isEditMode) return;
e.preventDefault();
e.stopPropagation();
// Check if dragging files
if (e.dataTransfer.types.includes('Files')) {
setIsDragging(true);
}
}, [isEditMode]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
if (!isEditMode) return;
e.preventDefault();
e.stopPropagation();
// Only set dragging to false if we're leaving the main container
// This is a simple check, typically you'd check relatedTarget
if (e.currentTarget.contains(e.relatedTarget as Node)) {
return;
}
setIsDragging(false);
}, [isEditMode]);
const handleDragOver = useCallback((e: React.DragEvent) => {
if (!isEditMode) return;
e.preventDefault();
e.stopPropagation();
// Important to allow drop
e.dataTransfer.dropEffect = 'copy';
}, [isEditMode]);
const handleDrop = useCallback(async (e: React.DragEvent) => {
if (!isEditMode) return;
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
// Filter for images
const imageFiles = files.filter(f => f.type.startsWith('image/'));
if (imageFiles.length === 0) {
if (files.length > 0) {
toast.error(translate('Please drop an image file'));
}
return;
}
const file = imageFiles[0];
await processDroppedImage(file);
}, [isEditMode]); // Removed processDroppedImage from deps to avoid circular dependency if define outside
const processDroppedImage = async (file: File) => {
setIsUploading(true);
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
toast.error(translate('You must be logged in to upload images'));
return;
}
// 1. Upload to storage
const { publicUrl, meta } = await uploadImage(file, user.id);
// 2. Create picture record
const { data: pictureData, error: insertError } = await supabase
.from('pictures')
.insert({
user_id: user.id,
title: file.name.split('.')[0] || 'Uploaded Image',
description: null,
image_url: publicUrl,
type: 'supabase-image',
meta: meta || {},
// We don't link to a post here yet, it's just a picture in their library
// unless we want to auto-create a post?
// For a widget, we usually just point to the picture.
// The widget displays a "Picture", so we need a picture record.
})
.select()
.single();
if (insertError) throw insertError;
if (pictureData) {
// 3. Update widget props
handleSelectPicture(pictureData.id);
toast.success(translate('Image uploaded successfully'));
}
} catch (error) {
console.error('Error uploading dropped image:', error);
toast.error(translate('Failed to upload image'));
} finally {
setIsUploading(false);
}
};
// Render content based on state
const renderContent = () => {
if (loading) {
return (
<div className="bg-card border rounded-lg p-8 text-center min-h-[300px] flex flex-col items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-sm text-muted-foreground">
<T>Loading picture...</T>
</p>
</div>
);
}
if (!pictureId || !picture) {
return (
<div <div
className={`bg-card border rounded-lg p-8 text-center ${isEditMode ? 'cursor-pointer hover:border-primary hover:bg-accent/50 transition-colors' : '' className={`bg-card border rounded-lg p-8 text-center min-h-[300px] flex flex-col items-center justify-center relative ${isEditMode ? 'cursor-pointer hover:border-primary hover:bg-accent/50 transition-colors' : ''
}`} }`}
onClick={handleOpenPicker} onClick={handleOpenPicker}
> >
@ -140,64 +285,32 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
{isEditMode ? ( {isEditMode ? (
<T>No picture selected</T> <T>No picture selected</T>
) : ( ) : (
<T>No picture selected</T> <T>Empty Photo Card</T>
)} )}
</p> </p>
{isEditMode && ( {isEditMode && (
<Button variant="outline" size="sm" onClick={(e) => { <>
e.stopPropagation(); <Button variant="outline" size="sm" onClick={(e) => {
handleOpenPicker(); e.stopPropagation();
}}> handleOpenPicker();
<Plus className="h-4 w-4 mr-2" /> }}>
<T>Select Picture</T> <Plus className="h-4 w-4 mr-2" />
</Button> <T>Select Picture</T>
</Button>
<p className="text-xs text-muted-foreground mt-4">
<T>or drag and drop an image here</T>
</p>
</>
)} )}
</div> </div>
);
}
{isEditMode && ( const authorName = userProfile?.display_name || userProfile?.username || `User ${picture.user_id.slice(0, 8)}`;
<ImagePickerDialog const isExternal = picture.user_id === 'external';
isOpen={pickerOpen}
onClose={() => setPickerOpen(false)}
onSelect={handleSelectPicture}
currentValue={pictureId}
/>
)}
</>
);
}
if (loading) {
return ( return (
<div className="bg-card border rounded-lg p-8 text-center"> <div className="w-full relative">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-sm text-muted-foreground">
<T>Loading picture...</T>
</p>
</div>
);
}
if (!picture) {
return (
<div className="bg-card border rounded-lg p-8 text-center">
<ImageIcon className="h-12 w-12 mx-auto mb-4 text-destructive opacity-50" />
<p className="text-sm text-muted-foreground mb-2">
<T>Picture not found</T>
</p>
{isEditMode && (
<p className="text-xs text-muted-foreground">
<T>Please select a new picture using the image picker</T>
</p>
)}
</div>
);
}
const authorName = userProfile?.display_name || userProfile?.username || `User ${picture.user_id.slice(0, 8)}`;
return (
<>
<div className="w-full">
<PhotoCard <PhotoCard
pictureId={picture.id} pictureId={picture.id}
image={picture.image_url} image={picture.image_url}
@ -209,15 +322,70 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
isLiked={isLiked} isLiked={isLiked}
description={picture.description} description={picture.description}
onClick={(id) => { onClick={(id) => {
// Navigate to post page if (isExternal) {
window.location.href = `/post/${id}`; window.open(picture.image_url, '_blank');
} else {
// Navigate to post page
window.location.href = `/post/${id}`;
}
}} }}
onLike={handleLike} onLike={handleLike}
variant={contentDisplay === 'overlay' || contentDisplay === 'overlay-always' ? 'grid' : 'feed'} variant={contentDisplay === 'overlay' || contentDisplay === 'overlay-always' ? 'grid' : 'feed'}
overlayMode={contentDisplay === 'overlay-always' ? 'always' : 'hover'} overlayMode={contentDisplay === 'overlay-always' ? 'always' : 'hover'}
showHeader={showHeader} showHeader={showHeader}
showContent={showFooter} showContent={showFooter}
isExternal={isExternal}
/> />
{/* Overlay trigger for editing existing image */}
{isEditMode && !isDragging && (
<div
className="absolute top-2 right-2 p-2 bg-black/50 rounded-full cursor-pointer hover:bg-black/70 transition-colors z-10"
onClick={(e) => {
e.stopPropagation();
handleOpenPicker();
}}
title={translate("Change Picture")}
>
<ImageIcon className="w-4 h-4 text-white" />
</div>
)}
</div>
);
};
return (
<>
<div
className={`w-full relative transition-all duration-200 ${isDragging ? 'ring-4 ring-primary ring-inset rounded-lg scale-[0.99]' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{renderContent()}
{/* Drop Overlay */}
{isDragging && (
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex flex-col items-center justify-center rounded-lg z-50 border-2 border-dashed border-primary pointer-events-none">
<div className="bg-primary/10 p-6 rounded-full mb-4">
<Upload className="h-10 w-10 text-primary animate-bounce" />
</div>
<p className="text-lg font-semibold text-primary">
<T>Drop image to upload</T>
</p>
</div>
)}
{/* Uploading Overlay */}
{isUploading && (
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex flex-col items-center justify-center rounded-lg z-50">
<Loader2 className="h-10 w-10 text-primary animate-spin mb-4" />
<p className="text-lg font-semibold text-foreground">
<T>Uploading image...</T>
</p>
</div>
)}
</div> </div>
{isEditMode && ( {isEditMode && (

View File

@ -1,9 +1,8 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect } from 'react';
import { GenericCanvas } from '@/components/hmi/GenericCanvas'; import { GenericCanvas } from '@/components/hmi/GenericCanvas';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { T } from '@/i18n'; import { T } from '@/i18n';
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
import { Button } from '@/components/ui/button';
export interface TabDefinition { export interface TabDefinition {
id: string; id: string;
@ -23,6 +22,11 @@ interface TabsWidgetProps {
contentClassName?: string; // Content area classes contentClassName?: string; // Content area classes
isEditMode?: boolean; isEditMode?: boolean;
onPropsChange: (props: Record<string, any>) => void; onPropsChange: (props: Record<string, any>) => void;
selectedWidgetId?: string | null;
onSelectWidget?: (id: string, pageId?: string) => void;
onSelectContainer?: (containerId: string | null, pageId?: string) => void;
editingWidgetId?: string | null;
onEditWidget?: (id: string | null) => void;
} }
const TabsWidget: React.FC<TabsWidgetProps> = ({ const TabsWidget: React.FC<TabsWidgetProps> = ({
@ -35,7 +39,12 @@ const TabsWidget: React.FC<TabsWidgetProps> = ({
tabBarClassName = '', tabBarClassName = '',
contentClassName = '', contentClassName = '',
isEditMode = false, isEditMode = false,
onPropsChange onPropsChange,
selectedWidgetId,
onSelectWidget,
onSelectContainer,
editingWidgetId,
onEditWidget,
}) => { }) => {
const [currentTabId, setCurrentTabId] = useState<string | undefined>(activeTabId); const [currentTabId, setCurrentTabId] = useState<string | undefined>(activeTabId);
@ -138,6 +147,11 @@ const TabsWidget: React.FC<TabsWidgetProps> = ({
isEditMode={isEditMode} isEditMode={isEditMode}
showControls={false} // Tabs usually hide nested canvas controls to look cleaner showControls={false} // Tabs usually hide nested canvas controls to look cleaner
className="p-4" className="p-4"
selectedWidgetId={selectedWidgetId}
onSelectWidget={onSelectWidget}
onSelectContainer={onSelectContainer}
editingWidgetId={editingWidgetId}
onEditWidget={onEditWidget}
/> />
) : ( ) : (
<div className="flex items-center justify-center h-full text-slate-400"> <div className="flex items-center justify-center h-full text-slate-400">

View File

@ -8,12 +8,13 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { WidgetDefinition } from '@/lib/widgetRegistry'; import { WidgetDefinition } from '@/lib/widgetRegistry';
import { ImagePickerDialog } from './ImagePickerDialog'; import { ImagePickerDialog } from './ImagePickerDialog';
import { PagePickerDialog } from './PagePickerDialog'; import { PagePickerDialog } from './PagePickerDialog';
import { Image as ImageIcon, Maximize2, FileText } from 'lucide-react'; import { Image as ImageIcon, Maximize2, FileText, Sparkles } from 'lucide-react';
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import MarkdownEditor from '@/components/MarkdownEditorEx'; import MarkdownEditor from '@/components/MarkdownEditorEx';
import { TailwindClassPicker } from './TailwindClassPicker'; import { TailwindClassPicker } from './TailwindClassPicker';
import { TabsPropertyEditor } from './TabsPropertyEditor'; import { TabsPropertyEditor } from './TabsPropertyEditor';
import { HtmlGeneratorWizard } from './HtmlGeneratorWizard';
export interface WidgetPropertiesFormProps { export interface WidgetPropertiesFormProps {
widgetDefinition: WidgetDefinition; widgetDefinition: WidgetDefinition;
@ -24,6 +25,8 @@ export interface WidgetPropertiesFormProps {
onSave?: () => void; onSave?: () => void;
onCancel?: () => void; onCancel?: () => void;
showActions?: boolean; showActions?: boolean;
contextVariables?: Record<string, any>;
pageContext?: any;
} }
export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
@ -34,7 +37,9 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
onSettingsChange, onSettingsChange,
onSave, onSave,
onCancel, onCancel,
showActions = false showActions = false,
contextVariables = {},
pageContext
}) => { }) => {
// Local state for immediate feedback in the form, though we also prop up changes // Local state for immediate feedback in the form, though we also prop up changes
const [settings, setSettings] = useState<Record<string, any>>(currentProps); const [settings, setSettings] = useState<Record<string, any>>(currentProps);
@ -44,6 +49,8 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
const [pagePickerField, setPagePickerField] = useState<string | null>(null); const [pagePickerField, setPagePickerField] = useState<string | null>(null);
const [markdownEditorOpen, setMarkdownEditorOpen] = useState(false); const [markdownEditorOpen, setMarkdownEditorOpen] = useState(false);
const [activeMarkdownField, setActiveMarkdownField] = useState<string | null>(null); const [activeMarkdownField, setActiveMarkdownField] = useState<string | null>(null);
const [htmlWizardOpen, setHtmlWizardOpen] = useState(false);
const [activeHtmlField, setActiveHtmlField] = useState<string | null>(null);
// Sync with prop changes (e.g. selection change) // Sync with prop changes (e.g. selection change)
useEffect(() => { useEffect(() => {
@ -238,19 +245,35 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
<Label htmlFor={key} className="text-xs font-medium text-slate-500 dark:text-slate-400"> <Label htmlFor={key} className="text-xs font-medium text-slate-500 dark:text-slate-400">
<T>{config.label}</T> <T>{config.label}</T>
</Label> </Label>
<Button <div className="flex gap-1">
type="button" <Button
variant="ghost" type="button"
size="sm" variant="ghost"
className="h-6 px-2 text-xs" size="sm"
onClick={() => { className="h-6 px-2 text-xs text-blue-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-950/20"
setActiveMarkdownField(key); onClick={() => {
setMarkdownEditorOpen(true); setActiveHtmlField(key);
}} setHtmlWizardOpen(true);
> }}
<Maximize2 className="h-3.5 w-3.5 mr-1" /> title="Generate HTML with AI"
<T>Fullscreen</T> >
</Button> <Sparkles className="h-3.5 w-3.5 mr-1" />
<T>Generate</T>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
setActiveMarkdownField(key);
setMarkdownEditorOpen(true);
}}
>
<Maximize2 className="h-3.5 w-3.5 mr-1" />
<T>Fullscreen</T>
</Button>
</div>
</div> </div>
<Textarea <Textarea
id={key} id={key}
@ -471,6 +494,28 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* HTML Generator Wizard */}
<HtmlGeneratorWizard
isOpen={htmlWizardOpen}
onClose={() => {
setHtmlWizardOpen(false);
setActiveHtmlField(null);
}}
onHtmlGenerated={(html) => {
if (activeHtmlField) {
updateSetting(activeHtmlField, html);
}
}}
onHtmlGenerated={(html) => {
if (activeHtmlField) {
updateSetting(activeHtmlField, html);
}
}}
contextVariables={contextVariables}
pageContext={pageContext}
initialPrompt={activeHtmlField ? settings[activeHtmlField] : ''}
/>
</div> </div>
); );
}; };

View File

@ -11,13 +11,15 @@ interface WidgetPropertyPanelProps {
selectedWidgetId: string | null; selectedWidgetId: string | null;
onWidgetRenamed?: (newId: string) => void; onWidgetRenamed?: (newId: string) => void;
className?: string; className?: string;
contextVariables?: Record<string, any>;
} }
export const WidgetPropertyPanel: React.FC<WidgetPropertyPanelProps> = ({ export const WidgetPropertyPanel: React.FC<WidgetPropertyPanelProps> = ({
pageId, pageId,
selectedWidgetId, selectedWidgetId,
onWidgetRenamed, onWidgetRenamed,
className = '' className = '',
contextVariables = {}
}) => { }) => {
const { loadedPages, updateWidgetProps, renameWidget } = useLayout(); const { loadedPages, updateWidgetProps, renameWidget } = useLayout();
const page = loadedPages.get(pageId); const page = loadedPages.get(pageId);
@ -86,15 +88,10 @@ export const WidgetPropertyPanel: React.FC<WidgetPropertyPanelProps> = ({
onSettingsChange={handleSettingsChange} onSettingsChange={handleSettingsChange}
widgetInstanceId={widget.id} widgetInstanceId={widget.id}
onRename={(newId) => { onRename={(newId) => {
// We need to propagate this up because the selection state depends on the ID
// If `renameWidget` succeeds, we should probably tell the parent to select the new ID.
// Since I can't easily change the parent's selection state from here without a prop,
// I'll add an `onWidgetRenamed` prop to `WidgetPropertyPanel`.
// Wait, I can't add a prop without updating usage in PlaygroundCanvas.
// Let's implement the internal logic first.
handleRename(newId); handleRename(newId);
}} }}
contextVariables={contextVariables}
pageContext={page}
/> />
) : ( ) : (
<div className="text-center text-slate-500 text-xs py-8"> <div className="text-center text-slate-500 text-xs py-8">

View File

@ -67,13 +67,15 @@ const WidgetSettingsManagerComponent: React.FC<WidgetSettingsManagerProps> = ({
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<WidgetPropertiesForm <div className="max-h-[70vh] overflow-y-auto scrollbar-custom py-2">
widgetDefinition={widgetDefinition} <WidgetPropertiesForm
currentProps={settings} widgetDefinition={widgetDefinition}
onSettingsChange={setSettings} currentProps={settings}
widgetInstanceId={widgetInstanceId} onSettingsChange={setSettings}
onRename={onRename} widgetInstanceId={widgetInstanceId}
/> onRename={onRename}
/>
</div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={handleCancel}> <Button variant="outline" onClick={handleCancel}>

View File

@ -4,6 +4,7 @@ import { toast } from 'sonner';
import { useWidgetLoader } from './useWidgetLoader.tsx'; import { useWidgetLoader } from './useWidgetLoader.tsx';
import { useLayouts } from './useLayouts'; import { useLayouts } from './useLayouts';
import { Database } from '@/integrations/supabase/types'; import { Database } from '@/integrations/supabase/types';
import { supabase } from "@/integrations/supabase/client";
type Layout = Database['public']['Tables']['layouts']['Row']; type Layout = Database['public']['Tables']['layouts']['Row'];
type LayoutVisibility = Database['public']['Enums']['layout_visibility']; type LayoutVisibility = Database['public']['Enums']['layout_visibility'];
@ -390,11 +391,18 @@ export function usePlaygroundLogic() {
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL; const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL;
toast.info("Sending test email..."); toast.info("Sending test email...");
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 response = await fetch(`${serverUrl}/api/send/email/${dummyId}`, { const response = await fetch(`${serverUrl}/api/send/email/${dummyId}`, {
method: 'POST', method: 'POST',
headers: { headers,
'Content-Type': 'application/json'
},
body: JSON.stringify({ body: JSON.stringify({
html, html,
subject: `[Test] ${layout.name} - ${new Date().toLocaleTimeString()}` subject: `[Test] ${layout.name} - ${new Date().toLocaleTimeString()}`

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
import { supabase as defaultSupabase } from "@/integrations/supabase/client"; import { supabase as defaultSupabase } from "@/integrations/supabase/client";
import { z } from "zod";
import { UserProfile, PostMediaItem } from "@/pages/Post/types"; import { UserProfile, PostMediaItem } from "@/pages/Post/types";
import { MediaType, MediaItem } from "@/types"; import { MediaType, MediaItem } from "@/types";
import { SupabaseClient } from "@supabase/supabase-js"; import { SupabaseClient } from "@supabase/supabase-js";
@ -1210,3 +1211,109 @@ export const getLayouts = async (filters?: { type?: string, visibility?: string,
return { data, error: null }; return { data, error: null };
}; };
// --- i18n ---
export const TargetLanguageCodeSchema = z.union([
z.enum(["en", "pt", "bg", "cs", "da", "de", "el", "es", "et", "fi", "fr", "hu", "id", "it", "ja", "ko", "lt", "lv", "nb", "nl", "pl", "ro", "ru", "sk", "sl", "sv", "tr", "uk", "zh"]),
z.enum(["en-GB", "en-US", "pt-BR", "pt-PT"])
]);
export type TargetLanguageCode = z.infer<typeof TargetLanguageCodeSchema>;
export const SourceLanguageCodeSchema = z.enum(["bg", "cs", "da", "de", "el", "en", "es", "et", "fi", "fr", "hu", "id", "it", "ja", "ko", "lt", "lv", "nb", "nl", "pl", "pt", "ro", "ru", "sk", "sl", "sv", "tr", "uk", "zh"]);
export type SourceLanguageCode = z.infer<typeof SourceLanguageCodeSchema>;
export interface Glossary {
glossary_id: string;
name: string;
ready: boolean;
source_lang: string;
target_lang: string;
entry_count: number;
}
export const translateText = async (text: string, srcLang: string, dstLang: string, glossaryId?: string) => {
// POST /api/i18n/translate
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/i18n/translate', {
method: 'POST',
headers,
body: JSON.stringify({
srcLang,
dstLang,
text,
meta: glossaryId ? { glossary_id: glossaryId } : undefined
})
});
if (!res.ok) throw new Error(`Translation failed: ${res.statusText}`);
return await res.json(); // { translation: string }
};
export const fetchGlossaries = async () => {
// GET /api/i18n/glossaries
return fetchWithDeduplication('i18n-glossaries', async () => {
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/i18n/glossaries', { headers });
if (!res.ok) throw new Error(`Fetch glossaries failed: ${res.statusText}`);
return await res.json() as Glossary[];
});
};
export const createGlossary = async (name: string, srcLang: string, dstLang: string, entries: Record<string, string>) => {
// POST /api/i18n/glossaries
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/i18n/glossaries', {
method: 'POST',
headers,
body: JSON.stringify({
name,
source_lang: srcLang,
target_lang: dstLang,
entries
})
});
if (!res.ok) {
const err = await res.json();
throw new Error(`Create glossary failed: ${err.error || res.statusText}`);
}
invalidateCache('i18n-glossaries');
return await res.json();
};
export const deleteGlossary = async (id: string) => {
// DELETE /api/i18n/glossaries/:id
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/i18n/glossaries/${id}`, {
method: 'DELETE',
headers
});
if (!res.ok) throw new Error(`Delete glossary failed: ${res.statusText}`);
invalidateCache('i18n-glossaries');
return true;
};

View File

@ -601,9 +601,73 @@ Optimized: "A fluffy tabby cat sitting gracefully on a vintage wooden chair, sof
}, null, apiKey); }, null, apiKey);
}; };
// ==================================================================== // Generate HTML snippet with Tailwind CSS
// TOOL SYSTEM - LLM with Function Calling export const generateHtmlSnippet = async (
// ==================================================================== userPrompt: string,
contextVariables: Record<string, any> = {},
pageContext: any = null,
apiKey?: string
): Promise<string | null> => {
return withOpenAI(async (client) => {
try {
consoleLogger.info('Starting HTML snippet generation', {
promptLength: userPrompt.length,
hasContext: Object.keys(contextVariables).length > 0,
hasPageContext: !!pageContext
});
const variableList = Object.keys(contextVariables).map(k => `\${${k}}`).join(', ');
const contextPrompt = Object.keys(contextVariables).length > 0
? `\nAvailable Variables: You can use these variables in your HTML: ${variableList}. Use the syntax \${variableName} to insert them.`
: '';
const pageContextPrompt = pageContext
? `\nPage Context (JSON): Use this to understand the surrounding page structure/data if relevant:\n\`\`\`json\n${JSON.stringify(pageContext, null, 2).slice(0, 5000)}\n\`\`\``
: '';
const systemPrompt = `You are an expert Tailwind CSS and HTML developer.
Your task is to generate or modify a standalone HTML snippet based on the user's request.
Rules:
1. Return ONLY the HTML code. Do NOT wrap it in markdown code blocks (\`\`\`html ... \`\`\`\`).
2. Do NOT include any explanations, comments, or conversational text.
3. Use Tailwind CSS classes for styling.
4. The HTML should be a document fragment (e.g., a <div>, <section>, or <article>), NOT a full <html> document.
5. Make the design modern, clean, and responsive.${contextPrompt}${pageContextPrompt}
6. If icons are needed, use valid inline <svg> elements with Tailwind classes. Do NOT use React component names (like <LucideIcon />) as they will not render.
7. Ensure strict accessibility compliance (aria-labels, roles).`;
console.log('System prompt:', systemPrompt);
console.log('User prompt:', userPrompt);
const response = await client.chat.completions.create({
model: "gpt-4o", // Stronger model for code generation
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt }
]
});
let content = response.choices[0]?.message?.content?.trim();
if (!content) {
consoleLogger.warn('No HTML returned from OpenAI');
return null;
}
// Cleanup if the model ignored the "no markdown" rule
content = content.replace(/^```html\s*/i, '').replace(/^```\s*/, '').replace(/\s*```$/, '');
return content;
} catch (error: any) {
consoleLogger.error('OpenAI HTML generation failed:', error);
throw error;
}
}, null, apiKey);
};
/** /**
* Helper function to create Zod-validated OpenAI tools * Helper function to create Zod-validated OpenAI tools

View File

@ -4,6 +4,7 @@ import {
ListFilter, ListFilter,
Layout, Layout,
FileText, FileText,
Code,
} from 'lucide-react'; } from 'lucide-react';
// Import your components // Import your components
@ -15,11 +16,51 @@ import LayoutContainerWidget from '@/components/widgets/LayoutContainerWidget';
import MarkdownTextWidget from '@/components/widgets/MarkdownTextWidget'; import MarkdownTextWidget from '@/components/widgets/MarkdownTextWidget';
import GalleryWidget from '@/components/widgets/GalleryWidget'; import GalleryWidget from '@/components/widgets/GalleryWidget';
import TabsWidget from '@/components/widgets/TabsWidget'; import TabsWidget from '@/components/widgets/TabsWidget';
import { HtmlWidget } from '@/components/widgets/HtmlWidget';
export function registerAllWidgets() { export function registerAllWidgets() {
// Clear existing registrations (useful for HMR) // Clear existing registrations (useful for HMR)
widgetRegistry.clear(); widgetRegistry.clear();
// HTML Widget
widgetRegistry.register({
component: HtmlWidget,
metadata: {
id: 'html-widget',
name: 'HTML Content',
category: 'display',
description: 'Render HTML content with variable substitution',
icon: Code,
defaultProps: {
content: '<div>\n <h3 class="text-xl font-bold">Hello ${name}</h3>\n <p>Welcome to our custom widget!</p>\n</div>',
variables: '{\n "name": "World"\n}'
},
configSchema: {
content: {
type: 'markdown', // Using markdown editor for larger text area
label: 'HTML Content',
description: 'Enter your HTML code here. Use ${varName} for substitutions.',
default: '<div>Hello World</div>'
},
variables: {
type: 'markdown', // Using markdown/textarea for JSON input for now
label: 'Variables (JSON)',
description: 'JSON object defining variables for substitution.',
default: '{}'
},
className: {
type: 'classname',
label: 'CSS Class',
description: 'Tailwind classes for the container',
default: ''
}
},
minSize: { width: 300, height: 100 },
resizable: true,
tags: ['html', 'code', 'custom', 'embed']
}
});
// Photo widgets // Photo widgets
widgetRegistry.register({ widgetRegistry.register({
component: PhotoGrid, component: PhotoGrid,
@ -130,6 +171,7 @@ export function registerAllWidgets() {
orientation: 'horizontal', orientation: 'horizontal',
tabBarPosition: 'top' tabBarPosition: 'top'
}, },
configSchema: { configSchema: {
tabs: { tabs: {
type: 'tabs-editor', type: 'tabs-editor',
@ -165,6 +207,14 @@ export function registerAllWidgets() {
minSize: { width: 400, height: 300 }, minSize: { width: 400, height: 300 },
resizable: true, resizable: true,
tags: ['layout', 'tabs', 'container'] tags: ['layout', 'tabs', 'container']
},
getNestedLayouts: (props) => {
if (!props.tabs || !Array.isArray(props.tabs)) return [];
return props.tabs.map((tab: any) => ({
id: tab.id,
label: tab.label,
layoutId: tab.layoutId
}));
} }
}); });
@ -355,6 +405,16 @@ export function registerAllWidgets() {
minSize: { width: 300, height: 200 }, minSize: { width: 300, height: 200 },
resizable: true, resizable: true,
tags: ['layout', 'container', 'nested', 'canvas'] tags: ['layout', 'container', 'nested', 'canvas']
},
getNestedLayouts: (props) => {
if (props.nestedPageId) {
return [{
id: 'nested-container',
label: props.nestedPageName || 'Nested Container',
layoutId: props.nestedPageId
}];
}
return [];
} }
}); });

View File

@ -18,6 +18,7 @@ export interface WidgetDefinition {
component: React.ComponentType<any>; component: React.ComponentType<any>;
metadata: WidgetMetadata; metadata: WidgetMetadata;
previewComponent?: React.ComponentType<any>; previewComponent?: React.ComponentType<any>;
getNestedLayouts?: (props: Record<string, any>) => { id: string; label: string; layoutId: string }[];
} }
class WidgetRegistry { class WidgetRegistry {

View File

@ -9,6 +9,7 @@ import { LayoutGrid, GalleryVerticalEnd, TrendingUp, Clock, List, FolderTree } f
import { ListLayout } from "@/components/ListLayout"; import { ListLayout } from "@/components/ListLayout";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import type { FeedSortOption } from "@/hooks/useFeedData"; import type { FeedSortOption } from "@/hooks/useFeedData";
import { SEO } from "@/components/SEO";
const Index = () => { const Index = () => {
const { slug } = useParams<{ slug?: string }>(); const { slug } = useParams<{ slug?: string }>();
@ -41,6 +42,7 @@ const Index = () => {
return ( return (
<div className="bg-background"> <div className="bg-background">
<SEO title="PolyMech Home" />
<div className="md:py-2"> <div className="md:py-2">
<div> <div>
<div> <div>

View File

@ -19,6 +19,7 @@ import { usePostActions } from "./Post/usePostActions";
import { exportMarkdown, downloadMediaItem } from "./Post/PostActions"; import { exportMarkdown, downloadMediaItem } from "./Post/PostActions";
import { DeleteDialog } from "./Post/components/DeleteDialogs"; import { DeleteDialog } from "./Post/components/DeleteDialogs";
import { CategoryManager } from "@/components/widgets/CategoryManager"; import { CategoryManager } from "@/components/widgets/CategoryManager";
import { SEO } from "@/components/SEO";
import '@vidstack/react/player/styles/default/theme.css'; import '@vidstack/react/player/styles/default/theme.css';
@ -954,7 +955,15 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
: "bg-background flex flex-col h-full"; : "bg-background flex flex-col h-full";
return ( return (
<div className={containerClassName}> <div className={`min-h-screen bg-background ${className}`}>
{post && (
<SEO
title={post.title || mediaItem?.title}
description={post.description || mediaItem?.description || `View ${post.title} on Polymech`}
image={mediaItem?.image_url}
type={isVideo ? 'video.other' : 'article'}
/>
)}
<div className={embedded ? "w-full h-full" : "w-full h-full max-w-[1600px] mx-auto"}> <div className={embedded ? "w-full h-full" : "w-full h-full max-w-[1600px] mx-auto"}>
{viewMode === 'article' ? ( {viewMode === 'article' ? (

View File

@ -41,12 +41,6 @@ export const CompactRenderer: React.FC<PostRendererProps> = (props) => {
const effectiveType = mediaItem.type || detectMediaType(mediaItem.image_url); const effectiveType = mediaItem.type || detectMediaType(mediaItem.image_url);
const isVideo = isVideoType(normalizeMediaType(effectiveType)); const isVideo = isVideoType(normalizeMediaType(effectiveType));
console.log('mediaItem', mediaItem);
console.log('isVideo', isVideo);
console.log('effectiveType', effectiveType);
console.log('mediaItems', mediaItems);
return ( return (
<div className={props.className || "h-full"}> <div className={props.className || "h-full"}>
{/* Mobile Header - Controls and Info at Top */} {/* Mobile Header - Controls and Info at Top */}

View File

@ -5,11 +5,13 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react"; import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { T, translate } from "@/i18n"; import { T, translate } from "@/i18n";
import { Database } from "@/integrations/supabase/types";
import { GenericCanvas } from "@/components/hmi/GenericCanvas"; import { GenericCanvas } from "@/components/hmi/GenericCanvas";
import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import MarkdownRenderer from "@/components/MarkdownRenderer"; import MarkdownRenderer from "@/components/MarkdownRenderer";
import { Sidebar } from "@/components/sidebar/Sidebar"; import { Sidebar } from "@/components/sidebar/Sidebar";
import { TableOfContents } from "@/components/sidebar/TableOfContents"; import { TableOfContents } from "@/components/sidebar/TableOfContents";
import { MobileTOC } from "@/components/sidebar/MobileTOC"; import { MobileTOC } from "@/components/sidebar/MobileTOC";
@ -18,8 +20,7 @@ import { useLayout } from "@/contexts/LayoutContext";
import { fetchUserPage } from "@/lib/db"; import { fetchUserPage } from "@/lib/db";
import { UserPageTopBar } from "@/components/user-page/UserPageTopBar"; import { UserPageTopBar } from "@/components/user-page/UserPageTopBar";
import { UserPageDetails } from "@/components/user-page/UserPageDetails"; import { UserPageDetails } from "@/components/user-page/UserPageDetails";
import { Database } from "@/integrations/supabase/types"; import { SEO } from "@/components/SEO";
const UserPageEdit = lazy(() => import("./UserPageEdit")); const UserPageEdit = lazy(() => import("./UserPageEdit"));
@ -183,7 +184,7 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
setTimeout(() => { setTimeout(() => {
const element = document.getElementById(id); const element = document.getElementById(id);
if (element) { if (element) {
element.scrollIntoView({ behavior: 'smooth' }); // element.scrollIntoView({ behavior: 'smooth' });
} }
}, 100); }, 100);
} }
@ -249,6 +250,15 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
return ( return (
<div className={`${embedded ? 'h-full' : 'h-[calc(100vh-3.5rem)]'} bg-background flex flex-col overflow-hidden`}> <div className={`${embedded ? 'h-full' : 'h-[calc(100vh-3.5rem)]'} bg-background flex flex-col overflow-hidden`}>
{/* SEO Metadata */}
{page && (
<SEO
title={page.title}
description={page.meta?.description || `View ${page.title} on Polymech`}
image={page.meta?.ogImage} // Assuming meta might have ogImage
/>
)}
{/* Top Header (Back button) or Ribbon Bar - Fixed if not embedded */} {/* Top Header (Back button) or Ribbon Bar - Fixed if not embedded */}
{!embedded && ( {!embedded && (
<UserPageTopBar <UserPageTopBar
@ -359,6 +369,12 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
initialLayout={page.content} initialLayout={page.content}
selectedWidgetId={null} selectedWidgetId={null}
onSelectWidget={() => { }} onSelectWidget={() => { }}
contextVariables={(() => {
const typeValues = page.meta?.typeValues || {};
// Flatten all type values into a single object
// Later types override earlier ones if keys collide, but order isn't guaranteed in object
return Object.values(typeValues).reduce((acc: any, val: any) => ({ ...acc, ...val }), {});
})()}
/> />
)} )}
</div> </div>

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect, lazy, Suspense } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -7,9 +7,14 @@ import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { T, translate } from "@/i18n"; import { T, translate } from "@/i18n";
import { GenericCanvas } from "@/components/hmi/GenericCanvas"; import { GenericCanvas } from "@/components/hmi/GenericCanvas";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
// Editor components lazy loaded
const WidgetPropertyPanel = lazy(() => import("@/components/widgets/WidgetPropertyPanel").then(module => ({ default: module.WidgetPropertyPanel })));
const HierarchyTree = lazy(() => import("@/components/sidebar/HierarchyTree").then(module => ({ default: module.HierarchyTree })));
const UserPageTypeFields = lazy(() => import("@/components/user-page/UserPageTypeFields").then(module => ({ default: module.UserPageTypeFields })));
const SaveTemplateDialog = lazy(() => import("@/components/user-page/SaveTemplateDialog").then(module => ({ default: module.SaveTemplateDialog })));
import { PageActions } from "@/components/PageActions";
import { WidgetPropertyPanel } from "@/components/widgets/WidgetPropertyPanel";
import MarkdownRenderer from "@/components/MarkdownRenderer"; import MarkdownRenderer from "@/components/MarkdownRenderer";
import { Sidebar } from "@/components/sidebar/Sidebar"; import { Sidebar } from "@/components/sidebar/Sidebar";
import { TableOfContents } from "@/components/sidebar/TableOfContents"; import { TableOfContents } from "@/components/sidebar/TableOfContents";
@ -20,10 +25,6 @@ import { UserPageDetails } from "@/components/user-page/UserPageDetails";
import { useLayouts } from "@/hooks/useLayouts"; import { useLayouts } from "@/hooks/useLayouts";
import { Database } from "@/integrations/supabase/types"; import { Database } from "@/integrations/supabase/types";
import PageRibbonBar from "@/components/user-page/ribbons/PageRibbonBar"; import PageRibbonBar from "@/components/user-page/ribbons/PageRibbonBar";
import { SaveTemplateDialog } from "@/components/user-page/SaveTemplateDialog";
import { getLayouts } from "@/lib/db";
import { HierarchyTree } from "@/components/sidebar/HierarchyTree";
import { UserPageTypeFields } from "@/components/user-page/UserPageTypeFields";
type Layout = Database['public']['Tables']['layouts']['Row']; type Layout = Database['public']['Tables']['layouts']['Row'];
@ -89,6 +90,7 @@ const UserPageEdit = ({
}: UserPageEditProps) => { }: UserPageEditProps) => {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [selectedWidgetId, setSelectedWidgetId] = useState<string | null>(null); const [selectedWidgetId, setSelectedWidgetId] = useState<string | null>(null);
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
const [showHierarchy, setShowHierarchy] = useState(false); const [showHierarchy, setShowHierarchy] = useState(false);
// Auto-collapse sidebar if no TOC headings // Auto-collapse sidebar if no TOC headings
@ -120,6 +122,23 @@ const UserPageEdit = ({
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(null); const [activeTemplateId, setActiveTemplateId] = useState<string | null>(null);
const [showSaveTemplateDialog, setShowSaveTemplateDialog] = useState(false); const [showSaveTemplateDialog, setShowSaveTemplateDialog] = useState(false);
// Settings Dialog State
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [settingsWidgetId, setSettingsWidgetId] = useState<string | null>(null);
const [settingsLayoutId, setSettingsLayoutId] = useState<string | null>(null);
const handleOpenSettings = (id: string, type: 'widget' | 'container', layoutId: string) => {
if (type === 'widget') {
setSettingsWidgetId(id);
setSettingsLayoutId(layoutId);
setSettingsDialogOpen(true);
} else if (type === 'container') {
// For now just select it, or implement container settings
setSelectedContainerId(id);
// TODO: Container Settings Dialog
}
};
useEffect(() => { useEffect(() => {
if (isOwner) { if (isOwner) {
loadTemplates(); loadTemplates();
@ -140,7 +159,8 @@ const UserPageEdit = ({
const handleAddWidget = async (widgetId: string) => { const handleAddWidget = async (widgetId: string) => {
if (!page) return; if (!page) return;
const pageId = `page-${page.id}`; // Use the selected page ID (nested layout) if available, otherwise default to the main page
const pageId = selectedPageId || `page-${page.id}`;
// Determine target container // Determine target container
let targetContainerId = selectedContainerId; let targetContainerId = selectedContainerId;
@ -431,6 +451,11 @@ const UserPageEdit = ({
} }
}; };
const contextVariables = (() => {
const typeValues = page.meta?.typeValues || {};
return Object.values(typeValues).reduce((acc: any, val: any) => ({ ...acc, ...val }), {});
})();
return ( return (
<> <>
<PageRibbonBar <PageRibbonBar
@ -512,18 +537,23 @@ const UserPageEdit = ({
/> />
)} )}
{showHierarchy && currentLayout && ( {showHierarchy && currentLayout && (
<HierarchyTree <Suspense fallback={<div className="p-4 text-xs text-muted-foreground">Loading hierarchy...</div>}>
containers={currentLayout.containers} <HierarchyTree
selectedWidgetId={selectedWidgetId} containers={currentLayout.containers}
selectedContainerId={selectedContainerId} selectedWidgetId={selectedWidgetId}
onSelectWidget={(id) => { selectedContainerId={selectedContainerId}
setSelectedWidgetId(id); onSelectWidget={(id) => {
// Ensure editor is open/focused if needed setSelectedWidgetId(id);
const widget = currentLayout.containers.flatMap((c: any) => [c, ...c.children]).flatMap((c: any) => c.widgets).find((w: any) => w.id === id); setSelectedPageId(`page-${page.id}`);
if (widget) setEditingWidgetId(id); // Ensure editor is open/focused if needed
}} const widget = currentLayout.containers.flatMap((c: any) => [c, ...c.children]).flatMap((c: any) => c.widgets).find((w: any) => w.id === id);
onSelectContainer={setSelectedContainerId} if (widget) setEditingWidgetId(id);
/> }}
onSelectContainer={setSelectedContainerId}
onSettingsClick={handleOpenSettings}
layoutId={currentLayout.id}
/>
</Suspense>
)} )}
</div> </div>
)} )}
@ -568,12 +598,23 @@ const UserPageEdit = ({
showControls={true} showControls={true}
initialLayout={page.content} initialLayout={page.content}
selectedWidgetId={selectedWidgetId} selectedWidgetId={selectedWidgetId}
onSelectWidget={setSelectedWidgetId} onSelectWidget={(id, pageId) => {
setSelectedWidgetId(id);
setSelectedPageId(pageId || `page-${page.id}`);
}}
selectedContainerId={selectedContainerId} selectedContainerId={selectedContainerId}
onSelectContainer={setSelectedContainerId} onSelectContainer={(id, pageId) => {
setSelectedContainerId(id);
if (id) {
setSelectedPageId(pageId || `page-${page.id}`);
}
// If deselecting, we might want to reset pageId to main page or keep last context?
// Keeping last context is fine.
}}
editingWidgetId={editingWidgetId} editingWidgetId={editingWidgetId}
onEditWidget={handleEditWidget} onEditWidget={handleEditWidget}
newlyAddedWidgetId={newlyAddedWidgetId} newlyAddedWidgetId={newlyAddedWidgetId}
contextVariables={contextVariables}
/> />
)} )}
</div> </div>
@ -611,20 +652,25 @@ const UserPageEdit = ({
<ResizablePanel defaultSize={25} minSize={20} maxSize={50} order={2} id="user-page-props"> <ResizablePanel defaultSize={25} minSize={20} maxSize={50} order={2} id="user-page-props">
<div className="h-full flex flex-col shrink-0 transition-all duration-300 overflow-hidden bg-background"> <div className="h-full flex flex-col shrink-0 transition-all duration-300 overflow-hidden bg-background">
{selectedWidgetId ? ( {selectedWidgetId ? (
<WidgetPropertyPanel <Suspense fallback={<div className="h-full flex items-center justify-center text-muted-foreground">Loading settings...</div>}>
pageId={`page-${page.id}`} <WidgetPropertyPanel
selectedWidgetId={selectedWidgetId} pageId={selectedPageId || `page-${page.id}`}
onWidgetRenamed={setSelectedWidgetId} selectedWidgetId={selectedWidgetId}
/> onWidgetRenamed={setSelectedWidgetId}
contextVariables={contextVariables}
/>
</Suspense>
) : showTypeFields ? ( ) : showTypeFields ? (
<div className="h-full overflow-y-auto p-4"> <div className="h-full overflow-y-auto p-4">
<UserPageTypeFields <Suspense fallback={<div className="h-full flex items-center justify-center text-muted-foreground">Loading types...</div>}>
pageId={page.id} <UserPageTypeFields
pageMeta={page.meta} pageId={page.id}
assignedTypes={assignedTypes} pageMeta={page.meta}
isEditMode={true} assignedTypes={assignedTypes}
onMetaUpdate={(newMeta) => onPageUpdate({ ...page, meta: newMeta })} isEditMode={true}
/> onMetaUpdate={(newMeta) => onPageUpdate({ ...page, meta: newMeta })}
/>
</Suspense>
</div> </div>
) : null} ) : null}
</div> </div>
@ -634,11 +680,37 @@ const UserPageEdit = ({
</ResizablePanelGroup> </ResizablePanelGroup>
</div> </div>
<SaveTemplateDialog <Suspense fallback={null}>
isOpen={showSaveTemplateDialog} <SaveTemplateDialog
onClose={() => setShowSaveTemplateDialog(false)} isOpen={showSaveTemplateDialog}
onSave={onSaveTemplate} onClose={() => setShowSaveTemplateDialog(false)}
/> onSave={onSaveTemplate}
/>
</Suspense>
{/* Widget Settings Dialog */}
<Dialog open={settingsDialogOpen} onOpenChange={setSettingsDialogOpen}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle><T>Widget Settings</T></DialogTitle>
</DialogHeader>
{settingsWidgetId && settingsLayoutId && (
<Suspense fallback={<div>Loading...</div>}>
<WidgetPropertyPanel
pageId={settingsLayoutId}
selectedWidgetId={settingsWidgetId}
onWidgetRenamed={(newId) => {
setSettingsWidgetId(newId);
if (selectedWidgetId === settingsWidgetId) {
setSelectedWidgetId(newId);
}
}}
contextVariables={contextVariables}
/>
</Suspense>
)}
</DialogContent>
</Dialog>
</> </>
); );
}; };

View File

@ -13,6 +13,7 @@ import { T, translate } from "@/i18n";
import { normalizeMediaType } from "@/lib/mediaRegistry"; import { normalizeMediaType } from "@/lib/mediaRegistry";
import { useFeedData } from "@/hooks/useFeedData"; import { useFeedData } from "@/hooks/useFeedData";
import * as db from "@/lib/db"; import * as db from "@/lib/db";
import { SEO } from "@/components/SEO";
interface UserProfile { interface UserProfile {
id: string; id: string;
@ -227,6 +228,11 @@ const UserProfile = () => {
return ( return (
<div className="min-h-screen bg-background pt-14"> <div className="min-h-screen bg-background pt-14">
<SEO
title={userProfile.display_name || userProfile.username || 'User Profile'}
description={userProfile.bio || `Check out ${userProfile.display_name}'s profile on PolyMech`}
image={userProfile.avatar_url || undefined}
/>
<div className="container mx-auto px-0 md:px-4 py-8 max-w-4xl"> <div className="container mx-auto px-0 md:px-4 py-8 max-w-4xl">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">

View File

@ -3,14 +3,13 @@
* Based on deobfuscated TikTok video player implementation * Based on deobfuscated TikTok video player implementation
*/ */
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect, lazy, Suspense } from 'react';
import { VideoItem } from '../types'; import { VideoItem } from '../types';
import { MediaPlayer, MediaProvider, type MediaPlayerInstance } from '@vidstack/react'; import type { MediaPlayerInstance } from '@vidstack/react';
import { defaultLayoutIcons, DefaultVideoLayout } from '@vidstack/react/player/layouts/default'; import { defaultLayoutIcons } from '@vidstack/react/player/layouts/default';
// Import Vidstack styles // Lazy load Vidstack implementation
import '@vidstack/react/player/styles/default/theme.css'; const VidstackPlayer = lazy(() => import('./VidstackPlayerImpl').then(module => ({ default: module.VidstackPlayerImpl })));
import '@vidstack/react/player/styles/default/layouts/video.css';
interface VideoPlayerProps { interface VideoPlayerProps {
video: VideoItem; video: VideoItem;
@ -52,35 +51,36 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
return ( return (
<div className={`w-full h-full bg-black flex justify-center items-center ${className}`}> <div className={`w-full h-full bg-black flex justify-center items-center ${className}`}>
{/* Video Player - Using Vidstack for HLS support */} {/* Video Player - Using Vidstack for HLS support */}
<MediaPlayer {/* Video Player - Using Vidstack for HLS support */}
ref={player} <Suspense fallback={<div className="w-full h-full bg-black animate-pulse" />}>
title={video.desc || 'Video'} <VidstackPlayer
src={ ref={player}
video.video.playAddr.includes('/api/videos/') title={video.desc || 'Video'}
? { src: video.video.playAddr, type: 'video/mp4' } src={
: video.video.playAddr video.video.playAddr.includes('/api/videos/')
} ? { src: video.video.playAddr, type: 'video/mp4' }
poster={video.video.cover} : video.video.playAddr
playsInline }
loop poster={video.video.cover}
muted={false} playsInline
autoPlay={true} loop
load={isActive ? "eager" : "idle"} muted={false}
posterLoad="eager" autoPlay={true}
crossOrigin="anonymous" load={isActive ? "eager" : "idle"}
className="w-full h-full" posterLoad="eager"
style={{ crossOrigin="anonymous"
'--video-brand': '#ff0050', className="w-full h-full"
'--media-object-fit': 'contain', style={{
'--media-object-position': 'center' '--video-brand': '#ff0050',
} as any} '--media-object-fit': 'contain',
> '--media-object-position': 'center'
<MediaProvider /> } as any}
<DefaultVideoLayout layoutProps={{
icons={defaultLayoutIcons} icons: defaultLayoutIcons,
noScrubGesture noScrubGesture: true
}}
/> />
</MediaPlayer> </Suspense>
</div> </div>
); );
}; };

View File

@ -0,0 +1,31 @@
import React, { forwardRef } from 'react';
import { MediaPlayer, MediaProvider, type MediaPlayerInstance, type MediaPlayerProps } from '@vidstack/react';
import { defaultLayoutIcons, DefaultVideoLayout } from '@vidstack/react/player/layouts/default';
// Import Vidstack styles
import '@vidstack/react/player/styles/default/theme.css';
import '@vidstack/react/player/styles/default/layouts/video.css';
interface VidstackPlayerImplProps extends Omit<MediaPlayerProps, 'children'> {
children?: React.ReactNode;
layoutProps?: {
icons: typeof defaultLayoutIcons;
noScrubGesture?: boolean;
// Add other layout props as needed based on usage
};
}
export const VidstackPlayerImpl = forwardRef<MediaPlayerInstance, VidstackPlayerImplProps>(({ children, layoutProps, ...props }, ref) => {
return (
<MediaPlayer ref={ref} {...props}>
<MediaProvider />
<DefaultVideoLayout
icons={layoutProps?.icons || defaultLayoutIcons}
{...layoutProps}
/>
{children}
</MediaPlayer>
);
});
VidstackPlayerImpl.displayName = 'VidstackPlayerImpl';