html widget | i18n | fixes
This commit is contained in:
parent
c5db29352b
commit
8164a960df
@ -27,7 +27,7 @@ import UserProfile from "./pages/UserProfile";
|
||||
import UserCollections from "./pages/UserCollections";
|
||||
import Collections from "./pages/Collections";
|
||||
import NewCollection from "./pages/NewCollection";
|
||||
import UserPage from "./pages/UserPage";
|
||||
const UserPage = React.lazy(() => import("./pages/UserPage"));
|
||||
import NewPage from "./pages/NewPage";
|
||||
import TagPage from "./pages/TagPage";
|
||||
import SearchResults from "./pages/SearchResults";
|
||||
@ -35,6 +35,8 @@ import Wizard from "./pages/Wizard";
|
||||
import NewPost from "./pages/NewPost";
|
||||
|
||||
import Organizations from "./pages/Organizations";
|
||||
import LogsPage from "./components/logging/LogsPage";
|
||||
|
||||
const ProviderSettings = React.lazy(() => import("./pages/ProviderSettings"));
|
||||
const PlaygroundEditor = React.lazy(() => import("./pages/PlaygroundEditor"));
|
||||
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 TypesPlayground = React.lazy(() => import("./components/types/TypesPlayground"));
|
||||
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();
|
||||
|
||||
@ -79,7 +83,7 @@ const AppWrapper = () => {
|
||||
<Route path="/user/:userId" element={<UserProfile />} />
|
||||
<Route path="/user/:userId/collections" element={<UserCollections />} />
|
||||
<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/:userId/:slug" element={<Collections />} />
|
||||
<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/collections" element={<UserCollections />} />
|
||||
<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/:userId/:slug" element={<Collections />} />
|
||||
<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/video-generator" element={<React.Suspense fallback={<div>Loading...</div>}><VideoGenPlayground /></React.Suspense>} />
|
||||
<Route path="/playground/canvas" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundCanvas /></React.Suspense>} />
|
||||
<Route path="/playground/types" element={<React.Suspense fallback={<div>Loading...</div>}><TypesPlayground /></React.Suspense>} />
|
||||
<Route path="/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 />} />
|
||||
|
||||
{/* Logs */}
|
||||
@ -167,44 +173,53 @@ import { StreamInvalidator } from "@/components/StreamInvalidator";
|
||||
|
||||
// ... (imports)
|
||||
|
||||
import { ActionProvider } from "@/actions/ActionProvider";
|
||||
import { HelmetProvider } from 'react-helmet-async';
|
||||
|
||||
// ... previous imports ...
|
||||
|
||||
const App = () => {
|
||||
React.useEffect(() => {
|
||||
initFormatDetection();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SWRConfig value={{ provider: () => new Map() }}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<LogProvider>
|
||||
<PostNavigationProvider>
|
||||
<MediaRefreshProvider>
|
||||
<LayoutProvider>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
<BrowserRouter>
|
||||
<OrganizationProvider>
|
||||
<ProfilesProvider>
|
||||
<WebSocketProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
|
||||
<StreamProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
|
||||
<StreamInvalidator />
|
||||
<FeedCacheProvider>
|
||||
<AppWrapper />
|
||||
</FeedCacheProvider>
|
||||
</StreamProvider>
|
||||
</WebSocketProvider>
|
||||
</ProfilesProvider>
|
||||
</OrganizationProvider>
|
||||
</BrowserRouter>
|
||||
</TooltipProvider>
|
||||
</LayoutProvider>
|
||||
</MediaRefreshProvider>
|
||||
</PostNavigationProvider>
|
||||
</LogProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</SWRConfig>
|
||||
<HelmetProvider>
|
||||
<SWRConfig value={{ provider: () => new Map() }}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<LogProvider>
|
||||
<PostNavigationProvider>
|
||||
<MediaRefreshProvider>
|
||||
<LayoutProvider>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
<ActionProvider>
|
||||
<BrowserRouter>
|
||||
<OrganizationProvider>
|
||||
<ProfilesProvider>
|
||||
<WebSocketProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
|
||||
<StreamProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
|
||||
<StreamInvalidator />
|
||||
<FeedCacheProvider>
|
||||
<AppWrapper />
|
||||
</FeedCacheProvider>
|
||||
</StreamProvider>
|
||||
</WebSocketProvider>
|
||||
</ProfilesProvider>
|
||||
</OrganizationProvider>
|
||||
</BrowserRouter>
|
||||
</ActionProvider>
|
||||
</TooltipProvider>
|
||||
</LayoutProvider>
|
||||
</MediaRefreshProvider>
|
||||
</PostNavigationProvider>
|
||||
</LogProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</SWRConfig>
|
||||
</HelmetProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -15,8 +15,5 @@ export const ActionProvider: React.FC<ActionProviderProps> = ({ children }) => {
|
||||
registerAction(action);
|
||||
});
|
||||
}, [registerAction]);
|
||||
|
||||
// TODO: Add keyboard shortcut listener here
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
@ -12,7 +12,7 @@ export const useActionStore = create<ActionStore>((set, get) => ({
|
||||
// Prevent duplicate registration if not needed, or overwrite
|
||||
// For now, we overwrite based on 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 {
|
||||
actions: {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Eye, EyeOff, Edit3, Trash2, Share2, Link as LinkIcon, FileText, Download, FolderTree, FileJson, LayoutTemplate } from "lucide-react";
|
||||
@ -12,7 +12,8 @@ import {
|
||||
DropdownMenuGroup
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
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 { Database } from '@/integrations/supabase/types';
|
||||
|
||||
@ -467,15 +468,19 @@ draft: ${!page.visible}
|
||||
{showLabels && <span className="ml-2 hidden md:inline"><T>Categories</T></span>}
|
||||
</Button>
|
||||
|
||||
<CategoryManager
|
||||
isOpen={showCategoryManager}
|
||||
onClose={() => setShowCategoryManager(false)}
|
||||
currentPageId={page.id}
|
||||
currentPageMeta={page.meta}
|
||||
onPageMetaUpdate={handleMetaUpdate}
|
||||
filterByType="pages"
|
||||
defaultMetaType="pages"
|
||||
/>
|
||||
<React.Suspense fallback={null}>
|
||||
{showCategoryManager && (
|
||||
<CategoryManager
|
||||
isOpen={showCategoryManager}
|
||||
onClose={() => setShowCategoryManager(false)}
|
||||
currentPageId={page.id}
|
||||
currentPageMeta={page.meta}
|
||||
onPageMetaUpdate={handleMetaUpdate}
|
||||
filterByType="pages"
|
||||
defaultMetaType="pages"
|
||||
/>
|
||||
)}
|
||||
</React.Suspense>
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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 { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
@ -41,6 +41,7 @@ interface PhotoCardProps {
|
||||
variant?: 'grid' | 'feed';
|
||||
apiUrl?: string;
|
||||
versionCount?: number;
|
||||
isExternal?: boolean;
|
||||
}
|
||||
|
||||
const PhotoCard = ({
|
||||
@ -66,7 +67,8 @@ const PhotoCard = ({
|
||||
responsive,
|
||||
variant = 'grid',
|
||||
apiUrl,
|
||||
versionCount
|
||||
versionCount,
|
||||
isExternal = false
|
||||
}: PhotoCardProps) => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@ -104,6 +106,8 @@ const PhotoCard = ({
|
||||
const handleLike = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (isExternal) return;
|
||||
|
||||
if (!user) {
|
||||
toast.error(translate('Please sign in to like pictures'));
|
||||
return;
|
||||
@ -141,6 +145,8 @@ const PhotoCard = ({
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (isExternal) return;
|
||||
|
||||
if (!user || !isOwner) {
|
||||
toast.error(translate('You can only delete your own images'));
|
||||
return;
|
||||
@ -297,6 +303,11 @@ const PhotoCard = ({
|
||||
};
|
||||
|
||||
const handlePublish = async (option: 'overwrite' | 'new', imageUrl: string, newTitle: string, description?: string) => {
|
||||
if (isExternal) {
|
||||
toast.error(translate('Cannot publish external images'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
toast.error(translate('Please sign in to publish images'));
|
||||
return;
|
||||
@ -409,6 +420,13 @@ const PhotoCard = ({
|
||||
data={responsive}
|
||||
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>
|
||||
|
||||
{/* Desktop Hover Overlay - hidden on mobile, and hidden in feed variant */}
|
||||
@ -428,27 +446,31 @@ const PhotoCard = ({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
size="sm"
|
||||
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>}
|
||||
{!isExternal && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
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>}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 text-white hover:text-blue-400 ml-2"
|
||||
>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-white text-sm">{comments}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 text-white hover:text-blue-400 ml-2"
|
||||
>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-white text-sm">{comments}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isOwner && (
|
||||
{isOwner && !isExternal && (
|
||||
<>
|
||||
<Button
|
||||
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">
|
||||
<Share2 className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
<MagicWizardButton
|
||||
imageUrl={image}
|
||||
imageTitle={title}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-xs bg-white/20 hover:bg-white/30 border-0 text-white"
|
||||
/>
|
||||
{!isExternal && (
|
||||
<MagicWizardButton
|
||||
imageUrl={image}
|
||||
imageTitle={title}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-xs bg-white/20 hover:bg-white/30 border-0 text-white"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -555,27 +579,31 @@ const PhotoCard = ({
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleLike}
|
||||
className={localIsLiked ? "text-red-500 hover:text-red-600" : ""}
|
||||
>
|
||||
<Heart className="h-6 w-6" fill={localIsLiked ? "currentColor" : "none"} />
|
||||
</Button>
|
||||
{localLikes > 0 && (
|
||||
<span className="text-sm font-medium text-foreground mr-1">{localLikes}</span>
|
||||
)}
|
||||
{!isExternal && (
|
||||
<>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleLike}
|
||||
className={localIsLiked ? "text-red-500 hover:text-red-600" : ""}
|
||||
>
|
||||
<Heart className="h-6 w-6" fill={localIsLiked ? "currentColor" : "none"} />
|
||||
</Button>
|
||||
{localLikes > 0 && (
|
||||
<span className="text-sm font-medium text-foreground mr-1">{localLikes}</span>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-foreground"
|
||||
>
|
||||
<MessageCircle className="h-6 w-6 -rotate-90" />
|
||||
</Button>
|
||||
{comments > 0 && (
|
||||
<span className="text-sm font-medium text-foreground mr-1">{comments}</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-foreground"
|
||||
>
|
||||
<MessageCircle className="h-6 w-6 -rotate-90" />
|
||||
</Button>
|
||||
{comments > 0 && (
|
||||
<span className="text-sm font-medium text-foreground mr-1">{comments}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
@ -590,14 +618,16 @@ const PhotoCard = ({
|
||||
<Download className="h-6 w-6" />
|
||||
</Button>
|
||||
|
||||
<MagicWizardButton
|
||||
imageUrl={image}
|
||||
imageTitle={title}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-foreground hover:text-primary"
|
||||
/>
|
||||
{isOwner && (
|
||||
{!isExternal && (
|
||||
<MagicWizardButton
|
||||
imageUrl={image}
|
||||
imageTitle={title}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-foreground hover:text-primary"
|
||||
/>
|
||||
)}
|
||||
{isOwner && !isExternal && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@ -641,7 +671,7 @@ const PhotoCard = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEditModal && (
|
||||
{showEditModal && !isExternal && (
|
||||
<EditImageModal
|
||||
open={showEditModal}
|
||||
onOpenChange={setShowEditModal}
|
||||
@ -668,8 +698,8 @@ const PhotoCard = ({
|
||||
onPublish={handlePublish}
|
||||
isGenerating={isGenerating}
|
||||
isPublishing={isPublishing}
|
||||
showPrompt={true}
|
||||
showPublish={!!generatedImageUrl}
|
||||
showPrompt={!isExternal} // Hide prompt/edit for external
|
||||
showPublish={!!generatedImageUrl && !isExternal}
|
||||
generatedImageUrl={generatedImageUrl || undefined}
|
||||
currentIndex={navigationData?.currentIndex}
|
||||
totalCount={navigationData?.posts.length}
|
||||
|
||||
41
packages/ui/src/components/SEO.tsx
Normal file
41
packages/ui/src/components/SEO.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -100,7 +100,7 @@ const TopNavigation = () => {
|
||||
{/* Logo / Brand */}
|
||||
<Link to="/" className="flex items-center space-x-2">
|
||||
<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>
|
||||
|
||||
{/* Search Bar - Center */}
|
||||
|
||||
@ -3,9 +3,9 @@ import { Button } from "@/components/ui/button";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
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 { T, translate } from "@/i18n";
|
||||
import type { MuxResolution } from "@/types";
|
||||
@ -14,13 +14,17 @@ import { detectMediaType, MEDIA_TYPES } from "@/lib/mediaRegistry";
|
||||
import UserAvatarBlock from "@/components/UserAvatarBlock";
|
||||
import { formatDate, isLikelyFilename } from "@/utils/textUtils";
|
||||
|
||||
import {
|
||||
MediaPlayer, MediaProvider, type MediaPlayerInstance
|
||||
} from '@vidstack/react';
|
||||
// import {
|
||||
// MediaPlayer, MediaProvider, type MediaPlayerInstance
|
||||
// } from '@vidstack/react';
|
||||
import type { MediaPlayerInstance } from '@vidstack/react';
|
||||
|
||||
// Import Vidstack styles
|
||||
import '@vidstack/react/player/styles/default/theme.css';
|
||||
import '@vidstack/react/player/styles/default/layouts/video.css';
|
||||
// import '@vidstack/react/player/styles/default/theme.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 {
|
||||
videoId: string;
|
||||
@ -469,28 +473,30 @@ const VideoCard = ({
|
||||
</>
|
||||
) : (
|
||||
// Show MediaPlayer when playing
|
||||
<MediaPlayer
|
||||
key={videoId}
|
||||
ref={player}
|
||||
title={title}
|
||||
src={
|
||||
playbackUrl.includes('.m3u8')
|
||||
? { src: playbackUrl, type: 'application/x-mpegurl' }
|
||||
: (job?.resultUrl && job.status === 'completed')
|
||||
? { src: job.resultUrl, type: 'application/x-mpegurl' }
|
||||
: playbackUrl.includes('/api/videos/jobs')
|
||||
? { src: playbackUrl, type: 'video/mp4' }
|
||||
: playbackUrl
|
||||
}
|
||||
poster={posterUrl}
|
||||
fullscreenOrientation="any"
|
||||
controls
|
||||
playsInline
|
||||
className={`w-full ${variant === 'grid' ? "h-full" : ""}`}
|
||||
>
|
||||
<MediaProvider />
|
||||
<DefaultVideoLayout icons={defaultLayoutIcons} />
|
||||
</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>}>
|
||||
<VidstackPlayer
|
||||
key={videoId}
|
||||
ref={player}
|
||||
title={title}
|
||||
src={
|
||||
playbackUrl.includes('.m3u8')
|
||||
? { src: playbackUrl, type: 'application/x-mpegurl' }
|
||||
: (job?.resultUrl && job.status === 'completed')
|
||||
? { src: job.resultUrl, type: 'application/x-mpegurl' }
|
||||
: playbackUrl.includes('/api/videos/jobs')
|
||||
? { src: playbackUrl, type: 'video/mp4' }
|
||||
: playbackUrl
|
||||
}
|
||||
poster={posterUrl}
|
||||
fullscreenOrientation="any"
|
||||
controls
|
||||
playsInline
|
||||
className={`w-full ${variant === 'grid' ? "h-full" : ""}`}
|
||||
layoutProps={{
|
||||
icons: defaultLayoutIcons
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@ -14,13 +14,14 @@ interface GenericCanvasProps {
|
||||
showControls?: boolean;
|
||||
className?: string;
|
||||
selectedWidgetId?: string | null;
|
||||
onSelectWidget?: (widgetId: string) => void;
|
||||
onSelectWidget?: (widgetId: string, pageId?: string) => void;
|
||||
selectedContainerId?: string | null;
|
||||
onSelectContainer?: (containerId: string | null) => void;
|
||||
onSelectContainer?: (containerId: string | null, pageId?: string) => void;
|
||||
initialLayout?: any;
|
||||
editingWidgetId?: string | null;
|
||||
onEditWidget?: (widgetId: string | null) => void;
|
||||
newlyAddedWidgetId?: string | null;
|
||||
contextVariables?: Record<string, any>;
|
||||
}
|
||||
|
||||
const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
|
||||
@ -36,7 +37,8 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
|
||||
initialLayout,
|
||||
editingWidgetId,
|
||||
onEditWidget,
|
||||
newlyAddedWidgetId
|
||||
newlyAddedWidgetId,
|
||||
contextVariables
|
||||
}) => {
|
||||
const {
|
||||
loadedPages,
|
||||
@ -72,13 +74,14 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
|
||||
const [internalSelectedContainer, setInternalSelectedContainer] = useState<string | null>(null);
|
||||
|
||||
const selectedContainer = propSelectedContainerId !== undefined ? propSelectedContainerId : internalSelectedContainer;
|
||||
const setSelectedContainer = (id: string | null) => {
|
||||
const setSelectedContainer = (id: string | null, pageId?: string) => {
|
||||
if (propOnSelectContainer) {
|
||||
propOnSelectContainer(id);
|
||||
propOnSelectContainer(id, pageId);
|
||||
} else {
|
||||
setInternalSelectedContainer(id);
|
||||
}
|
||||
};
|
||||
|
||||
const [showWidgetPalette, setShowWidgetPalette] = useState(false);
|
||||
const [targetContainerId, setTargetContainerId] = useState<string | null>(null);
|
||||
const [targetColumn, setTargetColumn] = useState<number | undefined>(undefined);
|
||||
@ -96,8 +99,8 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const handleSelectContainer = (containerId: string) => {
|
||||
setSelectedContainer(containerId);
|
||||
const handleSelectContainer = (containerId: string, pageId?: string) => {
|
||||
setSelectedContainer(containerId, pageId);
|
||||
};
|
||||
|
||||
const handleAddWidget = (containerId: string, columnIndex?: number) => {
|
||||
@ -313,6 +316,7 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
|
||||
editingWidgetId={editingWidgetId}
|
||||
onEditWidget={onEditWidget}
|
||||
newlyAddedWidgetId={newlyAddedWidgetId}
|
||||
contextVariables={contextVariables}
|
||||
onRemoveWidget={async (widgetId) => {
|
||||
try {
|
||||
await removeWidgetFromPage(pageId, widgetId);
|
||||
|
||||
@ -15,7 +15,7 @@ interface LayoutContainerProps {
|
||||
isEditMode: boolean;
|
||||
pageId: string;
|
||||
selectedContainerId?: string | null;
|
||||
onSelect?: (containerId: string) => void;
|
||||
onSelect?: (containerId: string, pageId?: string) => void;
|
||||
onAddWidget?: (containerId: string, targetColumn?: number) => void;
|
||||
onRemoveWidget?: (widgetInstanceId: string) => void;
|
||||
onMoveWidget?: (widgetInstanceId: string, direction: 'up' | 'down' | 'left' | 'right') => void;
|
||||
@ -27,12 +27,13 @@ interface LayoutContainerProps {
|
||||
canMoveContainerUp?: boolean;
|
||||
canMoveContainerDown?: boolean;
|
||||
selectedWidgetId?: string | null;
|
||||
onSelectWidget?: (widgetId: string) => void;
|
||||
onSelectWidget?: (widgetId: string, pageId?: string) => void;
|
||||
depth?: number;
|
||||
isCompactMode?: boolean;
|
||||
editingWidgetId?: string | null;
|
||||
onEditWidget?: (widgetId: string | null) => void;
|
||||
newlyAddedWidgetId?: string | null;
|
||||
contextVariables?: Record<string, any>;
|
||||
}
|
||||
|
||||
const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
|
||||
@ -58,6 +59,7 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
|
||||
editingWidgetId,
|
||||
onEditWidget,
|
||||
newlyAddedWidgetId,
|
||||
contextVariables,
|
||||
}) => {
|
||||
const maxDepth = 3; // Limit nesting depth
|
||||
const canNest = depth < maxDepth;
|
||||
@ -116,7 +118,7 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
|
||||
isEditMode={isEditMode}
|
||||
pageId={pageId}
|
||||
isSelected={selectedWidgetId === widget.id}
|
||||
onSelect={() => onSelectWidget?.(widget.id)}
|
||||
onSelect={() => onSelectWidget?.(widget.id, pageId)}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < container.widgets.length - 1}
|
||||
onRemove={onRemoveWidget}
|
||||
@ -124,6 +126,10 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
|
||||
isEditing={editingWidgetId === widget.id}
|
||||
onEditWidget={onEditWidget}
|
||||
isNew={newlyAddedWidgetId === widget.id}
|
||||
selectedWidgetId={selectedWidgetId}
|
||||
onSelectWidget={onSelectWidget}
|
||||
editingWidgetId={editingWidgetId}
|
||||
contextVariables={contextVariables}
|
||||
/>
|
||||
))}
|
||||
|
||||
@ -182,6 +188,7 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
|
||||
editingWidgetId={editingWidgetId}
|
||||
onEditWidget={onEditWidget}
|
||||
newlyAddedWidgetId={newlyAddedWidgetId}
|
||||
contextVariables={contextVariables}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@ -196,7 +203,7 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
|
||||
)}
|
||||
onDoubleClick={isEditMode ? (e) => {
|
||||
e.stopPropagation();
|
||||
onSelect?.(container.id);
|
||||
onSelect?.(container.id, pageId);
|
||||
setTimeout(() => onAddWidget?.(container.id), 100); // Small delay to ensure selection happens first, no column = append
|
||||
} : undefined}
|
||||
title={isEditMode ? "Double-click to add widget" : undefined}
|
||||
@ -375,7 +382,7 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isEditMode) {
|
||||
onSelect?.(container.id);
|
||||
onSelect?.(container.id, pageId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@ -463,6 +470,10 @@ interface WidgetItemProps {
|
||||
isEditing?: boolean;
|
||||
onEditWidget?: (widgetId: string | null) => void;
|
||||
isNew?: boolean;
|
||||
selectedWidgetId?: string | null;
|
||||
onSelectWidget?: (widgetId: string, pageId?: string) => void;
|
||||
editingWidgetId?: string | null;
|
||||
contextVariables?: Record<string, any>;
|
||||
}
|
||||
|
||||
const WidgetItem: React.FC<WidgetItemProps> = ({
|
||||
@ -477,7 +488,11 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
|
||||
onSelect,
|
||||
isEditing,
|
||||
onEditWidget,
|
||||
isNew
|
||||
isNew,
|
||||
selectedWidgetId,
|
||||
onSelectWidget,
|
||||
editingWidgetId,
|
||||
contextVariables,
|
||||
}) => {
|
||||
const widgetDefinition = widgetRegistry.get(widget.widgetId);
|
||||
const { updateWidgetProps, renameWidget } = useLayout();
|
||||
@ -639,6 +654,11 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
|
||||
console.error('Failed to update widget props:', error);
|
||||
}
|
||||
}}
|
||||
selectedWidgetId={selectedWidgetId}
|
||||
onSelectWidget={onSelectWidget}
|
||||
editingWidgetId={editingWidgetId}
|
||||
onEditWidget={onEditWidget}
|
||||
contextVariables={contextVariables}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
228
packages/ui/src/components/playground/I18nPlayground.tsx
Normal file
228
packages/ui/src/components/playground/I18nPlayground.tsx
Normal 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} -> {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>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,10 @@
|
||||
|
||||
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 { LayoutContainer, WidgetInstance } from '@/lib/unifiedLayoutManager';
|
||||
import { widgetRegistry } from '@/lib/widgetRegistry';
|
||||
import { useLayout } from '@/contexts/LayoutContext';
|
||||
|
||||
interface HierarchyNodeProps {
|
||||
label: string;
|
||||
@ -16,6 +17,7 @@ interface HierarchyNodeProps {
|
||||
hasChildren?: boolean;
|
||||
onToggleExpand?: () => void;
|
||||
isExpanded?: boolean;
|
||||
onSettings?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const TreeNode = ({
|
||||
@ -27,7 +29,8 @@ const TreeNode = ({
|
||||
depth = 0,
|
||||
hasChildren = false,
|
||||
onToggleExpand,
|
||||
isExpanded = false
|
||||
isExpanded = false,
|
||||
onSettings
|
||||
}: HierarchyNodeProps) => {
|
||||
|
||||
return (
|
||||
@ -56,7 +59,23 @@ const TreeNode = ({
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{hasChildren && isExpanded && (
|
||||
<div>{children}</div>
|
||||
@ -71,6 +90,8 @@ interface HierarchyTreeProps {
|
||||
selectedContainerId?: string | null;
|
||||
onSelectWidget: (id: string) => void;
|
||||
onSelectContainer: (id: string) => void;
|
||||
onSettingsClick?: (id: string, type: 'widget' | 'container', layoutId: string) => void;
|
||||
layoutId: string;
|
||||
}
|
||||
|
||||
export const HierarchyTree = ({
|
||||
@ -78,23 +99,62 @@ export const HierarchyTree = ({
|
||||
selectedWidgetId,
|
||||
selectedContainerId,
|
||||
onSelectWidget,
|
||||
onSelectContainer
|
||||
onSelectContainer,
|
||||
onSettingsClick,
|
||||
layoutId
|
||||
}: HierarchyTreeProps) => {
|
||||
|
||||
// Manage expansion state locally
|
||||
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
|
||||
React.useEffect(() => {
|
||||
const allIds = new Set<string>();
|
||||
const traverse = (items: LayoutContainer[]) => {
|
||||
items.forEach(c => {
|
||||
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 (containers) traverse(containers);
|
||||
setExpandedNodes(allIds);
|
||||
setExpandedNodes(prev => {
|
||||
const next = new Set(prev);
|
||||
allIds.forEach(id => next.add(id));
|
||||
return next;
|
||||
});
|
||||
}, [containers]);
|
||||
|
||||
const toggleNode = (id: string) => {
|
||||
@ -109,10 +169,66 @@ export const HierarchyTree = ({
|
||||
|
||||
// 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 name = def?.metadata.name || widget.widgetId;
|
||||
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 (
|
||||
<TreeNode
|
||||
@ -122,14 +238,30 @@ export const HierarchyTree = ({
|
||||
isSelected={selectedWidgetId === widget.id}
|
||||
onClick={(e) => {
|
||||
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);
|
||||
}}
|
||||
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 hasChildren = container.widgets.length > 0 || container.children.length > 0;
|
||||
const isExpanded = expandedNodes.has(container.id);
|
||||
@ -145,12 +277,20 @@ export const HierarchyTree = ({
|
||||
hasChildren={hasChildren}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={() => toggleNode(container.id)}
|
||||
onSettings={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onSettingsClick) {
|
||||
onSettingsClick(container.id, 'container', currentLayoutId);
|
||||
} else {
|
||||
onSelectContainer(container.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Render Content */}
|
||||
{isExpanded && (
|
||||
<>
|
||||
{container.widgets.map(w => renderWidget(w, depth + 1))}
|
||||
{container.children.map(c => renderContainer(c, depth + 1))}
|
||||
{container.widgets.map(w => renderWidget(w, depth + 1, currentLayoutId))}
|
||||
{container.children.map(c => renderContainer(c, depth + 1, currentLayoutId))}
|
||||
</>
|
||||
)}
|
||||
</TreeNode>
|
||||
@ -169,7 +309,7 @@ export const HierarchyTree = ({
|
||||
|
||||
return (
|
||||
<div className="pb-2">
|
||||
{containers.map(c => renderContainer(c, 0))}
|
||||
{containers.map(c => renderContainer(c, 0, layoutId))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -62,7 +62,7 @@ function TocItemRenderer({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isActive && itemRef.current) {
|
||||
itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
// itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
|
||||
@ -34,13 +34,7 @@ export interface BuilderElement {
|
||||
uiSchema?: any;
|
||||
}
|
||||
|
||||
const PALETTE_ITEMS: BuilderElement[] = [
|
||||
{ id: 'p-string', type: 'string', name: 'String', title: 'New String' },
|
||||
{ id: 'p-number', type: 'number', name: 'Number', title: 'New Number' },
|
||||
{ id: 'p-boolean', type: 'boolean', name: 'Boolean', title: 'New Boolean' },
|
||||
{ id: 'p-object', type: 'object', name: 'Object', title: 'New Object' },
|
||||
{ id: 'p-array', type: 'array', name: 'Array', title: 'New Array' },
|
||||
];
|
||||
|
||||
|
||||
const DraggablePaletteItem = ({ item }: { item: BuilderElement }) => {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
@ -81,8 +75,8 @@ const CanvasElement = ({
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
// Check if this is a primitive type or a custom type
|
||||
const primitiveTypes = ['string', 'number', 'boolean', 'array', 'object'];
|
||||
const isPrimitive = primitiveTypes.includes(element.type);
|
||||
const primitiveTypes = ['string', 'number', 'int', 'float', 'boolean', 'bool', 'array', 'object'];
|
||||
const isPrimitive = primitiveTypes.includes(element.type.toLowerCase());
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -113,7 +107,7 @@ const CanvasElement = ({
|
||||
<AlertDialogDescription asChild>
|
||||
<div>
|
||||
{isPrimitive ? (
|
||||
<>This will remove the field "{element.title || element.name}" from the structure and delete it from the database. This action cannot be undone.</>
|
||||
<>This will remove the field "{element.title || element.name}" from the structure.</>
|
||||
) : (
|
||||
<>
|
||||
Choose how to remove the field "{element.title || element.name}":
|
||||
@ -128,28 +122,42 @@ const CanvasElement = ({
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
{!isPrimitive && onRemoveOnly && (
|
||||
{isPrimitive ? (
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveOnly();
|
||||
if (onRemoveOnly) onRemoveOnly();
|
||||
setShowDeleteDialog(false);
|
||||
}}
|
||||
className="bg-secondary text-secondary-foreground hover:bg-secondary/90"
|
||||
>
|
||||
Remove Only
|
||||
Remove
|
||||
</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>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
@ -158,9 +166,12 @@ const CanvasElement = ({
|
||||
};
|
||||
|
||||
function getIconForType(type: string) {
|
||||
switch (type) {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'string': return TypeIcon;
|
||||
case 'int':
|
||||
case 'float':
|
||||
case 'number': return Hash;
|
||||
case 'bool':
|
||||
case 'boolean': return ToggleLeft;
|
||||
case 'object': return Box;
|
||||
case 'array': return List;
|
||||
@ -210,9 +221,31 @@ const TypeBuilderContent: React.FC<{
|
||||
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(() => {
|
||||
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 => ({
|
||||
id: `type-${t.id}`,
|
||||
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">
|
||||
<CardTitle className="text-sm font-medium">Palette</CardTitle>
|
||||
</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 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Primitives</div>
|
||||
<div className="space-y-2">
|
||||
{PALETTE_ITEMS.map(item => (
|
||||
<DraggablePaletteItem key={item.id} item={item} />
|
||||
))}
|
||||
{primitivePaletteItems.length > 0 ? (
|
||||
primitivePaletteItems.map(item => (
|
||||
<DraggablePaletteItem key={item.id} item={item} />
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground italic">No primitive types found.</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' : ''}`}>
|
||||
<CardHeader className="py-3 px-4 border-b flex flex-row justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<CardTitle className="text-sm font-medium">Builder</CardTitle>
|
||||
<Tabs value={mode} onValueChange={(v) => { setMode(v as BuilderMode); setElements([]); }} className="w-[200px]">
|
||||
<TabsList className="grid w-full grid-cols-2 h-7">
|
||||
<TabsList className="grid grid-cols-2 h-7">
|
||||
<TabsTrigger value="structure" className="text-xs">Structure</TabsTrigger>
|
||||
<TabsTrigger value="alias" className="text-xs">Single Type</TabsTrigger>
|
||||
</TabsList>
|
||||
@ -278,7 +314,7 @@ const TypeBuilderContent: React.FC<{
|
||||
</Button>
|
||||
</div>
|
||||
</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 && (
|
||||
<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
|
||||
value={elements[0]?.type || ''}
|
||||
onValueChange={(val) => {
|
||||
if (elements.length > 0) {
|
||||
const updated = { ...elements[0], type: val, name: 'value', title: val + ' Alias' };
|
||||
setElements([updated]);
|
||||
setSelectedId(updated.id);
|
||||
} else {
|
||||
// Create new if empty? Or just wait for drag?
|
||||
// Let's allow creating via select if empty
|
||||
const newItemId = `field-${Date.now()}`;
|
||||
const newItem: BuilderElement = {
|
||||
id: newItemId,
|
||||
type: val,
|
||||
name: 'value',
|
||||
title: val + ' Alias',
|
||||
uiSchema: {}
|
||||
};
|
||||
setElements([newItem]);
|
||||
setSelectedId(newItemId);
|
||||
}
|
||||
const foundType = availableTypes.find(t => t.name === val);
|
||||
if (!foundType) return;
|
||||
|
||||
const newItemId = `field-${Date.now()}`;
|
||||
const newItem: BuilderElement = {
|
||||
id: elements.length > 0 ? elements[0].id : newItemId,
|
||||
type: val,
|
||||
name: 'value',
|
||||
title: val + ' Alias',
|
||||
uiSchema: {},
|
||||
...(foundType && { refId: foundType.id } as any)
|
||||
};
|
||||
setElements([newItem]);
|
||||
setSelectedId(newItem.id);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a primitive type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PALETTE_ITEMS.map(p => (
|
||||
{primitivePaletteItems.map(p => (
|
||||
<SelectItem key={p.id} value={p.type}>{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@ -464,12 +496,12 @@ const TypeBuilderContent: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export const TypeBuilder: React.FC<{
|
||||
export const TypeBuilder = React.forwardRef<TypeBuilderRef, {
|
||||
onSave: (data: BuilderOutput) => void,
|
||||
onCancel: () => void,
|
||||
availableTypes: TypeDefinition[],
|
||||
initialData?: BuilderOutput
|
||||
}> = ({ onSave, onCancel, availableTypes, initialData }) => {
|
||||
}>(({ onSave, onCancel, availableTypes, initialData }, ref) => {
|
||||
const [mode, setMode] = useState<BuilderMode>(initialData?.mode || 'structure');
|
||||
const [elements, setElements] = useState<BuilderElement[]>(initialData?.elements || []);
|
||||
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)}`;
|
||||
|
||||
// Determine the refId for this element
|
||||
// If template already has refId (custom type from palette), use it
|
||||
// Otherwise, look up primitive type by mapped name
|
||||
// If template already has refId (custom type from palette or primitive from DB), use it
|
||||
let refId = (template as any).refId;
|
||||
|
||||
// Fallback for legacy palette items (shouldn't be hit now, but good for safety)
|
||||
if (!refId) {
|
||||
// Map JSON Schema type names to database primitive type names
|
||||
const typeNameMap: Record<string, string> = {
|
||||
'number': 'int', 'boolean': 'bool', 'string': 'string', 'array': 'array', 'object': 'object'
|
||||
};
|
||||
@ -554,6 +586,16 @@ export const TypeBuilder: React.FC<{
|
||||
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 (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
@ -600,4 +642,8 @@ export const TypeBuilder: React.FC<{
|
||||
)}
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export interface TypeBuilderRef {
|
||||
triggerSave: () => void;
|
||||
}
|
||||
|
||||
56
packages/ui/src/components/types/TypeEditorActions.tsx
Normal file
56
packages/ui/src/components/types/TypeEditorActions.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -1,9 +1,9 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import React, { useState, useMemo, useImperativeHandle, forwardRef } from 'react';
|
||||
import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { RefreshCw, Save, Trash2, Play } from 'lucide-react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import Form from '@rjsf/core';
|
||||
import validator from '@rjsf/validator-ajv8';
|
||||
import { customWidgets, customTemplates } from './RJSFTemplates';
|
||||
@ -11,21 +11,22 @@ import { generateRandomData } from './randomDataGenerator';
|
||||
import { TypeDefinition } from './db';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface TypeRendererRef {
|
||||
triggerSave: () => Promise<void>;
|
||||
triggerPreview: () => void;
|
||||
}
|
||||
|
||||
interface TypeRendererProps {
|
||||
editedType: TypeDefinition;
|
||||
types: TypeDefinition[];
|
||||
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,
|
||||
types,
|
||||
onSave,
|
||||
onDelete,
|
||||
onVisualEdit
|
||||
}) => {
|
||||
}, ref) => {
|
||||
const [jsonSchemaString, setJsonSchemaString] = useState('{}');
|
||||
const [uiSchemaString, setUiSchemaString] = useState('{}');
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
@ -184,13 +185,16 @@ export const TypeRenderer: React.FC<TypeRendererProps> = ({
|
||||
await onSave(jsonSchemaString, uiSchemaString);
|
||||
};
|
||||
|
||||
const handlePreviewToggle = () => {
|
||||
if (!showPreview && editedType?.json_schema) {
|
||||
const randomData = generateRandomData(previewSchema);
|
||||
setPreviewFormData(randomData);
|
||||
useImperativeHandle(ref, () => ({
|
||||
triggerSave: handleSave,
|
||||
triggerPreview: () => {
|
||||
if (!showPreview && editedType?.json_schema) {
|
||||
const randomData = generateRandomData(previewSchema);
|
||||
setPreviewFormData(randomData);
|
||||
}
|
||||
setShowPreview(prev => !prev);
|
||||
}
|
||||
setShowPreview(!showPreview);
|
||||
};
|
||||
}));
|
||||
|
||||
const handleRegenerateData = () => {
|
||||
if (previewSchema) {
|
||||
@ -214,34 +218,6 @@ export const TypeRenderer: React.FC<TypeRendererProps> = ({
|
||||
{editedType.description || "No description"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onVisualEdit} size="sm" variant="outline">
|
||||
<RefreshCw className="mr-2 h-3.5 w-3.5" />
|
||||
Visual Edit
|
||||
</Button>
|
||||
{editedType.kind === 'structure' && (
|
||||
<Button
|
||||
onClick={handlePreviewToggle}
|
||||
size="sm"
|
||||
variant={showPreview ? "default" : "outline"}
|
||||
>
|
||||
<Play className="mr-2 h-3.5 w-3.5" />
|
||||
{showPreview ? 'Hide' : 'Preview'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
<Button onClick={handleSave} size="sm">
|
||||
<Save className="mr-2 h-3.5 w-3.5" />
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 min-h-0 p-0 overflow-hidden">
|
||||
@ -341,6 +317,6 @@ export const TypeRenderer: React.FC<TypeRendererProps> = ({
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default TypeRenderer;
|
||||
|
||||
349
packages/ui/src/components/types/TypesEditor.tsx
Normal file
349
packages/ui/src/components/types/TypesEditor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
77
packages/ui/src/components/types/TypesList.tsx
Normal file
77
packages/ui/src/components/types/TypesList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -1,30 +1,31 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { fetchTypes, updateType, createType, deleteType, TypeDefinition } from './db';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Loader2, Plus, RefreshCw } from "lucide-react";
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { fetchTypes, deleteType, TypeDefinition } from './db';
|
||||
import { Loader2, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { TypeBuilder, BuilderOutput, BuilderElement, BuilderMode } from './TypeBuilder';
|
||||
import TypeRenderer from './TypeRenderer';
|
||||
import { TypesList } from './TypesList';
|
||||
import { TypesEditor } from './TypesEditor';
|
||||
import { useActions } from '@/actions/useActions';
|
||||
import { Action } from '@/actions/types';
|
||||
import { TypeEditorActions } from './TypeEditorActions';
|
||||
|
||||
const TypesPlayground: React.FC = () => {
|
||||
const [types, setTypes] = useState<TypeDefinition[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedTypeId, setSelectedTypeId] = useState<string | null>(null);
|
||||
const [editedType, setEditedType] = useState<TypeDefinition | null>(null);
|
||||
const [isBuilding, setIsBuilding] = useState(false);
|
||||
const [builderInitialData, setBuilderInitialData] = useState<BuilderOutput | undefined>(undefined);
|
||||
|
||||
const loadTypes = async () => {
|
||||
const { registerAction, updateAction, unregisterAction } = useActions();
|
||||
|
||||
const loadTypes = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchTypes();
|
||||
console.log('types', data);
|
||||
// console.log('types', data);
|
||||
setTypes(data);
|
||||
if (selectedTypeId) {
|
||||
const refreshed = data.find(t => t.id === selectedTypeId);
|
||||
if (refreshed) selectType(refreshed);
|
||||
// Ensure selection is valid
|
||||
const exists = data.find(t => t.id === selectedTypeId);
|
||||
if (!exists) setSelectedTypeId(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch types", error);
|
||||
@ -32,202 +33,72 @@ const TypesPlayground: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [selectedTypeId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTypes();
|
||||
}, [loadTypes]);
|
||||
|
||||
const handleCreateNew = React.useCallback(() => {
|
||||
setSelectedTypeId(null);
|
||||
setIsBuilding(true);
|
||||
}, []);
|
||||
|
||||
const selectType = (t: TypeDefinition) => {
|
||||
const handleSelectType = React.useCallback((t: TypeDefinition) => {
|
||||
setIsBuilding(false);
|
||||
setSelectedTypeId(t.id);
|
||||
setEditedType(t);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setSelectedTypeId(null);
|
||||
setEditedType(null);
|
||||
setBuilderInitialData(undefined);
|
||||
setIsBuilding(true);
|
||||
};
|
||||
|
||||
const handleEditVisual = () => {
|
||||
if (!editedType) return;
|
||||
|
||||
// Convert current type to builder format
|
||||
const builderData: BuilderOutput = {
|
||||
mode: editedType.kind as BuilderMode,
|
||||
name: editedType.name,
|
||||
description: editedType.description || '',
|
||||
elements: []
|
||||
// Register "New Type" action
|
||||
useEffect(() => {
|
||||
const action: Action = {
|
||||
id: 'types.new',
|
||||
label: 'New Type',
|
||||
icon: Plus,
|
||||
group: 'types',
|
||||
handler: handleCreateNew,
|
||||
shortcut: 'mod+n' // Or something else
|
||||
};
|
||||
|
||||
// For structures, convert structure_fields to builder elements
|
||||
if (editedType.kind === 'structure' && editedType.structure_fields) {
|
||||
builderData.elements = editedType.structure_fields.map(field => {
|
||||
const fieldType = types.find(t => t.id === field.field_type_id);
|
||||
return {
|
||||
id: field.id || crypto.randomUUID(),
|
||||
name: field.field_name,
|
||||
type: fieldType?.name || 'string',
|
||||
title: field.field_name,
|
||||
description: fieldType?.description || ''
|
||||
} as BuilderElement;
|
||||
});
|
||||
}
|
||||
registerAction(action);
|
||||
|
||||
setBuilderInitialData(builderData);
|
||||
setIsBuilding(true);
|
||||
};
|
||||
return () => unregisterAction('types.new');
|
||||
}, [registerAction, unregisterAction]);
|
||||
|
||||
const handleBuilderSave = async (output: BuilderOutput) => {
|
||||
console.log('Builder output:', output);
|
||||
// Update "New Type" state
|
||||
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 (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-b px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Types Playground</h1>
|
||||
<h1 className="text-2xl font-bold">Types Editor</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage and preview your type definitions
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleCreateNew} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Type
|
||||
</Button>
|
||||
<TypeEditorActions
|
||||
actionIds={[
|
||||
'types.new',
|
||||
'types.edit.visual',
|
||||
'types.preview.toggle',
|
||||
'types.delete',
|
||||
'types.cancel',
|
||||
'types.save'
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
@ -239,111 +110,29 @@ const TypesPlayground: React.FC = () => {
|
||||
) : (
|
||||
<div className="grid grid-cols-12 gap-6 h-full">
|
||||
{/* List Sidebar */}
|
||||
<Card className={`flex flex-col min-h-0 ${isBuilding ? 'hidden' : 'col-span-3'}`}>
|
||||
<CardHeader className="pb-3 border-b px-4 py-3">
|
||||
<CardTitle className="text-sm font-medium">Available Types</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 min-h-0 p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-3 space-y-4">
|
||||
{kindOrder.map(kind => {
|
||||
const group = groupedTypes[kind];
|
||||
if (!group || group.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={kind} className="space-y-1">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-2 mb-2">
|
||||
{kind}
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{group.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => selectType(t)}
|
||||
className={`w-full text-left px-2 py-1.5 rounded-md text-xs transition-colors flex items-center justify-between ${selectedTypeId === t.id
|
||||
? 'bg-secondary text-secondary-foreground font-medium'
|
||||
: 'hover:bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{t.name}</span>
|
||||
{selectedTypeId === t.id && (
|
||||
<div className="w-1 h-1 rounded-full bg-primary flex-shrink-0 ml-2" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TypesList
|
||||
types={types}
|
||||
selectedTypeId={selectedTypeId}
|
||||
onSelect={handleSelectType}
|
||||
className={`col-span-3 ${isBuilding ? 'hidden' : ''}`}
|
||||
/>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className={`${isBuilding ? 'col-span-12' : 'col-span-9'} flex flex-col min-h-0 overflow-hidden`}>
|
||||
{isBuilding ? (
|
||||
<TypeBuilder
|
||||
onSave={handleBuilderSave}
|
||||
onCancel={() => { setIsBuilding(false); setBuilderInitialData(undefined); if (types.length > 0 && selectedTypeId) selectType(types.find(t => t.id === selectedTypeId)!); }}
|
||||
availableTypes={types}
|
||||
initialData={builderInitialData}
|
||||
/>
|
||||
) : (
|
||||
<Card className="flex flex-col h-full overflow-hidden">
|
||||
{editedType ? (
|
||||
<TypeRenderer
|
||||
editedType={editedType}
|
||||
types={types}
|
||||
onSave={async (jsonSchemaString, uiSchemaString) => {
|
||||
try {
|
||||
const jsonSchema = JSON.parse(jsonSchemaString);
|
||||
const uiSchema = JSON.parse(uiSchemaString);
|
||||
await updateType(editedType.id, {
|
||||
json_schema: jsonSchema,
|
||||
meta: { ...editedType.meta, uiSchema }
|
||||
});
|
||||
toast.success("Type updated");
|
||||
loadTypes();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.error("Failed to update type");
|
||||
}
|
||||
}}
|
||||
onDelete={async () => {
|
||||
if (confirm("Are you sure you want to delete this type?")) {
|
||||
try {
|
||||
await deleteType(editedType.id);
|
||||
toast.success("Type deleted");
|
||||
setEditedType(null);
|
||||
setSelectedTypeId(null);
|
||||
loadTypes();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.error("Failed to delete type");
|
||||
}
|
||||
}
|
||||
}}
|
||||
onVisualEdit={handleEditVisual}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground flex-col gap-2 p-8 text-center">
|
||||
<div className="bg-muted p-4 rounded-full mb-2">
|
||||
<RefreshCw className="h-8 w-8 opacity-20" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">No Type Selected</h3>
|
||||
<p className="max-w-sm text-sm">Select a type from the sidebar to view its details, edit the schema, and preview the generated form.</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
<TypesEditor
|
||||
types={types}
|
||||
selectedType={types.find(t => t.id === selectedTypeId) || null}
|
||||
isBuilding={isBuilding}
|
||||
onIsBuildingChange={setIsBuilding}
|
||||
onSave={loadTypes}
|
||||
onDeleteRaw={deleteType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
export default TypesPlayground;
|
||||
|
||||
@ -31,7 +31,7 @@ export interface TypeDefinition {
|
||||
}
|
||||
|
||||
export const fetchTypes = async (options?: {
|
||||
kind?: TypeDefinition['kind'] | string; // Allow string for flexibility or specific enum
|
||||
kind?: TypeDefinition['kind'] | string;
|
||||
parentTypeId?: string;
|
||||
visibility?: string;
|
||||
}) => {
|
||||
@ -39,64 +39,39 @@ export const fetchTypes = async (options?: {
|
||||
const key = `types-${JSON.stringify(options || {})}`;
|
||||
|
||||
return fetchWithDeduplication(key, async () => {
|
||||
let query = supabase
|
||||
.from('types')
|
||||
.select(`
|
||||
*,
|
||||
structure_fields:type_structure_fields!type_structure_fields_structure_type_id_fkey(*)
|
||||
`)
|
||||
.order('name');
|
||||
const params = new URLSearchParams();
|
||||
if (options?.kind) params.append('kind', options.kind);
|
||||
if (options?.parentTypeId) params.append('parentTypeId', options.parentTypeId);
|
||||
if (options?.visibility) params.append('visibility', options.visibility);
|
||||
|
||||
if (options?.kind) {
|
||||
query = query.eq('kind', options.kind as any);
|
||||
}
|
||||
const { data: sessionData } = await supabase.auth.getSession();
|
||||
const token = sessionData.session?.access_token;
|
||||
const headers: HeadersInit = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
if (options?.parentTypeId) {
|
||||
query = query.eq('parent_type_id', options.parentTypeId);
|
||||
}
|
||||
const res = await fetch(`/api/types?${params.toString()}`, { headers });
|
||||
if (!res.ok) throw new Error(`Failed to fetch types: ${res.statusText}`);
|
||||
|
||||
if (options?.visibility) {
|
||||
query = query.eq('visibility', options.visibility as any);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
const data = await res.json();
|
||||
return data as TypeDefinition[];
|
||||
}, 1); // 5 min cache
|
||||
}, 1); // 5 min cache (client side)
|
||||
};
|
||||
|
||||
export const fetchTypeById = async (id: string) => {
|
||||
const key = `type-${id}`;
|
||||
return fetchWithDeduplication(key, async () => {
|
||||
// We can call the API endpoint or Supabase directly.
|
||||
// Using API might yield more enriched data if the server does heavy lifting.
|
||||
// But for consistency with lib/db.ts, let's use supabase client directly or the API route?
|
||||
// lib/db.ts uses supabase client directly mostly.
|
||||
// However, the server does a nice join:
|
||||
/*
|
||||
.select(`
|
||||
*,
|
||||
enum_values:type_enum_values(*),
|
||||
flag_values:type_flag_values(*),
|
||||
structure_fields:type_structure_fields(*),
|
||||
casts_from:type_casts!from_type_id(*),
|
||||
casts_to:type_casts!to_type_id(*)
|
||||
`)
|
||||
*/
|
||||
const { data, error } = await supabase
|
||||
.from('types')
|
||||
.select(`
|
||||
*,
|
||||
enum_values:type_enum_values(*),
|
||||
flag_values:type_flag_values(*),
|
||||
structure_fields:type_structure_fields(*),
|
||||
casts_from:type_casts!from_type_id(*),
|
||||
casts_to:type_casts!to_type_id(*)
|
||||
`)
|
||||
.eq('id', id)
|
||||
.single();
|
||||
const { data: sessionData } = await supabase.auth.getSession();
|
||||
const token = sessionData.session?.access_token;
|
||||
const headers: HeadersInit = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
@ -7,7 +7,7 @@ import { T, translate } from "@/i18n";
|
||||
import { toast } from "sonner";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { invalidateUserPageCache } from "@/lib/db";
|
||||
import { PageActions } from "@/components/PageActions";
|
||||
const PageActions = React.lazy(() => import("@/components/PageActions").then(module => ({ default: module.PageActions })));
|
||||
import {
|
||||
FileText, Check, X, Calendar, FolderTree, EyeOff, Plus
|
||||
} from "lucide-react";
|
||||
@ -356,19 +356,21 @@ export const UserPageDetails: React.FC<UserPageDetailsProps> = ({
|
||||
|
||||
{/* PageActions - Only visible in View Mode (Edit Mode uses PageRibbonBar) */}
|
||||
{!isEditMode && (
|
||||
<PageActions
|
||||
page={page}
|
||||
isOwner={isOwner}
|
||||
isEditMode={isEditMode}
|
||||
onToggleEditMode={() => {
|
||||
onToggleEditMode();
|
||||
if (isEditMode) onWidgetRename(null);
|
||||
}}
|
||||
onPageUpdate={onPageUpdate}
|
||||
onMetaUpdated={() => userId && page.slug && invalidateUserPageCache(userId, page.slug)} // Simple invalidation trigger
|
||||
templates={templates}
|
||||
onLoadTemplate={onLoadTemplate}
|
||||
/>
|
||||
<React.Suspense fallback={<div className="h-9 w-24 bg-muted animate-pulse rounded" />}>
|
||||
<PageActions
|
||||
page={page}
|
||||
isOwner={isOwner}
|
||||
isEditMode={isEditMode}
|
||||
onToggleEditMode={() => {
|
||||
onToggleEditMode();
|
||||
if (isEditMode) onWidgetRename(null);
|
||||
}}
|
||||
onPageUpdate={onPageUpdate}
|
||||
onMetaUpdated={() => userId && page.slug && invalidateUserPageCache(userId, page.slug)} // Simple invalidation trigger
|
||||
templates={templates}
|
||||
onLoadTemplate={onLoadTemplate}
|
||||
/>
|
||||
</React.Suspense>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@ -4,12 +4,17 @@ import validator from '@rjsf/validator-ajv8';
|
||||
import { TypeDefinition, fetchTypes } from '../types/db';
|
||||
import { generateSchemaForType, generateUiSchemaForType } from '@/lib/schema-utils';
|
||||
import { customWidgets, customTemplates } from '../types/RJSFTemplates';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useLayout } from '@/contexts/LayoutContext';
|
||||
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 {
|
||||
pageId: string;
|
||||
@ -77,6 +82,37 @@ export const UserPageTypeFields: React.FC<UserPageTypeFieldsProps> = ({
|
||||
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 (typesLoading) {
|
||||
@ -87,7 +123,7 @@ export const UserPageTypeFields: React.FC<UserPageTypeFieldsProps> = ({
|
||||
<div className="space-y-6 mt-8">
|
||||
<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 => {
|
||||
const schema = generateSchemaForType(type.id, allTypes);
|
||||
// Ensure schema is object type for form rendering
|
||||
@ -107,16 +143,39 @@ export const UserPageTypeFields: React.FC<UserPageTypeFieldsProps> = ({
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
{/*
|
||||
Fix for nested button warning:
|
||||
Use an overlay pattern where the Trigger is absolute and sits behind the content.
|
||||
The content is pointer-events-none, except for the interactive buttons which are pointer-events-auto.
|
||||
*/}
|
||||
<AccordionPrimitive.Header className="relative flex items-center group hover:bg-muted/50 transition-colors">
|
||||
<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>
|
||||
</AccordionTrigger>
|
||||
</AccordionPrimitive.Header>
|
||||
<AccordionContent className="px-4 pb-4 pt-2">
|
||||
<Form
|
||||
schema={schema}
|
||||
@ -143,6 +202,41 @@ export const UserPageTypeFields: React.FC<UserPageTypeFieldsProps> = ({
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
@ -67,6 +68,16 @@ interface Page {
|
||||
slug: string;
|
||||
} | null;
|
||||
meta?: any;
|
||||
categories?: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}[];
|
||||
category_paths?: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}[][];
|
||||
}
|
||||
|
||||
interface PageRibbonBarProps {
|
||||
@ -221,6 +232,17 @@ export const PageRibbonBar = ({
|
||||
hasTypeFields
|
||||
}: PageRibbonBarProps) => {
|
||||
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 scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -734,6 +756,12 @@ export const PageRibbonBar = ({
|
||||
{activeTab === 'advanced' && (
|
||||
<>
|
||||
<RibbonGroup label="Developer">
|
||||
<RibbonItemLarge
|
||||
icon={Type}
|
||||
label="Types"
|
||||
onClick={handleOpenTypes}
|
||||
iconColor="text-indigo-600 dark:text-indigo-400"
|
||||
/>
|
||||
<RibbonItemLarge
|
||||
icon={FileJson}
|
||||
label="Dump JSON"
|
||||
@ -753,7 +781,7 @@ export const PageRibbonBar = ({
|
||||
onClose={() => setShowCategoryManager(false)}
|
||||
currentPageId={page.id}
|
||||
currentPageMeta={page.meta}
|
||||
onPageMetaUpdate={async (newMeta) => {
|
||||
onPageMetaUpdate={async (newMeta, newCategories) => {
|
||||
// Use UpdatePageMetaCommand for undo/redo support
|
||||
const pageId = `page-${page.id}`;
|
||||
|
||||
@ -767,12 +795,28 @@ export const PageRibbonBar = ({
|
||||
try {
|
||||
await executeCommand(new UpdatePageMetaCommand(
|
||||
pageId,
|
||||
oldMeta,
|
||||
newMeta,
|
||||
{ meta: oldMeta },
|
||||
{ meta: newMeta },
|
||||
(meta) => {
|
||||
// Update local state for immediate feedback
|
||||
// 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();
|
||||
}
|
||||
));
|
||||
|
||||
@ -17,7 +17,7 @@ interface CategoryManagerProps {
|
||||
onClose: () => void;
|
||||
currentPageId?: string; // If provided, allows linking page to category
|
||||
currentPageMeta?: any;
|
||||
onPageMetaUpdate?: (newMeta: any) => void;
|
||||
onPageMetaUpdate?: (newMeta: any, newCategories?: Category[]) => void;
|
||||
filterByType?: string; // Filter categories by meta.type (e.g., 'layout', 'page', 'email')
|
||||
defaultMetaType?: string; // Default type to set in meta when creating new categories
|
||||
}
|
||||
@ -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 () => {
|
||||
if (!currentPageId || !selectedCategoryId) return;
|
||||
|
||||
@ -158,9 +189,13 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
|
||||
const newIds = [...currentIds, selectedCategoryId];
|
||||
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
|
||||
if (onPageMetaUpdate) {
|
||||
await onPageMetaUpdate(newMeta);
|
||||
await onPageMetaUpdate(newMeta, resolvedCategories);
|
||||
} else {
|
||||
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 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
|
||||
if (onPageMetaUpdate) {
|
||||
await onPageMetaUpdate(newMeta);
|
||||
await onPageMetaUpdate(newMeta, resolvedCategories);
|
||||
} else {
|
||||
await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null });
|
||||
}
|
||||
|
||||
@ -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 { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
|
||||
import SmartLightbox from '@/pages/Post/components/SmartLightbox';
|
||||
import { T } from '@/i18n';
|
||||
import { ImageIcon, Plus, Settings } from 'lucide-react';
|
||||
import { T, translate } from '@/i18n';
|
||||
import { ImageIcon, Plus, Settings, Upload, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { PostMediaItem } from '@/pages/Post/types';
|
||||
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');
|
||||
|
||||
interface GalleryWidgetProps {
|
||||
@ -52,6 +56,10 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
|
||||
const [showPicker, setShowPicker] = 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
|
||||
useEffect(() => {
|
||||
const normalizedProps = normalizePictureIds(propPictureIds);
|
||||
@ -90,7 +98,9 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
|
||||
setMediaItems(postMediaItems);
|
||||
|
||||
// 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]);
|
||||
}
|
||||
} catch (error) {
|
||||
@ -136,19 +146,136 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
|
||||
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
|
||||
if (!pictureIds || pictureIds.length === 0) {
|
||||
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" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
<T>No pictures selected</T>
|
||||
</p>
|
||||
{isEditMode && (
|
||||
<Button onClick={() => setShowPicker(true)} variant="outline">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
<T>Select Pictures</T>
|
||||
</Button>
|
||||
<>
|
||||
<Button onClick={() => setShowPicker(true)} variant="outline">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
<T>Select Pictures</T>
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-4">
|
||||
<T>or drag and drop images here</T>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{showPicker && (
|
||||
<ImagePickerDialog
|
||||
@ -159,12 +286,33 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
if (loading && !isUploading) { // Show normal loading only if not uploading (upload shows its own overlay)
|
||||
return (
|
||||
<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>
|
||||
@ -174,11 +322,26 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
|
||||
|
||||
// Gallery view
|
||||
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 (
|
||||
<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 */}
|
||||
{isEditMode && (
|
||||
<div className="absolute top-2 right-2 z-50">
|
||||
@ -213,6 +376,28 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
|
||||
/>
|
||||
</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 */}
|
||||
{showPicker && (
|
||||
<ImagePickerDialog
|
||||
|
||||
117
packages/ui/src/components/widgets/HtmlGeneratorWizard.tsx
Normal file
117
packages/ui/src/components/widgets/HtmlGeneratorWizard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -8,6 +8,8 @@ import { marked } from 'marked';
|
||||
interface HtmlWidgetProps {
|
||||
src?: string;
|
||||
html?: string;
|
||||
content?: string; // New content prop
|
||||
variables?: string | Record<string, any>; // JSON string or object
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
isEditMode?: boolean;
|
||||
@ -18,14 +20,18 @@ interface HtmlWidgetProps {
|
||||
export const HtmlWidget: React.FC<HtmlWidgetProps> = ({
|
||||
src,
|
||||
html: initialHtml,
|
||||
content: initialContent,
|
||||
variables,
|
||||
className = '',
|
||||
style,
|
||||
isEditMode,
|
||||
onPropsChange,
|
||||
...restProps
|
||||
}) => {
|
||||
const [content, setContent] = useState<string | null>(initialHtml || null);
|
||||
const [processedContent, setProcessedContent] = useState<string | null>(initialHtml || null);
|
||||
// Prioritize content over html
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@ -36,8 +42,8 @@ export const HtmlWidget: React.FC<HtmlWidgetProps> = ({
|
||||
const [currentImageValue, setCurrentImageValue] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialHtml) {
|
||||
setContent(initialHtml);
|
||||
if (sourceHtml) {
|
||||
setContent(sourceHtml);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -58,7 +64,7 @@ export const HtmlWidget: React.FC<HtmlWidgetProps> = ({
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [src, initialHtml]);
|
||||
}, [src, sourceHtml]);
|
||||
|
||||
// Apply substitutions whenever content or props change
|
||||
useEffect(() => {
|
||||
@ -69,8 +75,30 @@ export const HtmlWidget: React.FC<HtmlWidgetProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
// Pre-process props for markdown
|
||||
const finalProps = { ...restProps };
|
||||
// Parse variables if needed
|
||||
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>();
|
||||
|
||||
if (finalProps.widgetDefId) {
|
||||
@ -173,7 +201,7 @@ export const HtmlWidget: React.FC<HtmlWidgetProps> = ({
|
||||
};
|
||||
|
||||
processContent();
|
||||
}, [content, restProps, isEditMode]);
|
||||
}, [content, restProps, variables, isEditMode]);
|
||||
|
||||
|
||||
// Event Delegation for Inline Editing
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import PhotoCard from '@/components/PhotoCard';
|
||||
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
|
||||
import { T } from '@/i18n';
|
||||
import { ImageIcon, Plus } from 'lucide-react';
|
||||
import { T, translate } from '@/i18n';
|
||||
import { ImageIcon, Plus, Upload, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { uploadImage } from '@/lib/uploadUtils';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface PhotoCardWidgetProps {
|
||||
isEditMode?: boolean;
|
||||
@ -30,6 +32,7 @@ interface Picture {
|
||||
interface UserProfile {
|
||||
display_name: string | null;
|
||||
username: string | null;
|
||||
avatar_url?: string | null;
|
||||
}
|
||||
|
||||
const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
||||
@ -48,6 +51,10 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
||||
const [loading, setLoading] = 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
|
||||
useEffect(() => {
|
||||
setPictureId(propPictureId);
|
||||
@ -63,7 +70,31 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
||||
const fetchPictureData = async () => {
|
||||
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;
|
||||
if (!uuidRegex.test(pictureId)) {
|
||||
console.error('Invalid picture ID format. Expected UUID, got:', pictureId);
|
||||
@ -87,7 +118,7 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
||||
// Fetch user profile
|
||||
const { data: profileData } = await supabase
|
||||
.from('profiles')
|
||||
.select('display_name, username')
|
||||
.select('display_name, username, avatar_url')
|
||||
.eq('user_id', pictureData.user_id)
|
||||
.single();
|
||||
|
||||
@ -127,11 +158,125 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (!pictureId) {
|
||||
return (
|
||||
<>
|
||||
// Drag and Drop Handlers
|
||||
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
|
||||
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}
|
||||
>
|
||||
@ -140,64 +285,32 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
||||
{isEditMode ? (
|
||||
<T>No picture selected</T>
|
||||
) : (
|
||||
<T>No picture selected</T>
|
||||
<T>Empty Photo Card</T>
|
||||
)}
|
||||
</p>
|
||||
{isEditMode && (
|
||||
<Button variant="outline" size="sm" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOpenPicker();
|
||||
}}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
<T>Select Picture</T>
|
||||
</Button>
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOpenPicker();
|
||||
}}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
{isEditMode && (
|
||||
<ImagePickerDialog
|
||||
isOpen={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onSelect={handleSelectPicture}
|
||||
currentValue={pictureId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const authorName = userProfile?.display_name || userProfile?.username || `User ${picture.user_id.slice(0, 8)}`;
|
||||
const isExternal = picture.user_id === 'external';
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-card border rounded-lg p-8 text-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 (!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">
|
||||
<div className="w-full relative">
|
||||
<PhotoCard
|
||||
pictureId={picture.id}
|
||||
image={picture.image_url}
|
||||
@ -209,15 +322,70 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
||||
isLiked={isLiked}
|
||||
description={picture.description}
|
||||
onClick={(id) => {
|
||||
// Navigate to post page
|
||||
window.location.href = `/post/${id}`;
|
||||
if (isExternal) {
|
||||
window.open(picture.image_url, '_blank');
|
||||
} else {
|
||||
// Navigate to post page
|
||||
window.location.href = `/post/${id}`;
|
||||
}
|
||||
}}
|
||||
onLike={handleLike}
|
||||
variant={contentDisplay === 'overlay' || contentDisplay === 'overlay-always' ? 'grid' : 'feed'}
|
||||
overlayMode={contentDisplay === 'overlay-always' ? 'always' : 'hover'}
|
||||
showHeader={showHeader}
|
||||
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>
|
||||
|
||||
{isEditMode && (
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { GenericCanvas } from '@/components/hmi/GenericCanvas';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { T } from '@/i18n';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export interface TabDefinition {
|
||||
id: string;
|
||||
@ -23,6 +22,11 @@ interface TabsWidgetProps {
|
||||
contentClassName?: string; // Content area classes
|
||||
isEditMode?: boolean;
|
||||
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> = ({
|
||||
@ -35,7 +39,12 @@ const TabsWidget: React.FC<TabsWidgetProps> = ({
|
||||
tabBarClassName = '',
|
||||
contentClassName = '',
|
||||
isEditMode = false,
|
||||
onPropsChange
|
||||
onPropsChange,
|
||||
selectedWidgetId,
|
||||
onSelectWidget,
|
||||
onSelectContainer,
|
||||
editingWidgetId,
|
||||
onEditWidget,
|
||||
}) => {
|
||||
const [currentTabId, setCurrentTabId] = useState<string | undefined>(activeTabId);
|
||||
|
||||
@ -138,6 +147,11 @@ const TabsWidget: React.FC<TabsWidgetProps> = ({
|
||||
isEditMode={isEditMode}
|
||||
showControls={false} // Tabs usually hide nested canvas controls to look cleaner
|
||||
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">
|
||||
|
||||
@ -8,12 +8,13 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { WidgetDefinition } from '@/lib/widgetRegistry';
|
||||
import { ImagePickerDialog } from './ImagePickerDialog';
|
||||
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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import MarkdownEditor from '@/components/MarkdownEditorEx';
|
||||
import { TailwindClassPicker } from './TailwindClassPicker';
|
||||
import { TabsPropertyEditor } from './TabsPropertyEditor';
|
||||
import { HtmlGeneratorWizard } from './HtmlGeneratorWizard';
|
||||
|
||||
export interface WidgetPropertiesFormProps {
|
||||
widgetDefinition: WidgetDefinition;
|
||||
@ -24,6 +25,8 @@ export interface WidgetPropertiesFormProps {
|
||||
onSave?: () => void;
|
||||
onCancel?: () => void;
|
||||
showActions?: boolean;
|
||||
contextVariables?: Record<string, any>;
|
||||
pageContext?: any;
|
||||
}
|
||||
|
||||
export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
|
||||
@ -34,7 +37,9 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
|
||||
onSettingsChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
showActions = false
|
||||
showActions = false,
|
||||
contextVariables = {},
|
||||
pageContext
|
||||
}) => {
|
||||
// Local state for immediate feedback in the form, though we also prop up changes
|
||||
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 [markdownEditorOpen, setMarkdownEditorOpen] = useState(false);
|
||||
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)
|
||||
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">
|
||||
<T>{config.label}</T>
|
||||
</Label>
|
||||
<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 className="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-blue-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-950/20"
|
||||
onClick={() => {
|
||||
setActiveHtmlField(key);
|
||||
setHtmlWizardOpen(true);
|
||||
}}
|
||||
title="Generate HTML with AI"
|
||||
>
|
||||
<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>
|
||||
<Textarea
|
||||
id={key}
|
||||
@ -471,6 +494,28 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
|
||||
</div>
|
||||
</DialogContent>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -11,13 +11,15 @@ interface WidgetPropertyPanelProps {
|
||||
selectedWidgetId: string | null;
|
||||
onWidgetRenamed?: (newId: string) => void;
|
||||
className?: string;
|
||||
contextVariables?: Record<string, any>;
|
||||
}
|
||||
|
||||
export const WidgetPropertyPanel: React.FC<WidgetPropertyPanelProps> = ({
|
||||
pageId,
|
||||
selectedWidgetId,
|
||||
onWidgetRenamed,
|
||||
className = ''
|
||||
className = '',
|
||||
contextVariables = {}
|
||||
}) => {
|
||||
const { loadedPages, updateWidgetProps, renameWidget } = useLayout();
|
||||
const page = loadedPages.get(pageId);
|
||||
@ -86,15 +88,10 @@ export const WidgetPropertyPanel: React.FC<WidgetPropertyPanelProps> = ({
|
||||
onSettingsChange={handleSettingsChange}
|
||||
widgetInstanceId={widget.id}
|
||||
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);
|
||||
}}
|
||||
contextVariables={contextVariables}
|
||||
pageContext={page}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-slate-500 text-xs py-8">
|
||||
|
||||
@ -67,13 +67,15 @@ const WidgetSettingsManagerComponent: React.FC<WidgetSettingsManagerProps> = ({
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<WidgetPropertiesForm
|
||||
widgetDefinition={widgetDefinition}
|
||||
currentProps={settings}
|
||||
onSettingsChange={setSettings}
|
||||
widgetInstanceId={widgetInstanceId}
|
||||
onRename={onRename}
|
||||
/>
|
||||
<div className="max-h-[70vh] overflow-y-auto scrollbar-custom py-2">
|
||||
<WidgetPropertiesForm
|
||||
widgetDefinition={widgetDefinition}
|
||||
currentProps={settings}
|
||||
onSettingsChange={setSettings}
|
||||
widgetInstanceId={widgetInstanceId}
|
||||
onRename={onRename}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
|
||||
@ -4,6 +4,7 @@ import { toast } from 'sonner';
|
||||
import { useWidgetLoader } from './useWidgetLoader.tsx';
|
||||
import { useLayouts } from './useLayouts';
|
||||
import { Database } from '@/integrations/supabase/types';
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
|
||||
type Layout = Database['public']['Tables']['layouts']['Row'];
|
||||
type LayoutVisibility = Database['public']['Enums']['layout_visibility'];
|
||||
@ -390,11 +391,18 @@ export function usePlaygroundLogic() {
|
||||
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL;
|
||||
|
||||
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}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
html,
|
||||
subject: `[Test] ${layout.name} - ${new Date().toLocaleTimeString()}`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,5 @@
|
||||
import { supabase as defaultSupabase } from "@/integrations/supabase/client";
|
||||
import { z } from "zod";
|
||||
import { UserProfile, PostMediaItem } from "@/pages/Post/types";
|
||||
import { MediaType, MediaItem } from "@/types";
|
||||
import { SupabaseClient } from "@supabase/supabase-js";
|
||||
@ -1210,3 +1211,109 @@ export const getLayouts = async (filters?: { type?: string, visibility?: string,
|
||||
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;
|
||||
};
|
||||
|
||||
@ -601,9 +601,73 @@ Optimized: "A fluffy tabby cat sitting gracefully on a vintage wooden chair, sof
|
||||
}, null, apiKey);
|
||||
};
|
||||
|
||||
// ====================================================================
|
||||
// TOOL SYSTEM - LLM with Function Calling
|
||||
// ====================================================================
|
||||
// Generate HTML snippet with Tailwind CSS
|
||||
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
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
ListFilter,
|
||||
Layout,
|
||||
FileText,
|
||||
Code,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Import your components
|
||||
@ -15,11 +16,51 @@ import LayoutContainerWidget from '@/components/widgets/LayoutContainerWidget';
|
||||
import MarkdownTextWidget from '@/components/widgets/MarkdownTextWidget';
|
||||
import GalleryWidget from '@/components/widgets/GalleryWidget';
|
||||
import TabsWidget from '@/components/widgets/TabsWidget';
|
||||
import { HtmlWidget } from '@/components/widgets/HtmlWidget';
|
||||
|
||||
export function registerAllWidgets() {
|
||||
// Clear existing registrations (useful for HMR)
|
||||
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
|
||||
widgetRegistry.register({
|
||||
component: PhotoGrid,
|
||||
@ -130,6 +171,7 @@ export function registerAllWidgets() {
|
||||
orientation: 'horizontal',
|
||||
tabBarPosition: 'top'
|
||||
},
|
||||
|
||||
configSchema: {
|
||||
tabs: {
|
||||
type: 'tabs-editor',
|
||||
@ -165,6 +207,14 @@ export function registerAllWidgets() {
|
||||
minSize: { width: 400, height: 300 },
|
||||
resizable: true,
|
||||
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 },
|
||||
resizable: true,
|
||||
tags: ['layout', 'container', 'nested', 'canvas']
|
||||
},
|
||||
getNestedLayouts: (props) => {
|
||||
if (props.nestedPageId) {
|
||||
return [{
|
||||
id: 'nested-container',
|
||||
label: props.nestedPageName || 'Nested Container',
|
||||
layoutId: props.nestedPageId
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ export interface WidgetDefinition {
|
||||
component: React.ComponentType<any>;
|
||||
metadata: WidgetMetadata;
|
||||
previewComponent?: React.ComponentType<any>;
|
||||
getNestedLayouts?: (props: Record<string, any>) => { id: string; label: string; layoutId: string }[];
|
||||
}
|
||||
|
||||
class WidgetRegistry {
|
||||
|
||||
@ -9,6 +9,7 @@ import { LayoutGrid, GalleryVerticalEnd, TrendingUp, Clock, List, FolderTree } f
|
||||
import { ListLayout } from "@/components/ListLayout";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import type { FeedSortOption } from "@/hooks/useFeedData";
|
||||
import { SEO } from "@/components/SEO";
|
||||
|
||||
const Index = () => {
|
||||
const { slug } = useParams<{ slug?: string }>();
|
||||
@ -41,6 +42,7 @@ const Index = () => {
|
||||
|
||||
return (
|
||||
<div className="bg-background">
|
||||
<SEO title="PolyMech Home" />
|
||||
<div className="md:py-2">
|
||||
<div>
|
||||
<div>
|
||||
|
||||
@ -19,6 +19,7 @@ import { usePostActions } from "./Post/usePostActions";
|
||||
import { exportMarkdown, downloadMediaItem } from "./Post/PostActions";
|
||||
import { DeleteDialog } from "./Post/components/DeleteDialogs";
|
||||
import { CategoryManager } from "@/components/widgets/CategoryManager";
|
||||
import { SEO } from "@/components/SEO";
|
||||
|
||||
|
||||
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";
|
||||
|
||||
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"}>
|
||||
|
||||
{viewMode === 'article' ? (
|
||||
|
||||
@ -41,12 +41,6 @@ export const CompactRenderer: React.FC<PostRendererProps> = (props) => {
|
||||
const effectiveType = mediaItem.type || detectMediaType(mediaItem.image_url);
|
||||
const isVideo = isVideoType(normalizeMediaType(effectiveType));
|
||||
|
||||
console.log('mediaItem', mediaItem);
|
||||
console.log('isVideo', isVideo);
|
||||
console.log('effectiveType', effectiveType);
|
||||
console.log('mediaItems', mediaItems);
|
||||
|
||||
|
||||
return (
|
||||
<div className={props.className || "h-full"}>
|
||||
{/* Mobile Header - Controls and Info at Top */}
|
||||
|
||||
@ -5,11 +5,13 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { T, translate } from "@/i18n";
|
||||
import { Database } from "@/integrations/supabase/types";
|
||||
|
||||
import { GenericCanvas } from "@/components/hmi/GenericCanvas";
|
||||
import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
|
||||
import MarkdownRenderer from "@/components/MarkdownRenderer";
|
||||
|
||||
import { Sidebar } from "@/components/sidebar/Sidebar";
|
||||
import { TableOfContents } from "@/components/sidebar/TableOfContents";
|
||||
import { MobileTOC } from "@/components/sidebar/MobileTOC";
|
||||
@ -18,8 +20,7 @@ import { useLayout } from "@/contexts/LayoutContext";
|
||||
import { fetchUserPage } from "@/lib/db";
|
||||
import { UserPageTopBar } from "@/components/user-page/UserPageTopBar";
|
||||
import { UserPageDetails } from "@/components/user-page/UserPageDetails";
|
||||
import { Database } from "@/integrations/supabase/types";
|
||||
|
||||
import { SEO } from "@/components/SEO";
|
||||
|
||||
const UserPageEdit = lazy(() => import("./UserPageEdit"));
|
||||
|
||||
@ -183,7 +184,7 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
// element.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
@ -249,6 +250,15 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
{!embedded && (
|
||||
<UserPageTopBar
|
||||
@ -359,6 +369,12 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
|
||||
initialLayout={page.content}
|
||||
selectedWidgetId={null}
|
||||
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>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, lazy, Suspense } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -7,9 +7,14 @@ import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { T, translate } from "@/i18n";
|
||||
import { GenericCanvas } from "@/components/hmi/GenericCanvas";
|
||||
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 { Sidebar } from "@/components/sidebar/Sidebar";
|
||||
import { TableOfContents } from "@/components/sidebar/TableOfContents";
|
||||
@ -20,10 +25,6 @@ import { UserPageDetails } from "@/components/user-page/UserPageDetails";
|
||||
import { useLayouts } from "@/hooks/useLayouts";
|
||||
import { Database } from "@/integrations/supabase/types";
|
||||
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'];
|
||||
|
||||
@ -89,6 +90,7 @@ const UserPageEdit = ({
|
||||
}: UserPageEditProps) => {
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [selectedWidgetId, setSelectedWidgetId] = useState<string | null>(null);
|
||||
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
|
||||
const [showHierarchy, setShowHierarchy] = useState(false);
|
||||
|
||||
// Auto-collapse sidebar if no TOC headings
|
||||
@ -120,6 +122,23 @@ const UserPageEdit = ({
|
||||
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(null);
|
||||
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(() => {
|
||||
if (isOwner) {
|
||||
loadTemplates();
|
||||
@ -140,7 +159,8 @@ const UserPageEdit = ({
|
||||
|
||||
const handleAddWidget = async (widgetId: string) => {
|
||||
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
|
||||
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 (
|
||||
<>
|
||||
<PageRibbonBar
|
||||
@ -512,18 +537,23 @@ const UserPageEdit = ({
|
||||
/>
|
||||
)}
|
||||
{showHierarchy && currentLayout && (
|
||||
<HierarchyTree
|
||||
containers={currentLayout.containers}
|
||||
selectedWidgetId={selectedWidgetId}
|
||||
selectedContainerId={selectedContainerId}
|
||||
onSelectWidget={(id) => {
|
||||
setSelectedWidgetId(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);
|
||||
if (widget) setEditingWidgetId(id);
|
||||
}}
|
||||
onSelectContainer={setSelectedContainerId}
|
||||
/>
|
||||
<Suspense fallback={<div className="p-4 text-xs text-muted-foreground">Loading hierarchy...</div>}>
|
||||
<HierarchyTree
|
||||
containers={currentLayout.containers}
|
||||
selectedWidgetId={selectedWidgetId}
|
||||
selectedContainerId={selectedContainerId}
|
||||
onSelectWidget={(id) => {
|
||||
setSelectedWidgetId(id);
|
||||
setSelectedPageId(`page-${page.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);
|
||||
if (widget) setEditingWidgetId(id);
|
||||
}}
|
||||
onSelectContainer={setSelectedContainerId}
|
||||
onSettingsClick={handleOpenSettings}
|
||||
layoutId={currentLayout.id}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@ -568,12 +598,23 @@ const UserPageEdit = ({
|
||||
showControls={true}
|
||||
initialLayout={page.content}
|
||||
selectedWidgetId={selectedWidgetId}
|
||||
onSelectWidget={setSelectedWidgetId}
|
||||
onSelectWidget={(id, pageId) => {
|
||||
setSelectedWidgetId(id);
|
||||
setSelectedPageId(pageId || `page-${page.id}`);
|
||||
}}
|
||||
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}
|
||||
onEditWidget={handleEditWidget}
|
||||
newlyAddedWidgetId={newlyAddedWidgetId}
|
||||
contextVariables={contextVariables}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -611,20 +652,25 @@ const UserPageEdit = ({
|
||||
<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">
|
||||
{selectedWidgetId ? (
|
||||
<WidgetPropertyPanel
|
||||
pageId={`page-${page.id}`}
|
||||
selectedWidgetId={selectedWidgetId}
|
||||
onWidgetRenamed={setSelectedWidgetId}
|
||||
/>
|
||||
<Suspense fallback={<div className="h-full flex items-center justify-center text-muted-foreground">Loading settings...</div>}>
|
||||
<WidgetPropertyPanel
|
||||
pageId={selectedPageId || `page-${page.id}`}
|
||||
selectedWidgetId={selectedWidgetId}
|
||||
onWidgetRenamed={setSelectedWidgetId}
|
||||
contextVariables={contextVariables}
|
||||
/>
|
||||
</Suspense>
|
||||
) : showTypeFields ? (
|
||||
<div className="h-full overflow-y-auto p-4">
|
||||
<UserPageTypeFields
|
||||
pageId={page.id}
|
||||
pageMeta={page.meta}
|
||||
assignedTypes={assignedTypes}
|
||||
isEditMode={true}
|
||||
onMetaUpdate={(newMeta) => onPageUpdate({ ...page, meta: newMeta })}
|
||||
/>
|
||||
<Suspense fallback={<div className="h-full flex items-center justify-center text-muted-foreground">Loading types...</div>}>
|
||||
<UserPageTypeFields
|
||||
pageId={page.id}
|
||||
pageMeta={page.meta}
|
||||
assignedTypes={assignedTypes}
|
||||
isEditMode={true}
|
||||
onMetaUpdate={(newMeta) => onPageUpdate({ ...page, meta: newMeta })}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@ -634,11 +680,37 @@ const UserPageEdit = ({
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
<SaveTemplateDialog
|
||||
isOpen={showSaveTemplateDialog}
|
||||
onClose={() => setShowSaveTemplateDialog(false)}
|
||||
onSave={onSaveTemplate}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<SaveTemplateDialog
|
||||
isOpen={showSaveTemplateDialog}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -13,6 +13,7 @@ import { T, translate } from "@/i18n";
|
||||
import { normalizeMediaType } from "@/lib/mediaRegistry";
|
||||
import { useFeedData } from "@/hooks/useFeedData";
|
||||
import * as db from "@/lib/db";
|
||||
import { SEO } from "@/components/SEO";
|
||||
|
||||
interface UserProfile {
|
||||
id: string;
|
||||
@ -227,6 +228,11 @@ const UserProfile = () => {
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
|
||||
@ -3,14 +3,13 @@
|
||||
* 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 { MediaPlayer, MediaProvider, type MediaPlayerInstance } from '@vidstack/react';
|
||||
import { defaultLayoutIcons, DefaultVideoLayout } from '@vidstack/react/player/layouts/default';
|
||||
import type { MediaPlayerInstance } from '@vidstack/react';
|
||||
import { defaultLayoutIcons } 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';
|
||||
// Lazy load Vidstack implementation
|
||||
const VidstackPlayer = lazy(() => import('./VidstackPlayerImpl').then(module => ({ default: module.VidstackPlayerImpl })));
|
||||
|
||||
interface VideoPlayerProps {
|
||||
video: VideoItem;
|
||||
@ -52,35 +51,36 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
return (
|
||||
<div className={`w-full h-full bg-black flex justify-center items-center ${className}`}>
|
||||
{/* Video Player - Using Vidstack for HLS support */}
|
||||
<MediaPlayer
|
||||
ref={player}
|
||||
title={video.desc || 'Video'}
|
||||
src={
|
||||
video.video.playAddr.includes('/api/videos/')
|
||||
? { src: video.video.playAddr, type: 'video/mp4' }
|
||||
: video.video.playAddr
|
||||
}
|
||||
poster={video.video.cover}
|
||||
playsInline
|
||||
loop
|
||||
muted={false}
|
||||
autoPlay={true}
|
||||
load={isActive ? "eager" : "idle"}
|
||||
posterLoad="eager"
|
||||
crossOrigin="anonymous"
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
'--video-brand': '#ff0050',
|
||||
'--media-object-fit': 'contain',
|
||||
'--media-object-position': 'center'
|
||||
} as any}
|
||||
>
|
||||
<MediaProvider />
|
||||
<DefaultVideoLayout
|
||||
icons={defaultLayoutIcons}
|
||||
noScrubGesture
|
||||
{/* Video Player - Using Vidstack for HLS support */}
|
||||
<Suspense fallback={<div className="w-full h-full bg-black animate-pulse" />}>
|
||||
<VidstackPlayer
|
||||
ref={player}
|
||||
title={video.desc || 'Video'}
|
||||
src={
|
||||
video.video.playAddr.includes('/api/videos/')
|
||||
? { src: video.video.playAddr, type: 'video/mp4' }
|
||||
: video.video.playAddr
|
||||
}
|
||||
poster={video.video.cover}
|
||||
playsInline
|
||||
loop
|
||||
muted={false}
|
||||
autoPlay={true}
|
||||
load={isActive ? "eager" : "idle"}
|
||||
posterLoad="eager"
|
||||
crossOrigin="anonymous"
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
'--video-brand': '#ff0050',
|
||||
'--media-object-fit': 'contain',
|
||||
'--media-object-position': 'center'
|
||||
} as any}
|
||||
layoutProps={{
|
||||
icons: defaultLayoutIcons,
|
||||
noScrubGesture: true
|
||||
}}
|
||||
/>
|
||||
</MediaPlayer>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
31
packages/ui/src/player/components/VidstackPlayerImpl.tsx
Normal file
31
packages/ui/src/player/components/VidstackPlayerImpl.tsx
Normal 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';
|
||||
Loading…
Reference in New Issue
Block a user