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 UserCollections from "./pages/UserCollections";
|
||||||
import Collections from "./pages/Collections";
|
import Collections from "./pages/Collections";
|
||||||
import NewCollection from "./pages/NewCollection";
|
import NewCollection from "./pages/NewCollection";
|
||||||
import UserPage from "./pages/UserPage";
|
const UserPage = React.lazy(() => import("./pages/UserPage"));
|
||||||
import NewPage from "./pages/NewPage";
|
import NewPage from "./pages/NewPage";
|
||||||
import TagPage from "./pages/TagPage";
|
import TagPage from "./pages/TagPage";
|
||||||
import SearchResults from "./pages/SearchResults";
|
import SearchResults from "./pages/SearchResults";
|
||||||
@ -35,6 +35,8 @@ import Wizard from "./pages/Wizard";
|
|||||||
import NewPost from "./pages/NewPost";
|
import NewPost from "./pages/NewPost";
|
||||||
|
|
||||||
import Organizations from "./pages/Organizations";
|
import Organizations from "./pages/Organizations";
|
||||||
|
import LogsPage from "./components/logging/LogsPage";
|
||||||
|
|
||||||
const ProviderSettings = React.lazy(() => import("./pages/ProviderSettings"));
|
const ProviderSettings = React.lazy(() => import("./pages/ProviderSettings"));
|
||||||
const PlaygroundEditor = React.lazy(() => import("./pages/PlaygroundEditor"));
|
const PlaygroundEditor = React.lazy(() => import("./pages/PlaygroundEditor"));
|
||||||
const PlaygroundEditorLLM = React.lazy(() => import("./pages/PlaygroundEditorLLM"));
|
const PlaygroundEditorLLM = React.lazy(() => import("./pages/PlaygroundEditorLLM"));
|
||||||
@ -49,7 +51,9 @@ const VideoGenPlayground = React.lazy(() => import("./pages/VideoGenPlayground")
|
|||||||
const PlaygroundCanvas = React.lazy(() => import("./pages/PlaygroundCanvas"));
|
const PlaygroundCanvas = React.lazy(() => import("./pages/PlaygroundCanvas"));
|
||||||
const TypesPlayground = React.lazy(() => import("./components/types/TypesPlayground"));
|
const TypesPlayground = React.lazy(() => import("./components/types/TypesPlayground"));
|
||||||
const Tetris = React.lazy(() => import("./apps/tetris/Tetris"));
|
const Tetris = React.lazy(() => import("./apps/tetris/Tetris"));
|
||||||
import LogsPage from "./components/logging/LogsPage";
|
const I18nPlayground = React.lazy(() => import("./components/playground/I18nPlayground"));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@ -79,7 +83,7 @@ const AppWrapper = () => {
|
|||||||
<Route path="/user/:userId" element={<UserProfile />} />
|
<Route path="/user/:userId" element={<UserProfile />} />
|
||||||
<Route path="/user/:userId/collections" element={<UserCollections />} />
|
<Route path="/user/:userId/collections" element={<UserCollections />} />
|
||||||
<Route path="/user/:userId/pages/new" element={<NewPage />} />
|
<Route path="/user/:userId/pages/new" element={<NewPage />} />
|
||||||
<Route path="/user/:username/pages/:slug" element={<UserPage />} />
|
<Route path="/user/:username/pages/:slug" element={<React.Suspense fallback={<div>Loading...</div>}><UserPage /></React.Suspense>} />
|
||||||
<Route path="/collections/new" element={<NewCollection />} />
|
<Route path="/collections/new" element={<NewCollection />} />
|
||||||
<Route path="/collections/:userId/:slug" element={<Collections />} />
|
<Route path="/collections/:userId/:slug" element={<Collections />} />
|
||||||
<Route path="/tags/:tag" element={<TagPage />} />
|
<Route path="/tags/:tag" element={<TagPage />} />
|
||||||
@ -112,7 +116,7 @@ const AppWrapper = () => {
|
|||||||
<Route path="/org/:orgSlug/user/:userId" element={<UserProfile />} />
|
<Route path="/org/:orgSlug/user/:userId" element={<UserProfile />} />
|
||||||
<Route path="/org/:orgSlug/user/:userId/collections" element={<UserCollections />} />
|
<Route path="/org/:orgSlug/user/:userId/collections" element={<UserCollections />} />
|
||||||
<Route path="/org/:orgSlug/user/:userId/pages/new" element={<NewPage />} />
|
<Route path="/org/:orgSlug/user/:userId/pages/new" element={<NewPage />} />
|
||||||
<Route path="/org/:orgSlug/user/:username/pages/:slug" element={<UserPage />} />
|
<Route path="/org/:orgSlug/user/:username/pages/:slug" element={<React.Suspense fallback={<div>Loading...</div>}><UserPage /></React.Suspense>} />
|
||||||
<Route path="/org/:orgSlug/collections/new" element={<NewCollection />} />
|
<Route path="/org/:orgSlug/collections/new" element={<NewCollection />} />
|
||||||
<Route path="/org/:orgSlug/collections/:userId/:slug" element={<Collections />} />
|
<Route path="/org/:orgSlug/collections/:userId/:slug" element={<Collections />} />
|
||||||
<Route path="/org/:orgSlug/tags/:tag" element={<TagPage />} />
|
<Route path="/org/:orgSlug/tags/:tag" element={<TagPage />} />
|
||||||
@ -138,7 +142,9 @@ const AppWrapper = () => {
|
|||||||
<Route path="/playground/image-editor" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundImageEditor /></React.Suspense>} />
|
<Route path="/playground/image-editor" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundImageEditor /></React.Suspense>} />
|
||||||
<Route path="/playground/video-generator" element={<React.Suspense fallback={<div>Loading...</div>}><VideoGenPlayground /></React.Suspense>} />
|
<Route path="/playground/video-generator" element={<React.Suspense fallback={<div>Loading...</div>}><VideoGenPlayground /></React.Suspense>} />
|
||||||
<Route path="/playground/canvas" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundCanvas /></React.Suspense>} />
|
<Route path="/playground/canvas" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundCanvas /></React.Suspense>} />
|
||||||
<Route path="/playground/types" element={<React.Suspense fallback={<div>Loading...</div>}><TypesPlayground /></React.Suspense>} />
|
<Route path="/types-editor" element={<React.Suspense fallback={<div>Loading...</div>}><TypesPlayground /></React.Suspense>} />
|
||||||
|
<Route path="/playground/i18n" element={<React.Suspense fallback={<div>Loading...</div>}><I18nPlayground /></React.Suspense>} />
|
||||||
|
<Route path="/org/:orgSlug/types-editor" element={<React.Suspense fallback={<div>Loading...</div>}><TypesPlayground /></React.Suspense>} />
|
||||||
<Route path="/test-cache/:id" element={<CacheTest />} />
|
<Route path="/test-cache/:id" element={<CacheTest />} />
|
||||||
|
|
||||||
{/* Logs */}
|
{/* Logs */}
|
||||||
@ -167,44 +173,53 @@ import { StreamInvalidator } from "@/components/StreamInvalidator";
|
|||||||
|
|
||||||
// ... (imports)
|
// ... (imports)
|
||||||
|
|
||||||
|
import { ActionProvider } from "@/actions/ActionProvider";
|
||||||
|
import { HelmetProvider } from 'react-helmet-async';
|
||||||
|
|
||||||
|
// ... previous imports ...
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
initFormatDetection();
|
initFormatDetection();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SWRConfig value={{ provider: () => new Map() }}>
|
<HelmetProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<SWRConfig value={{ provider: () => new Map() }}>
|
||||||
<AuthProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
<LogProvider>
|
<AuthProvider>
|
||||||
<PostNavigationProvider>
|
<LogProvider>
|
||||||
<MediaRefreshProvider>
|
<PostNavigationProvider>
|
||||||
<LayoutProvider>
|
<MediaRefreshProvider>
|
||||||
<TooltipProvider>
|
<LayoutProvider>
|
||||||
<Toaster />
|
<TooltipProvider>
|
||||||
<Sonner />
|
<Toaster />
|
||||||
<BrowserRouter>
|
<Sonner />
|
||||||
<OrganizationProvider>
|
<ActionProvider>
|
||||||
<ProfilesProvider>
|
<BrowserRouter>
|
||||||
<WebSocketProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
|
<OrganizationProvider>
|
||||||
<StreamProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
|
<ProfilesProvider>
|
||||||
<StreamInvalidator />
|
<WebSocketProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
|
||||||
<FeedCacheProvider>
|
<StreamProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
|
||||||
<AppWrapper />
|
<StreamInvalidator />
|
||||||
</FeedCacheProvider>
|
<FeedCacheProvider>
|
||||||
</StreamProvider>
|
<AppWrapper />
|
||||||
</WebSocketProvider>
|
</FeedCacheProvider>
|
||||||
</ProfilesProvider>
|
</StreamProvider>
|
||||||
</OrganizationProvider>
|
</WebSocketProvider>
|
||||||
</BrowserRouter>
|
</ProfilesProvider>
|
||||||
</TooltipProvider>
|
</OrganizationProvider>
|
||||||
</LayoutProvider>
|
</BrowserRouter>
|
||||||
</MediaRefreshProvider>
|
</ActionProvider>
|
||||||
</PostNavigationProvider>
|
</TooltipProvider>
|
||||||
</LogProvider>
|
</LayoutProvider>
|
||||||
</AuthProvider>
|
</MediaRefreshProvider>
|
||||||
</QueryClientProvider>
|
</PostNavigationProvider>
|
||||||
</SWRConfig>
|
</LogProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</SWRConfig>
|
||||||
|
</HelmetProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -15,8 +15,5 @@ export const ActionProvider: React.FC<ActionProviderProps> = ({ children }) => {
|
|||||||
registerAction(action);
|
registerAction(action);
|
||||||
});
|
});
|
||||||
}, [registerAction]);
|
}, [registerAction]);
|
||||||
|
|
||||||
// TODO: Add keyboard shortcut listener here
|
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -12,7 +12,7 @@ export const useActionStore = create<ActionStore>((set, get) => ({
|
|||||||
// Prevent duplicate registration if not needed, or overwrite
|
// Prevent duplicate registration if not needed, or overwrite
|
||||||
// For now, we overwrite based on ID
|
// For now, we overwrite based on ID
|
||||||
if (state.actions[action.id]) {
|
if (state.actions[action.id]) {
|
||||||
console.warn(`Action with id ${action.id} already exists. Overwriting.`);
|
// console.warn(`Action with id ${action.id} already exists. Overwriting.`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
actions: {
|
actions: {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Eye, EyeOff, Edit3, Trash2, Share2, Link as LinkIcon, FileText, Download, FolderTree, FileJson, LayoutTemplate } from "lucide-react";
|
import { Eye, EyeOff, Edit3, Trash2, Share2, Link as LinkIcon, FileText, Download, FolderTree, FileJson, LayoutTemplate } from "lucide-react";
|
||||||
@ -12,7 +12,8 @@ import {
|
|||||||
DropdownMenuGroup
|
DropdownMenuGroup
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { T, translate } from "@/i18n";
|
import { T, translate } from "@/i18n";
|
||||||
import { CategoryManager } from "./widgets/CategoryManager";
|
// import { CategoryManager } from "./widgets/CategoryManager"; // Lazy loaded below
|
||||||
|
const CategoryManager = React.lazy(() => import("./widgets/CategoryManager").then(module => ({ default: module.CategoryManager })));
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Database } from '@/integrations/supabase/types';
|
import { Database } from '@/integrations/supabase/types';
|
||||||
|
|
||||||
@ -467,15 +468,19 @@ draft: ${!page.visible}
|
|||||||
{showLabels && <span className="ml-2 hidden md:inline"><T>Categories</T></span>}
|
{showLabels && <span className="ml-2 hidden md:inline"><T>Categories</T></span>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<CategoryManager
|
<React.Suspense fallback={null}>
|
||||||
isOpen={showCategoryManager}
|
{showCategoryManager && (
|
||||||
onClose={() => setShowCategoryManager(false)}
|
<CategoryManager
|
||||||
currentPageId={page.id}
|
isOpen={showCategoryManager}
|
||||||
currentPageMeta={page.meta}
|
onClose={() => setShowCategoryManager(false)}
|
||||||
onPageMetaUpdate={handleMetaUpdate}
|
currentPageId={page.id}
|
||||||
filterByType="pages"
|
currentPageMeta={page.meta}
|
||||||
defaultMetaType="pages"
|
onPageMetaUpdate={handleMetaUpdate}
|
||||||
/>
|
filterByType="pages"
|
||||||
|
defaultMetaType="pages"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Suspense>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Heart, Download, Share2, User, MessageCircle, Edit3, Trash2, Maximize, Layers } from "lucide-react";
|
import { Heart, Download, Share2, User, MessageCircle, Edit3, Trash2, Maximize, Layers, ExternalLink } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
@ -41,6 +41,7 @@ interface PhotoCardProps {
|
|||||||
variant?: 'grid' | 'feed';
|
variant?: 'grid' | 'feed';
|
||||||
apiUrl?: string;
|
apiUrl?: string;
|
||||||
versionCount?: number;
|
versionCount?: number;
|
||||||
|
isExternal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PhotoCard = ({
|
const PhotoCard = ({
|
||||||
@ -66,7 +67,8 @@ const PhotoCard = ({
|
|||||||
responsive,
|
responsive,
|
||||||
variant = 'grid',
|
variant = 'grid',
|
||||||
apiUrl,
|
apiUrl,
|
||||||
versionCount
|
versionCount,
|
||||||
|
isExternal = false
|
||||||
}: PhotoCardProps) => {
|
}: PhotoCardProps) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -104,6 +106,8 @@ const PhotoCard = ({
|
|||||||
const handleLike = async (e: React.MouseEvent) => {
|
const handleLike = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (isExternal) return;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
toast.error(translate('Please sign in to like pictures'));
|
toast.error(translate('Please sign in to like pictures'));
|
||||||
return;
|
return;
|
||||||
@ -141,6 +145,8 @@ const PhotoCard = ({
|
|||||||
const handleDelete = async (e: React.MouseEvent) => {
|
const handleDelete = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (isExternal) return;
|
||||||
|
|
||||||
if (!user || !isOwner) {
|
if (!user || !isOwner) {
|
||||||
toast.error(translate('You can only delete your own images'));
|
toast.error(translate('You can only delete your own images'));
|
||||||
return;
|
return;
|
||||||
@ -297,6 +303,11 @@ const PhotoCard = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePublish = async (option: 'overwrite' | 'new', imageUrl: string, newTitle: string, description?: string) => {
|
const handlePublish = async (option: 'overwrite' | 'new', imageUrl: string, newTitle: string, description?: string) => {
|
||||||
|
if (isExternal) {
|
||||||
|
toast.error(translate('Cannot publish external images'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
toast.error(translate('Please sign in to publish images'));
|
toast.error(translate('Please sign in to publish images'));
|
||||||
return;
|
return;
|
||||||
@ -409,6 +420,13 @@ const PhotoCard = ({
|
|||||||
data={responsive}
|
data={responsive}
|
||||||
apiUrl={apiUrl}
|
apiUrl={apiUrl}
|
||||||
/>
|
/>
|
||||||
|
{/* Helper Badge for External Images */}
|
||||||
|
{isExternal && (
|
||||||
|
<div className="absolute top-2 left-2 bg-black/60 text-white text-[10px] px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
External
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Hover Overlay - hidden on mobile, and hidden in feed variant */}
|
{/* Desktop Hover Overlay - hidden on mobile, and hidden in feed variant */}
|
||||||
@ -428,27 +446,31 @@ const PhotoCard = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<Button
|
{!isExternal && (
|
||||||
size="sm"
|
<>
|
||||||
variant="ghost"
|
<Button
|
||||||
onClick={handleLike}
|
size="sm"
|
||||||
className={`h-8 w-8 p-0 ${localIsLiked ? "text-red-500" : "text-white hover:text-red-500"
|
variant="ghost"
|
||||||
}`}
|
onClick={handleLike}
|
||||||
>
|
className={`h-8 w-8 p-0 ${localIsLiked ? "text-red-500" : "text-white hover:text-red-500"
|
||||||
<Heart className="h-4 w-4" fill={localIsLiked ? "currentColor" : "none"} />
|
}`}
|
||||||
</Button>
|
>
|
||||||
{localLikes > 0 && <span className="text-white text-sm">{localLikes}</span>}
|
<Heart className="h-4 w-4" fill={localIsLiked ? "currentColor" : "none"} />
|
||||||
|
</Button>
|
||||||
|
{localLikes > 0 && <span className="text-white text-sm">{localLikes}</span>}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-8 w-8 p-0 text-white hover:text-blue-400 ml-2"
|
className="h-8 w-8 p-0 text-white hover:text-blue-400 ml-2"
|
||||||
>
|
>
|
||||||
<MessageCircle className="h-4 w-4" />
|
<MessageCircle className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-white text-sm">{comments}</span>
|
<span className="text-white text-sm">{comments}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{isOwner && (
|
{isOwner && !isExternal && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -527,13 +549,15 @@ const PhotoCard = ({
|
|||||||
<Button size="sm" variant="secondary" className="h-6 w-6 p-0 bg-white/20 hover:bg-white/30 border-0 text-white">
|
<Button size="sm" variant="secondary" className="h-6 w-6 p-0 bg-white/20 hover:bg-white/30 border-0 text-white">
|
||||||
<Share2 className="h-2.5 w-2.5" />
|
<Share2 className="h-2.5 w-2.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<MagicWizardButton
|
{!isExternal && (
|
||||||
imageUrl={image}
|
<MagicWizardButton
|
||||||
imageTitle={title}
|
imageUrl={image}
|
||||||
size="sm"
|
imageTitle={title}
|
||||||
variant="ghost"
|
size="sm"
|
||||||
className="h-6 px-2 text-xs bg-white/20 hover:bg-white/30 border-0 text-white"
|
variant="ghost"
|
||||||
/>
|
className="h-6 px-2 text-xs bg-white/20 hover:bg-white/30 border-0 text-white"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -555,27 +579,31 @@ const PhotoCard = ({
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
{!isExternal && (
|
||||||
size="icon"
|
<>
|
||||||
variant="ghost"
|
<Button
|
||||||
onClick={handleLike}
|
size="icon"
|
||||||
className={localIsLiked ? "text-red-500 hover:text-red-600" : ""}
|
variant="ghost"
|
||||||
>
|
onClick={handleLike}
|
||||||
<Heart className="h-6 w-6" fill={localIsLiked ? "currentColor" : "none"} />
|
className={localIsLiked ? "text-red-500 hover:text-red-600" : ""}
|
||||||
</Button>
|
>
|
||||||
{localLikes > 0 && (
|
<Heart className="h-6 w-6" fill={localIsLiked ? "currentColor" : "none"} />
|
||||||
<span className="text-sm font-medium text-foreground mr-1">{localLikes}</span>
|
</Button>
|
||||||
)}
|
{localLikes > 0 && (
|
||||||
|
<span className="text-sm font-medium text-foreground mr-1">{localLikes}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-foreground"
|
className="text-foreground"
|
||||||
>
|
>
|
||||||
<MessageCircle className="h-6 w-6 -rotate-90" />
|
<MessageCircle className="h-6 w-6 -rotate-90" />
|
||||||
</Button>
|
</Button>
|
||||||
{comments > 0 && (
|
{comments > 0 && (
|
||||||
<span className="text-sm font-medium text-foreground mr-1">{comments}</span>
|
<span className="text-sm font-medium text-foreground mr-1">{comments}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -590,14 +618,16 @@ const PhotoCard = ({
|
|||||||
<Download className="h-6 w-6" />
|
<Download className="h-6 w-6" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<MagicWizardButton
|
{!isExternal && (
|
||||||
imageUrl={image}
|
<MagicWizardButton
|
||||||
imageTitle={title}
|
imageUrl={image}
|
||||||
size="icon"
|
imageTitle={title}
|
||||||
variant="ghost"
|
size="icon"
|
||||||
className="text-foreground hover:text-primary"
|
variant="ghost"
|
||||||
/>
|
className="text-foreground hover:text-primary"
|
||||||
{isOwner && (
|
/>
|
||||||
|
)}
|
||||||
|
{isOwner && !isExternal && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -641,7 +671,7 @@ const PhotoCard = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showEditModal && (
|
{showEditModal && !isExternal && (
|
||||||
<EditImageModal
|
<EditImageModal
|
||||||
open={showEditModal}
|
open={showEditModal}
|
||||||
onOpenChange={setShowEditModal}
|
onOpenChange={setShowEditModal}
|
||||||
@ -668,8 +698,8 @@ const PhotoCard = ({
|
|||||||
onPublish={handlePublish}
|
onPublish={handlePublish}
|
||||||
isGenerating={isGenerating}
|
isGenerating={isGenerating}
|
||||||
isPublishing={isPublishing}
|
isPublishing={isPublishing}
|
||||||
showPrompt={true}
|
showPrompt={!isExternal} // Hide prompt/edit for external
|
||||||
showPublish={!!generatedImageUrl}
|
showPublish={!!generatedImageUrl && !isExternal}
|
||||||
generatedImageUrl={generatedImageUrl || undefined}
|
generatedImageUrl={generatedImageUrl || undefined}
|
||||||
currentIndex={navigationData?.currentIndex}
|
currentIndex={navigationData?.currentIndex}
|
||||||
totalCount={navigationData?.posts.length}
|
totalCount={navigationData?.posts.length}
|
||||||
|
|||||||
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 */}
|
{/* Logo / Brand */}
|
||||||
<Link to="/" className="flex items-center space-x-2">
|
<Link to="/" className="flex items-center space-x-2">
|
||||||
<Camera className="h-6 w-6 text-primary" />
|
<Camera className="h-6 w-6 text-primary" />
|
||||||
<span className="font-bold text-lg hidden sm:inline-block">PixelHub</span>
|
<span className="font-bold text-lg hidden sm:inline-block">PolyMech</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Search Bar - Center */}
|
{/* Search Bar - Center */}
|
||||||
|
|||||||
@ -3,9 +3,9 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import MarkdownRenderer from "@/components/MarkdownRenderer";
|
import MarkdownRenderer from "@/components/MarkdownRenderer";
|
||||||
import { defaultLayoutIcons, DefaultVideoLayout } from '@vidstack/react/player/layouts/default';
|
import { defaultLayoutIcons } from '@vidstack/react/player/layouts/default';
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import { T, translate } from "@/i18n";
|
import { T, translate } from "@/i18n";
|
||||||
import type { MuxResolution } from "@/types";
|
import type { MuxResolution } from "@/types";
|
||||||
@ -14,13 +14,17 @@ import { detectMediaType, MEDIA_TYPES } from "@/lib/mediaRegistry";
|
|||||||
import UserAvatarBlock from "@/components/UserAvatarBlock";
|
import UserAvatarBlock from "@/components/UserAvatarBlock";
|
||||||
import { formatDate, isLikelyFilename } from "@/utils/textUtils";
|
import { formatDate, isLikelyFilename } from "@/utils/textUtils";
|
||||||
|
|
||||||
import {
|
// import {
|
||||||
MediaPlayer, MediaProvider, type MediaPlayerInstance
|
// MediaPlayer, MediaProvider, type MediaPlayerInstance
|
||||||
} from '@vidstack/react';
|
// } from '@vidstack/react';
|
||||||
|
import type { MediaPlayerInstance } from '@vidstack/react';
|
||||||
|
|
||||||
// Import Vidstack styles
|
// Import Vidstack styles
|
||||||
import '@vidstack/react/player/styles/default/theme.css';
|
// import '@vidstack/react/player/styles/default/theme.css';
|
||||||
import '@vidstack/react/player/styles/default/layouts/video.css';
|
// import '@vidstack/react/player/styles/default/layouts/video.css';
|
||||||
|
|
||||||
|
// Lazy load Vidstack implementation
|
||||||
|
const VidstackPlayer = React.lazy(() => import('../player/components/VidstackPlayerImpl').then(module => ({ default: module.VidstackPlayerImpl })));
|
||||||
|
|
||||||
interface VideoCardProps {
|
interface VideoCardProps {
|
||||||
videoId: string;
|
videoId: string;
|
||||||
@ -469,28 +473,30 @@ const VideoCard = ({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
// Show MediaPlayer when playing
|
// Show MediaPlayer when playing
|
||||||
<MediaPlayer
|
<React.Suspense fallback={<div className="w-full h-full bg-black animate-pulse flex items-center justify-center"><Loader2 className="w-8 h-8 animate-spin text-white" /></div>}>
|
||||||
key={videoId}
|
<VidstackPlayer
|
||||||
ref={player}
|
key={videoId}
|
||||||
title={title}
|
ref={player}
|
||||||
src={
|
title={title}
|
||||||
playbackUrl.includes('.m3u8')
|
src={
|
||||||
? { src: playbackUrl, type: 'application/x-mpegurl' }
|
playbackUrl.includes('.m3u8')
|
||||||
: (job?.resultUrl && job.status === 'completed')
|
? { src: playbackUrl, type: 'application/x-mpegurl' }
|
||||||
? { src: job.resultUrl, type: 'application/x-mpegurl' }
|
: (job?.resultUrl && job.status === 'completed')
|
||||||
: playbackUrl.includes('/api/videos/jobs')
|
? { src: job.resultUrl, type: 'application/x-mpegurl' }
|
||||||
? { src: playbackUrl, type: 'video/mp4' }
|
: playbackUrl.includes('/api/videos/jobs')
|
||||||
: playbackUrl
|
? { src: playbackUrl, type: 'video/mp4' }
|
||||||
}
|
: playbackUrl
|
||||||
poster={posterUrl}
|
}
|
||||||
fullscreenOrientation="any"
|
poster={posterUrl}
|
||||||
controls
|
fullscreenOrientation="any"
|
||||||
playsInline
|
controls
|
||||||
className={`w-full ${variant === 'grid' ? "h-full" : ""}`}
|
playsInline
|
||||||
>
|
className={`w-full ${variant === 'grid' ? "h-full" : ""}`}
|
||||||
<MediaProvider />
|
layoutProps={{
|
||||||
<DefaultVideoLayout icons={defaultLayoutIcons} />
|
icons: defaultLayoutIcons
|
||||||
</MediaPlayer>
|
}}
|
||||||
|
/>
|
||||||
|
</React.Suspense>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -14,13 +14,14 @@ interface GenericCanvasProps {
|
|||||||
showControls?: boolean;
|
showControls?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
selectedWidgetId?: string | null;
|
selectedWidgetId?: string | null;
|
||||||
onSelectWidget?: (widgetId: string) => void;
|
onSelectWidget?: (widgetId: string, pageId?: string) => void;
|
||||||
selectedContainerId?: string | null;
|
selectedContainerId?: string | null;
|
||||||
onSelectContainer?: (containerId: string | null) => void;
|
onSelectContainer?: (containerId: string | null, pageId?: string) => void;
|
||||||
initialLayout?: any;
|
initialLayout?: any;
|
||||||
editingWidgetId?: string | null;
|
editingWidgetId?: string | null;
|
||||||
onEditWidget?: (widgetId: string | null) => void;
|
onEditWidget?: (widgetId: string | null) => void;
|
||||||
newlyAddedWidgetId?: string | null;
|
newlyAddedWidgetId?: string | null;
|
||||||
|
contextVariables?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
|
const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
|
||||||
@ -36,7 +37,8 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
|
|||||||
initialLayout,
|
initialLayout,
|
||||||
editingWidgetId,
|
editingWidgetId,
|
||||||
onEditWidget,
|
onEditWidget,
|
||||||
newlyAddedWidgetId
|
newlyAddedWidgetId,
|
||||||
|
contextVariables
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
loadedPages,
|
loadedPages,
|
||||||
@ -72,13 +74,14 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
|
|||||||
const [internalSelectedContainer, setInternalSelectedContainer] = useState<string | null>(null);
|
const [internalSelectedContainer, setInternalSelectedContainer] = useState<string | null>(null);
|
||||||
|
|
||||||
const selectedContainer = propSelectedContainerId !== undefined ? propSelectedContainerId : internalSelectedContainer;
|
const selectedContainer = propSelectedContainerId !== undefined ? propSelectedContainerId : internalSelectedContainer;
|
||||||
const setSelectedContainer = (id: string | null) => {
|
const setSelectedContainer = (id: string | null, pageId?: string) => {
|
||||||
if (propOnSelectContainer) {
|
if (propOnSelectContainer) {
|
||||||
propOnSelectContainer(id);
|
propOnSelectContainer(id, pageId);
|
||||||
} else {
|
} else {
|
||||||
setInternalSelectedContainer(id);
|
setInternalSelectedContainer(id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [showWidgetPalette, setShowWidgetPalette] = useState(false);
|
const [showWidgetPalette, setShowWidgetPalette] = useState(false);
|
||||||
const [targetContainerId, setTargetContainerId] = useState<string | null>(null);
|
const [targetContainerId, setTargetContainerId] = useState<string | null>(null);
|
||||||
const [targetColumn, setTargetColumn] = useState<number | undefined>(undefined);
|
const [targetColumn, setTargetColumn] = useState<number | undefined>(undefined);
|
||||||
@ -96,8 +99,8 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectContainer = (containerId: string) => {
|
const handleSelectContainer = (containerId: string, pageId?: string) => {
|
||||||
setSelectedContainer(containerId);
|
setSelectedContainer(containerId, pageId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddWidget = (containerId: string, columnIndex?: number) => {
|
const handleAddWidget = (containerId: string, columnIndex?: number) => {
|
||||||
@ -313,6 +316,7 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
|
|||||||
editingWidgetId={editingWidgetId}
|
editingWidgetId={editingWidgetId}
|
||||||
onEditWidget={onEditWidget}
|
onEditWidget={onEditWidget}
|
||||||
newlyAddedWidgetId={newlyAddedWidgetId}
|
newlyAddedWidgetId={newlyAddedWidgetId}
|
||||||
|
contextVariables={contextVariables}
|
||||||
onRemoveWidget={async (widgetId) => {
|
onRemoveWidget={async (widgetId) => {
|
||||||
try {
|
try {
|
||||||
await removeWidgetFromPage(pageId, widgetId);
|
await removeWidgetFromPage(pageId, widgetId);
|
||||||
|
|||||||
@ -15,7 +15,7 @@ interface LayoutContainerProps {
|
|||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
selectedContainerId?: string | null;
|
selectedContainerId?: string | null;
|
||||||
onSelect?: (containerId: string) => void;
|
onSelect?: (containerId: string, pageId?: string) => void;
|
||||||
onAddWidget?: (containerId: string, targetColumn?: number) => void;
|
onAddWidget?: (containerId: string, targetColumn?: number) => void;
|
||||||
onRemoveWidget?: (widgetInstanceId: string) => void;
|
onRemoveWidget?: (widgetInstanceId: string) => void;
|
||||||
onMoveWidget?: (widgetInstanceId: string, direction: 'up' | 'down' | 'left' | 'right') => void;
|
onMoveWidget?: (widgetInstanceId: string, direction: 'up' | 'down' | 'left' | 'right') => void;
|
||||||
@ -27,12 +27,13 @@ interface LayoutContainerProps {
|
|||||||
canMoveContainerUp?: boolean;
|
canMoveContainerUp?: boolean;
|
||||||
canMoveContainerDown?: boolean;
|
canMoveContainerDown?: boolean;
|
||||||
selectedWidgetId?: string | null;
|
selectedWidgetId?: string | null;
|
||||||
onSelectWidget?: (widgetId: string) => void;
|
onSelectWidget?: (widgetId: string, pageId?: string) => void;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
isCompactMode?: boolean;
|
isCompactMode?: boolean;
|
||||||
editingWidgetId?: string | null;
|
editingWidgetId?: string | null;
|
||||||
onEditWidget?: (widgetId: string | null) => void;
|
onEditWidget?: (widgetId: string | null) => void;
|
||||||
newlyAddedWidgetId?: string | null;
|
newlyAddedWidgetId?: string | null;
|
||||||
|
contextVariables?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
|
const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
|
||||||
@ -58,6 +59,7 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
|
|||||||
editingWidgetId,
|
editingWidgetId,
|
||||||
onEditWidget,
|
onEditWidget,
|
||||||
newlyAddedWidgetId,
|
newlyAddedWidgetId,
|
||||||
|
contextVariables,
|
||||||
}) => {
|
}) => {
|
||||||
const maxDepth = 3; // Limit nesting depth
|
const maxDepth = 3; // Limit nesting depth
|
||||||
const canNest = depth < maxDepth;
|
const canNest = depth < maxDepth;
|
||||||
@ -116,7 +118,7 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
|
|||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
isSelected={selectedWidgetId === widget.id}
|
isSelected={selectedWidgetId === widget.id}
|
||||||
onSelect={() => onSelectWidget?.(widget.id)}
|
onSelect={() => onSelectWidget?.(widget.id, pageId)}
|
||||||
canMoveUp={index > 0}
|
canMoveUp={index > 0}
|
||||||
canMoveDown={index < container.widgets.length - 1}
|
canMoveDown={index < container.widgets.length - 1}
|
||||||
onRemove={onRemoveWidget}
|
onRemove={onRemoveWidget}
|
||||||
@ -124,6 +126,10 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
|
|||||||
isEditing={editingWidgetId === widget.id}
|
isEditing={editingWidgetId === widget.id}
|
||||||
onEditWidget={onEditWidget}
|
onEditWidget={onEditWidget}
|
||||||
isNew={newlyAddedWidgetId === widget.id}
|
isNew={newlyAddedWidgetId === widget.id}
|
||||||
|
selectedWidgetId={selectedWidgetId}
|
||||||
|
onSelectWidget={onSelectWidget}
|
||||||
|
editingWidgetId={editingWidgetId}
|
||||||
|
contextVariables={contextVariables}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@ -182,6 +188,7 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
|
|||||||
editingWidgetId={editingWidgetId}
|
editingWidgetId={editingWidgetId}
|
||||||
onEditWidget={onEditWidget}
|
onEditWidget={onEditWidget}
|
||||||
newlyAddedWidgetId={newlyAddedWidgetId}
|
newlyAddedWidgetId={newlyAddedWidgetId}
|
||||||
|
contextVariables={contextVariables}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -196,7 +203,7 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
|
|||||||
)}
|
)}
|
||||||
onDoubleClick={isEditMode ? (e) => {
|
onDoubleClick={isEditMode ? (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSelect?.(container.id);
|
onSelect?.(container.id, pageId);
|
||||||
setTimeout(() => onAddWidget?.(container.id), 100); // Small delay to ensure selection happens first, no column = append
|
setTimeout(() => onAddWidget?.(container.id), 100); // Small delay to ensure selection happens first, no column = append
|
||||||
} : undefined}
|
} : undefined}
|
||||||
title={isEditMode ? "Double-click to add widget" : undefined}
|
title={isEditMode ? "Double-click to add widget" : undefined}
|
||||||
@ -375,7 +382,7 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
onSelect?.(container.id);
|
onSelect?.(container.id, pageId);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -463,6 +470,10 @@ interface WidgetItemProps {
|
|||||||
isEditing?: boolean;
|
isEditing?: boolean;
|
||||||
onEditWidget?: (widgetId: string | null) => void;
|
onEditWidget?: (widgetId: string | null) => void;
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
|
selectedWidgetId?: string | null;
|
||||||
|
onSelectWidget?: (widgetId: string, pageId?: string) => void;
|
||||||
|
editingWidgetId?: string | null;
|
||||||
|
contextVariables?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WidgetItem: React.FC<WidgetItemProps> = ({
|
const WidgetItem: React.FC<WidgetItemProps> = ({
|
||||||
@ -477,7 +488,11 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
|
|||||||
onSelect,
|
onSelect,
|
||||||
isEditing,
|
isEditing,
|
||||||
onEditWidget,
|
onEditWidget,
|
||||||
isNew
|
isNew,
|
||||||
|
selectedWidgetId,
|
||||||
|
onSelectWidget,
|
||||||
|
editingWidgetId,
|
||||||
|
contextVariables,
|
||||||
}) => {
|
}) => {
|
||||||
const widgetDefinition = widgetRegistry.get(widget.widgetId);
|
const widgetDefinition = widgetRegistry.get(widget.widgetId);
|
||||||
const { updateWidgetProps, renameWidget } = useLayout();
|
const { updateWidgetProps, renameWidget } = useLayout();
|
||||||
@ -639,6 +654,11 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
|
|||||||
console.error('Failed to update widget props:', error);
|
console.error('Failed to update widget props:', error);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
selectedWidgetId={selectedWidgetId}
|
||||||
|
onSelectWidget={onSelectWidget}
|
||||||
|
editingWidgetId={editingWidgetId}
|
||||||
|
onEditWidget={onEditWidget}
|
||||||
|
contextVariables={contextVariables}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
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 React, { useState } from 'react';
|
||||||
import { ChevronRight, ChevronDown, Box, LayoutGrid } from 'lucide-react';
|
import { ChevronRight, ChevronDown, Box, LayoutGrid, Layers, Settings } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { LayoutContainer, WidgetInstance } from '@/lib/unifiedLayoutManager';
|
import { LayoutContainer, WidgetInstance } from '@/lib/unifiedLayoutManager';
|
||||||
import { widgetRegistry } from '@/lib/widgetRegistry';
|
import { widgetRegistry } from '@/lib/widgetRegistry';
|
||||||
|
import { useLayout } from '@/contexts/LayoutContext';
|
||||||
|
|
||||||
interface HierarchyNodeProps {
|
interface HierarchyNodeProps {
|
||||||
label: string;
|
label: string;
|
||||||
@ -16,6 +17,7 @@ interface HierarchyNodeProps {
|
|||||||
hasChildren?: boolean;
|
hasChildren?: boolean;
|
||||||
onToggleExpand?: () => void;
|
onToggleExpand?: () => void;
|
||||||
isExpanded?: boolean;
|
isExpanded?: boolean;
|
||||||
|
onSettings?: (e: React.MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TreeNode = ({
|
const TreeNode = ({
|
||||||
@ -27,7 +29,8 @@ const TreeNode = ({
|
|||||||
depth = 0,
|
depth = 0,
|
||||||
hasChildren = false,
|
hasChildren = false,
|
||||||
onToggleExpand,
|
onToggleExpand,
|
||||||
isExpanded = false
|
isExpanded = false,
|
||||||
|
onSettings
|
||||||
}: HierarchyNodeProps) => {
|
}: HierarchyNodeProps) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -56,7 +59,23 @@ const TreeNode = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Icon className={cn("h-3.5 w-3.5 shrink-0", isSelected ? "opacity-100" : "opacity-60")} />
|
<Icon className={cn("h-3.5 w-3.5 shrink-0", isSelected ? "opacity-100" : "opacity-60")} />
|
||||||
<span className="truncate text-xs">{label}</span>
|
<span className="truncate text-xs flex-1">{label}</span>
|
||||||
|
|
||||||
|
{onSettings && (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
className={cn(
|
||||||
|
"opacity-0 group-hover:opacity-100 transition-opacity p-0.5 rounded-sm hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground",
|
||||||
|
isSelected && "opacity-70 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800"
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSettings(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hasChildren && isExpanded && (
|
{hasChildren && isExpanded && (
|
||||||
<div>{children}</div>
|
<div>{children}</div>
|
||||||
@ -71,6 +90,8 @@ interface HierarchyTreeProps {
|
|||||||
selectedContainerId?: string | null;
|
selectedContainerId?: string | null;
|
||||||
onSelectWidget: (id: string) => void;
|
onSelectWidget: (id: string) => void;
|
||||||
onSelectContainer: (id: string) => void;
|
onSelectContainer: (id: string) => void;
|
||||||
|
onSettingsClick?: (id: string, type: 'widget' | 'container', layoutId: string) => void;
|
||||||
|
layoutId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HierarchyTree = ({
|
export const HierarchyTree = ({
|
||||||
@ -78,23 +99,62 @@ export const HierarchyTree = ({
|
|||||||
selectedWidgetId,
|
selectedWidgetId,
|
||||||
selectedContainerId,
|
selectedContainerId,
|
||||||
onSelectWidget,
|
onSelectWidget,
|
||||||
onSelectContainer
|
onSelectContainer,
|
||||||
|
onSettingsClick,
|
||||||
|
layoutId
|
||||||
}: HierarchyTreeProps) => {
|
}: HierarchyTreeProps) => {
|
||||||
|
|
||||||
// Manage expansion state locally
|
// Manage expansion state locally
|
||||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const { loadedPages, loadPageLayout } = useLayout();
|
||||||
|
const requestedLayoutsRef = React.useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Traverse and load nested layouts
|
||||||
|
React.useEffect(() => {
|
||||||
|
const checkAndLoadLayouts = (items: LayoutContainer[]) => {
|
||||||
|
items.forEach(c => {
|
||||||
|
c.widgets.forEach(w => {
|
||||||
|
const def = widgetRegistry.get(w.widgetId);
|
||||||
|
const nestedLayouts = def?.getNestedLayouts?.(w.props || {}) || [];
|
||||||
|
|
||||||
|
nestedLayouts.forEach(layoutInfo => {
|
||||||
|
if (layoutInfo.layoutId && !loadedPages.has(layoutInfo.layoutId) && !requestedLayoutsRef.current.has(layoutInfo.layoutId)) {
|
||||||
|
requestedLayoutsRef.current.add(layoutInfo.layoutId);
|
||||||
|
loadPageLayout(layoutInfo.layoutId, layoutInfo.label).catch(console.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (c.children) checkAndLoadLayouts(c.children);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (containers) checkAndLoadLayouts(containers);
|
||||||
|
}, [containers, loadedPages, loadPageLayout]);
|
||||||
|
|
||||||
// Auto-expand all containers on load/change
|
// Auto-expand all containers on load/change
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const allIds = new Set<string>();
|
const allIds = new Set<string>();
|
||||||
const traverse = (items: LayoutContainer[]) => {
|
const traverse = (items: LayoutContainer[]) => {
|
||||||
items.forEach(c => {
|
items.forEach(c => {
|
||||||
allIds.add(c.id);
|
allIds.add(c.id);
|
||||||
|
// Also expand widgets with children (nested layouts)
|
||||||
|
c.widgets.forEach(w => {
|
||||||
|
const def = widgetRegistry.get(w.widgetId);
|
||||||
|
const nestedLayouts = def?.getNestedLayouts?.(w.props || {}) || [];
|
||||||
|
if (nestedLayouts.length > 0) {
|
||||||
|
allIds.add(w.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (c.children) traverse(c.children);
|
if (c.children) traverse(c.children);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
if (containers) traverse(containers);
|
if (containers) traverse(containers);
|
||||||
setExpandedNodes(allIds);
|
setExpandedNodes(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
allIds.forEach(id => next.add(id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}, [containers]);
|
}, [containers]);
|
||||||
|
|
||||||
const toggleNode = (id: string) => {
|
const toggleNode = (id: string) => {
|
||||||
@ -109,10 +169,66 @@ export const HierarchyTree = ({
|
|||||||
|
|
||||||
// Auto-expand path to selection would be nice, but simple toggle for now
|
// Auto-expand path to selection would be nice, but simple toggle for now
|
||||||
|
|
||||||
const renderWidget = (widget: WidgetInstance, depth: number) => {
|
|
||||||
|
|
||||||
|
const renderWidget = (widget: WidgetInstance, depth: number, currentLayoutId: string) => {
|
||||||
const def = widgetRegistry.get(widget.widgetId);
|
const def = widgetRegistry.get(widget.widgetId);
|
||||||
const name = def?.metadata.name || widget.widgetId;
|
const name = def?.metadata.name || widget.widgetId;
|
||||||
const Icon = def?.metadata.icon || Box;
|
const Icon = def?.metadata.icon || Box;
|
||||||
|
const isExpanded = expandedNodes.has(widget.id);
|
||||||
|
|
||||||
|
// Check for nested layouts via registry definition
|
||||||
|
let nestedItems: React.ReactNode = null;
|
||||||
|
let hasChildren = false;
|
||||||
|
|
||||||
|
const nestedLayouts = def?.getNestedLayouts?.(widget.props || {}) || [];
|
||||||
|
|
||||||
|
if (nestedLayouts.length > 0) {
|
||||||
|
hasChildren = true;
|
||||||
|
nestedItems = nestedLayouts.map(layoutInfo => {
|
||||||
|
const nestedLayoutId = layoutInfo.layoutId;
|
||||||
|
const layout = loadedPages.get(nestedLayoutId);
|
||||||
|
const expansionKey = nestedLayoutId || layoutInfo.id;
|
||||||
|
const isTabExpanded = expandedNodes.has(expansionKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TreeNode
|
||||||
|
key={expansionKey}
|
||||||
|
label={layoutInfo.label}
|
||||||
|
icon={Layers}
|
||||||
|
isSelected={false}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleNode(expansionKey);
|
||||||
|
}}
|
||||||
|
depth={depth + 1}
|
||||||
|
hasChildren={true}
|
||||||
|
isExpanded={isTabExpanded}
|
||||||
|
onToggleExpand={() => toggleNode(expansionKey)}
|
||||||
|
onSettings={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onSettingsClick) {
|
||||||
|
// Select parent widget (in current layout)
|
||||||
|
onSettingsClick(widget.id, 'widget', currentLayoutId);
|
||||||
|
} else {
|
||||||
|
onSelectWidget(widget.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!layout ? (
|
||||||
|
<div className="py-1 px-2 text-xs text-muted-foreground opacity-50" style={{ paddingLeft: `${(depth + 2) * 12 + 4}px` }}>
|
||||||
|
(loading...)
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
layout.containers.map(c => renderContainer(c, depth + 2, nestedLayoutId))
|
||||||
|
)}
|
||||||
|
</TreeNode>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic 'layout-container' widget support if it exists and works similarly?
|
||||||
|
// For now just tabs.
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TreeNode
|
<TreeNode
|
||||||
@ -122,14 +238,30 @@ export const HierarchyTree = ({
|
|||||||
isSelected={selectedWidgetId === widget.id}
|
isSelected={selectedWidgetId === widget.id}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
// If it's a tab widget, we might want to select it.
|
||||||
|
// But if we click a nested widget, onSelectWidget is called with that ID.
|
||||||
onSelectWidget(widget.id);
|
onSelectWidget(widget.id);
|
||||||
}}
|
}}
|
||||||
depth={depth}
|
depth={depth}
|
||||||
/>
|
hasChildren={hasChildren}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
onToggleExpand={() => toggleNode(widget.id)}
|
||||||
|
onSettings={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onSettingsClick) {
|
||||||
|
onSettingsClick(widget.id, 'widget', currentLayoutId);
|
||||||
|
} else {
|
||||||
|
onSelectWidget(widget.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Render Nested Content */}
|
||||||
|
{isExpanded && nestedItems}
|
||||||
|
</TreeNode>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderContainer = (container: LayoutContainer, depth: number) => {
|
const renderContainer = (container: LayoutContainer, depth: number, currentLayoutId: string) => {
|
||||||
const title = container.settings?.title || `Container (${container.columns} col)`;
|
const title = container.settings?.title || `Container (${container.columns} col)`;
|
||||||
const hasChildren = container.widgets.length > 0 || container.children.length > 0;
|
const hasChildren = container.widgets.length > 0 || container.children.length > 0;
|
||||||
const isExpanded = expandedNodes.has(container.id);
|
const isExpanded = expandedNodes.has(container.id);
|
||||||
@ -145,12 +277,20 @@ export const HierarchyTree = ({
|
|||||||
hasChildren={hasChildren}
|
hasChildren={hasChildren}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
onToggleExpand={() => toggleNode(container.id)}
|
onToggleExpand={() => toggleNode(container.id)}
|
||||||
|
onSettings={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onSettingsClick) {
|
||||||
|
onSettingsClick(container.id, 'container', currentLayoutId);
|
||||||
|
} else {
|
||||||
|
onSelectContainer(container.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Render Content */}
|
{/* Render Content */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<>
|
<>
|
||||||
{container.widgets.map(w => renderWidget(w, depth + 1))}
|
{container.widgets.map(w => renderWidget(w, depth + 1, currentLayoutId))}
|
||||||
{container.children.map(c => renderContainer(c, depth + 1))}
|
{container.children.map(c => renderContainer(c, depth + 1, currentLayoutId))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TreeNode>
|
</TreeNode>
|
||||||
@ -169,7 +309,7 @@ export const HierarchyTree = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-2">
|
<div className="pb-2">
|
||||||
{containers.map(c => renderContainer(c, 0))}
|
{containers.map(c => renderContainer(c, 0, layoutId))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -62,7 +62,7 @@ function TocItemRenderer({
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isActive && itemRef.current) {
|
if (isActive && itemRef.current) {
|
||||||
itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
// itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
}
|
}
|
||||||
}, [isActive]);
|
}, [isActive]);
|
||||||
|
|
||||||
|
|||||||
@ -34,13 +34,7 @@ export interface BuilderElement {
|
|||||||
uiSchema?: any;
|
uiSchema?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PALETTE_ITEMS: BuilderElement[] = [
|
|
||||||
{ id: 'p-string', type: 'string', name: 'String', title: 'New String' },
|
|
||||||
{ id: 'p-number', type: 'number', name: 'Number', title: 'New Number' },
|
|
||||||
{ id: 'p-boolean', type: 'boolean', name: 'Boolean', title: 'New Boolean' },
|
|
||||||
{ id: 'p-object', type: 'object', name: 'Object', title: 'New Object' },
|
|
||||||
{ id: 'p-array', type: 'array', name: 'Array', title: 'New Array' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const DraggablePaletteItem = ({ item }: { item: BuilderElement }) => {
|
const DraggablePaletteItem = ({ item }: { item: BuilderElement }) => {
|
||||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||||
@ -81,8 +75,8 @@ const CanvasElement = ({
|
|||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
|
||||||
// Check if this is a primitive type or a custom type
|
// Check if this is a primitive type or a custom type
|
||||||
const primitiveTypes = ['string', 'number', 'boolean', 'array', 'object'];
|
const primitiveTypes = ['string', 'number', 'int', 'float', 'boolean', 'bool', 'array', 'object'];
|
||||||
const isPrimitive = primitiveTypes.includes(element.type);
|
const isPrimitive = primitiveTypes.includes(element.type.toLowerCase());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -113,7 +107,7 @@ const CanvasElement = ({
|
|||||||
<AlertDialogDescription asChild>
|
<AlertDialogDescription asChild>
|
||||||
<div>
|
<div>
|
||||||
{isPrimitive ? (
|
{isPrimitive ? (
|
||||||
<>This will remove the field "{element.title || element.name}" from the structure and delete it from the database. This action cannot be undone.</>
|
<>This will remove the field "{element.title || element.name}" from the structure.</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Choose how to remove the field "{element.title || element.name}":
|
Choose how to remove the field "{element.title || element.name}":
|
||||||
@ -128,28 +122,42 @@ const CanvasElement = ({
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
{!isPrimitive && onRemoveOnly && (
|
{isPrimitive ? (
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRemoveOnly();
|
if (onRemoveOnly) onRemoveOnly();
|
||||||
setShowDeleteDialog(false);
|
setShowDeleteDialog(false);
|
||||||
}}
|
}}
|
||||||
className="bg-secondary text-secondary-foreground hover:bg-secondary/90"
|
|
||||||
>
|
>
|
||||||
Remove Only
|
Remove
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{onRemoveOnly && (
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemoveOnly();
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
}}
|
||||||
|
className="bg-secondary text-secondary-foreground hover:bg-secondary/90"
|
||||||
|
>
|
||||||
|
Remove Only
|
||||||
|
</AlertDialogAction>
|
||||||
|
)}
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
}}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete in Database
|
||||||
|
</AlertDialogAction>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<AlertDialogAction
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDelete();
|
|
||||||
setShowDeleteDialog(false);
|
|
||||||
}}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
Delete in Database
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
@ -158,9 +166,12 @@ const CanvasElement = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
function getIconForType(type: string) {
|
function getIconForType(type: string) {
|
||||||
switch (type) {
|
switch (type.toLowerCase()) {
|
||||||
case 'string': return TypeIcon;
|
case 'string': return TypeIcon;
|
||||||
|
case 'int':
|
||||||
|
case 'float':
|
||||||
case 'number': return Hash;
|
case 'number': return Hash;
|
||||||
|
case 'bool':
|
||||||
case 'boolean': return ToggleLeft;
|
case 'boolean': return ToggleLeft;
|
||||||
case 'object': return Box;
|
case 'object': return Box;
|
||||||
case 'array': return List;
|
case 'array': return List;
|
||||||
@ -210,9 +221,31 @@ const TypeBuilderContent: React.FC<{
|
|||||||
id: 'canvas',
|
id: 'canvas',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const primitivePaletteItems = React.useMemo(() => {
|
||||||
|
const order = ['string', 'int', 'float', 'bool', 'object', 'array'];
|
||||||
|
return availableTypes
|
||||||
|
.filter(t => t.kind === 'primitive')
|
||||||
|
.sort((a, b) => {
|
||||||
|
const ia = order.indexOf(a.name);
|
||||||
|
const ib = order.indexOf(b.name);
|
||||||
|
if (ia !== -1 && ib !== -1) return ia - ib;
|
||||||
|
if (ia !== -1) return -1;
|
||||||
|
if (ib !== -1) return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
})
|
||||||
|
.map(t => ({
|
||||||
|
id: `primitive-${t.id}`,
|
||||||
|
type: t.name,
|
||||||
|
name: t.name.charAt(0).toUpperCase() + t.name.slice(1),
|
||||||
|
title: `New ${t.name.charAt(0).toUpperCase() + t.name.slice(1)}`,
|
||||||
|
description: t.description || undefined,
|
||||||
|
refId: t.id
|
||||||
|
} as BuilderElement & { refId?: string }));
|
||||||
|
}, [availableTypes]);
|
||||||
|
|
||||||
const customPaletteItems = React.useMemo(() => {
|
const customPaletteItems = React.useMemo(() => {
|
||||||
return availableTypes
|
return availableTypes
|
||||||
.filter(t => t.kind !== 'field') // Exclude field types from palette
|
.filter(t => t.kind !== 'primitive' && t.kind !== 'field') // Exclude primitives (handled above) and fields
|
||||||
.map(t => ({
|
.map(t => ({
|
||||||
id: `type-${t.id}`,
|
id: `type-${t.id}`,
|
||||||
type: t.name, // Use name as type reference for now? Or ID? ID is better for strictness, Name for display.
|
type: t.name, // Use name as type reference for now? Or ID? ID is better for strictness, Name for display.
|
||||||
@ -236,13 +269,17 @@ const TypeBuilderContent: React.FC<{
|
|||||||
<CardHeader className="py-3 px-4 border-b">
|
<CardHeader className="py-3 px-4 border-b">
|
||||||
<CardTitle className="text-sm font-medium">Palette</CardTitle>
|
<CardTitle className="text-sm font-medium">Palette</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 p-3 space-y-6 overflow-y-auto">
|
<CardContent className="flex-1 p-3 space-y-6 overflow-y-auto scrollbar-custom">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Primitives</div>
|
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Primitives</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{PALETTE_ITEMS.map(item => (
|
{primitivePaletteItems.length > 0 ? (
|
||||||
<DraggablePaletteItem key={item.id} item={item} />
|
primitivePaletteItems.map(item => (
|
||||||
))}
|
<DraggablePaletteItem key={item.id} item={item} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-muted-foreground italic">No primitive types found.</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -263,9 +300,8 @@ const TypeBuilderContent: React.FC<{
|
|||||||
<Card className={`flex-1 flex flex-col transition-colors ${isOver ? 'bg-muted/30 border-primary/50 ring-2 ring-primary/20' : ''}`}>
|
<Card className={`flex-1 flex flex-col transition-colors ${isOver ? 'bg-muted/30 border-primary/50 ring-2 ring-primary/20' : ''}`}>
|
||||||
<CardHeader className="py-3 px-4 border-b flex flex-row justify-between items-center">
|
<CardHeader className="py-3 px-4 border-b flex flex-row justify-between items-center">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<CardTitle className="text-sm font-medium">Builder</CardTitle>
|
|
||||||
<Tabs value={mode} onValueChange={(v) => { setMode(v as BuilderMode); setElements([]); }} className="w-[200px]">
|
<Tabs value={mode} onValueChange={(v) => { setMode(v as BuilderMode); setElements([]); }} className="w-[200px]">
|
||||||
<TabsList className="grid w-full grid-cols-2 h-7">
|
<TabsList className="grid grid-cols-2 h-7">
|
||||||
<TabsTrigger value="structure" className="text-xs">Structure</TabsTrigger>
|
<TabsTrigger value="structure" className="text-xs">Structure</TabsTrigger>
|
||||||
<TabsTrigger value="alias" className="text-xs">Single Type</TabsTrigger>
|
<TabsTrigger value="alias" className="text-xs">Single Type</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@ -278,7 +314,7 @@ const TypeBuilderContent: React.FC<{
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<div ref={setCanvasRef} className="flex-1 p-6 bg-muted/10 overflow-y-auto min-h-[300px] transition-colors relative">
|
<div ref={setCanvasRef} className="flex-1 p-4 bg-muted/10 overflow-y-auto min-h-[300px] transition-colors relative">
|
||||||
{isOver && (
|
{isOver && (
|
||||||
<div className="absolute inset-0 bg-primary/5 rounded-none border-2 border-primary/20 border-dashed pointer-events-none z-0" />
|
<div className="absolute inset-0 bg-primary/5 rounded-none border-2 border-primary/20 border-dashed pointer-events-none z-0" />
|
||||||
)}
|
)}
|
||||||
@ -342,31 +378,27 @@ const TypeBuilderContent: React.FC<{
|
|||||||
<Select
|
<Select
|
||||||
value={elements[0]?.type || ''}
|
value={elements[0]?.type || ''}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
if (elements.length > 0) {
|
const foundType = availableTypes.find(t => t.name === val);
|
||||||
const updated = { ...elements[0], type: val, name: 'value', title: val + ' Alias' };
|
if (!foundType) return;
|
||||||
setElements([updated]);
|
|
||||||
setSelectedId(updated.id);
|
const newItemId = `field-${Date.now()}`;
|
||||||
} else {
|
const newItem: BuilderElement = {
|
||||||
// Create new if empty? Or just wait for drag?
|
id: elements.length > 0 ? elements[0].id : newItemId,
|
||||||
// Let's allow creating via select if empty
|
type: val,
|
||||||
const newItemId = `field-${Date.now()}`;
|
name: 'value',
|
||||||
const newItem: BuilderElement = {
|
title: val + ' Alias',
|
||||||
id: newItemId,
|
uiSchema: {},
|
||||||
type: val,
|
...(foundType && { refId: foundType.id } as any)
|
||||||
name: 'value',
|
};
|
||||||
title: val + ' Alias',
|
setElements([newItem]);
|
||||||
uiSchema: {}
|
setSelectedId(newItem.id);
|
||||||
};
|
|
||||||
setElements([newItem]);
|
|
||||||
setSelectedId(newItemId);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a primitive type" />
|
<SelectValue placeholder="Select a primitive type" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{PALETTE_ITEMS.map(p => (
|
{primitivePaletteItems.map(p => (
|
||||||
<SelectItem key={p.id} value={p.type}>{p.name}</SelectItem>
|
<SelectItem key={p.id} value={p.type}>{p.name}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@ -464,12 +496,12 @@ const TypeBuilderContent: React.FC<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TypeBuilder: React.FC<{
|
export const TypeBuilder = React.forwardRef<TypeBuilderRef, {
|
||||||
onSave: (data: BuilderOutput) => void,
|
onSave: (data: BuilderOutput) => void,
|
||||||
onCancel: () => void,
|
onCancel: () => void,
|
||||||
availableTypes: TypeDefinition[],
|
availableTypes: TypeDefinition[],
|
||||||
initialData?: BuilderOutput
|
initialData?: BuilderOutput
|
||||||
}> = ({ onSave, onCancel, availableTypes, initialData }) => {
|
}>(({ onSave, onCancel, availableTypes, initialData }, ref) => {
|
||||||
const [mode, setMode] = useState<BuilderMode>(initialData?.mode || 'structure');
|
const [mode, setMode] = useState<BuilderMode>(initialData?.mode || 'structure');
|
||||||
const [elements, setElements] = useState<BuilderElement[]>(initialData?.elements || []);
|
const [elements, setElements] = useState<BuilderElement[]>(initialData?.elements || []);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
@ -503,11 +535,11 @@ export const TypeBuilder: React.FC<{
|
|||||||
const newItemId = `field-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
const newItemId = `field-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||||
|
|
||||||
// Determine the refId for this element
|
// Determine the refId for this element
|
||||||
// If template already has refId (custom type from palette), use it
|
// If template already has refId (custom type from palette or primitive from DB), use it
|
||||||
// Otherwise, look up primitive type by mapped name
|
|
||||||
let refId = (template as any).refId;
|
let refId = (template as any).refId;
|
||||||
|
|
||||||
|
// Fallback for legacy palette items (shouldn't be hit now, but good for safety)
|
||||||
if (!refId) {
|
if (!refId) {
|
||||||
// Map JSON Schema type names to database primitive type names
|
|
||||||
const typeNameMap: Record<string, string> = {
|
const typeNameMap: Record<string, string> = {
|
||||||
'number': 'int', 'boolean': 'bool', 'string': 'string', 'array': 'array', 'object': 'object'
|
'number': 'int', 'boolean': 'bool', 'string': 'string', 'array': 'array', 'object': 'object'
|
||||||
};
|
};
|
||||||
@ -554,6 +586,16 @@ export const TypeBuilder: React.FC<{
|
|||||||
if (selectedId === id) setSelectedId(null);
|
if (selectedId === id) setSelectedId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
React.useImperativeHandle(ref, () => ({
|
||||||
|
triggerSave: () => {
|
||||||
|
if (!typeName.trim()) {
|
||||||
|
// Maybe validate here or show error
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave({ mode, elements, name: typeName, description: typeDescription, fieldsToDelete });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
@ -600,4 +642,8 @@ export const TypeBuilder: React.FC<{
|
|||||||
)}
|
)}
|
||||||
</DndContext>
|
</DndContext>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
export interface TypeBuilderRef {
|
||||||
|
triggerSave: () => void;
|
||||||
|
}
|
||||||
|
|||||||
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 React, { useState, useMemo, useImperativeHandle, forwardRef } from 'react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { RefreshCw, Save, Trash2, Play } from 'lucide-react';
|
import { RefreshCw } from 'lucide-react';
|
||||||
import Form from '@rjsf/core';
|
import Form from '@rjsf/core';
|
||||||
import validator from '@rjsf/validator-ajv8';
|
import validator from '@rjsf/validator-ajv8';
|
||||||
import { customWidgets, customTemplates } from './RJSFTemplates';
|
import { customWidgets, customTemplates } from './RJSFTemplates';
|
||||||
@ -11,21 +11,22 @@ import { generateRandomData } from './randomDataGenerator';
|
|||||||
import { TypeDefinition } from './db';
|
import { TypeDefinition } from './db';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export interface TypeRendererRef {
|
||||||
|
triggerSave: () => Promise<void>;
|
||||||
|
triggerPreview: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface TypeRendererProps {
|
interface TypeRendererProps {
|
||||||
editedType: TypeDefinition;
|
editedType: TypeDefinition;
|
||||||
types: TypeDefinition[];
|
types: TypeDefinition[];
|
||||||
onSave: (jsonSchema: string, uiSchema: string) => Promise<void>;
|
onSave: (jsonSchema: string, uiSchema: string) => Promise<void>;
|
||||||
onDelete: () => Promise<void>;
|
|
||||||
onVisualEdit: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TypeRenderer: React.FC<TypeRendererProps> = ({
|
export const TypeRenderer = forwardRef<TypeRendererRef, TypeRendererProps>(({
|
||||||
editedType,
|
editedType,
|
||||||
types,
|
types,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
}, ref) => {
|
||||||
onVisualEdit
|
|
||||||
}) => {
|
|
||||||
const [jsonSchemaString, setJsonSchemaString] = useState('{}');
|
const [jsonSchemaString, setJsonSchemaString] = useState('{}');
|
||||||
const [uiSchemaString, setUiSchemaString] = useState('{}');
|
const [uiSchemaString, setUiSchemaString] = useState('{}');
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
@ -184,13 +185,16 @@ export const TypeRenderer: React.FC<TypeRendererProps> = ({
|
|||||||
await onSave(jsonSchemaString, uiSchemaString);
|
await onSave(jsonSchemaString, uiSchemaString);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePreviewToggle = () => {
|
useImperativeHandle(ref, () => ({
|
||||||
if (!showPreview && editedType?.json_schema) {
|
triggerSave: handleSave,
|
||||||
const randomData = generateRandomData(previewSchema);
|
triggerPreview: () => {
|
||||||
setPreviewFormData(randomData);
|
if (!showPreview && editedType?.json_schema) {
|
||||||
|
const randomData = generateRandomData(previewSchema);
|
||||||
|
setPreviewFormData(randomData);
|
||||||
|
}
|
||||||
|
setShowPreview(prev => !prev);
|
||||||
}
|
}
|
||||||
setShowPreview(!showPreview);
|
}));
|
||||||
};
|
|
||||||
|
|
||||||
const handleRegenerateData = () => {
|
const handleRegenerateData = () => {
|
||||||
if (previewSchema) {
|
if (previewSchema) {
|
||||||
@ -214,34 +218,6 @@ export const TypeRenderer: React.FC<TypeRendererProps> = ({
|
|||||||
{editedType.description || "No description"}
|
{editedType.description || "No description"}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={onVisualEdit} size="sm" variant="outline">
|
|
||||||
<RefreshCw className="mr-2 h-3.5 w-3.5" />
|
|
||||||
Visual Edit
|
|
||||||
</Button>
|
|
||||||
{editedType.kind === 'structure' && (
|
|
||||||
<Button
|
|
||||||
onClick={handlePreviewToggle}
|
|
||||||
size="sm"
|
|
||||||
variant={showPreview ? "default" : "outline"}
|
|
||||||
>
|
|
||||||
<Play className="mr-2 h-3.5 w-3.5" />
|
|
||||||
{showPreview ? 'Hide' : 'Preview'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={onDelete}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} size="sm">
|
|
||||||
<Save className="mr-2 h-3.5 w-3.5" />
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 min-h-0 p-0 overflow-hidden">
|
<CardContent className="flex-1 min-h-0 p-0 overflow-hidden">
|
||||||
@ -341,6 +317,6 @@ export const TypeRenderer: React.FC<TypeRendererProps> = ({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default TypeRenderer;
|
export default TypeRenderer;
|
||||||
|
|||||||
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 React, { useEffect, useState } from 'react';
|
||||||
import { fetchTypes, updateType, createType, deleteType, TypeDefinition } from './db';
|
import { fetchTypes, deleteType, TypeDefinition } from './db';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Loader2, Plus } from "lucide-react";
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import { Loader2, Plus, RefreshCw } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { TypeBuilder, BuilderOutput, BuilderElement, BuilderMode } from './TypeBuilder';
|
import { TypesList } from './TypesList';
|
||||||
import TypeRenderer from './TypeRenderer';
|
import { TypesEditor } from './TypesEditor';
|
||||||
|
import { useActions } from '@/actions/useActions';
|
||||||
|
import { Action } from '@/actions/types';
|
||||||
|
import { TypeEditorActions } from './TypeEditorActions';
|
||||||
|
|
||||||
const TypesPlayground: React.FC = () => {
|
const TypesPlayground: React.FC = () => {
|
||||||
const [types, setTypes] = useState<TypeDefinition[]>([]);
|
const [types, setTypes] = useState<TypeDefinition[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedTypeId, setSelectedTypeId] = useState<string | null>(null);
|
const [selectedTypeId, setSelectedTypeId] = useState<string | null>(null);
|
||||||
const [editedType, setEditedType] = useState<TypeDefinition | null>(null);
|
|
||||||
const [isBuilding, setIsBuilding] = useState(false);
|
const [isBuilding, setIsBuilding] = useState(false);
|
||||||
const [builderInitialData, setBuilderInitialData] = useState<BuilderOutput | undefined>(undefined);
|
|
||||||
|
|
||||||
const loadTypes = async () => {
|
const { registerAction, updateAction, unregisterAction } = useActions();
|
||||||
|
|
||||||
|
const loadTypes = React.useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await fetchTypes();
|
const data = await fetchTypes();
|
||||||
console.log('types', data);
|
// console.log('types', data);
|
||||||
setTypes(data);
|
setTypes(data);
|
||||||
if (selectedTypeId) {
|
if (selectedTypeId) {
|
||||||
const refreshed = data.find(t => t.id === selectedTypeId);
|
// Ensure selection is valid
|
||||||
if (refreshed) selectType(refreshed);
|
const exists = data.find(t => t.id === selectedTypeId);
|
||||||
|
if (!exists) setSelectedTypeId(null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch types", error);
|
console.error("Failed to fetch types", error);
|
||||||
@ -32,202 +33,72 @@ const TypesPlayground: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [selectedTypeId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTypes();
|
loadTypes();
|
||||||
|
}, [loadTypes]);
|
||||||
|
|
||||||
|
const handleCreateNew = React.useCallback(() => {
|
||||||
|
setSelectedTypeId(null);
|
||||||
|
setIsBuilding(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selectType = (t: TypeDefinition) => {
|
const handleSelectType = React.useCallback((t: TypeDefinition) => {
|
||||||
setIsBuilding(false);
|
setIsBuilding(false);
|
||||||
setSelectedTypeId(t.id);
|
setSelectedTypeId(t.id);
|
||||||
setEditedType(t);
|
}, []);
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateNew = () => {
|
// Register "New Type" action
|
||||||
setSelectedTypeId(null);
|
useEffect(() => {
|
||||||
setEditedType(null);
|
const action: Action = {
|
||||||
setBuilderInitialData(undefined);
|
id: 'types.new',
|
||||||
setIsBuilding(true);
|
label: 'New Type',
|
||||||
};
|
icon: Plus,
|
||||||
|
group: 'types',
|
||||||
const handleEditVisual = () => {
|
handler: handleCreateNew,
|
||||||
if (!editedType) return;
|
shortcut: 'mod+n' // Or something else
|
||||||
|
|
||||||
// Convert current type to builder format
|
|
||||||
const builderData: BuilderOutput = {
|
|
||||||
mode: editedType.kind as BuilderMode,
|
|
||||||
name: editedType.name,
|
|
||||||
description: editedType.description || '',
|
|
||||||
elements: []
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// For structures, convert structure_fields to builder elements
|
registerAction(action);
|
||||||
if (editedType.kind === 'structure' && editedType.structure_fields) {
|
|
||||||
builderData.elements = editedType.structure_fields.map(field => {
|
|
||||||
const fieldType = types.find(t => t.id === field.field_type_id);
|
|
||||||
return {
|
|
||||||
id: field.id || crypto.randomUUID(),
|
|
||||||
name: field.field_name,
|
|
||||||
type: fieldType?.name || 'string',
|
|
||||||
title: field.field_name,
|
|
||||||
description: fieldType?.description || ''
|
|
||||||
} as BuilderElement;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setBuilderInitialData(builderData);
|
return () => unregisterAction('types.new');
|
||||||
setIsBuilding(true);
|
}, [registerAction, unregisterAction]);
|
||||||
};
|
|
||||||
|
|
||||||
const handleBuilderSave = async (output: BuilderOutput) => {
|
// Update "New Type" state
|
||||||
console.log('Builder output:', output);
|
useEffect(() => {
|
||||||
|
updateAction('types.new', {
|
||||||
|
disabled: isBuilding || loading,
|
||||||
|
visible: !isBuilding // Hide when building to keep toolbar clean? Or just disable?
|
||||||
|
// User requested "Gray out buttons", so maybe just disabled.
|
||||||
|
// But if we hide it, we make space for other actions.
|
||||||
|
// Let's decide to keep it visible but disabled if building, or hide it if we want to focus on editor actions.
|
||||||
|
// Logic in TypesEditor hides some actions.
|
||||||
|
// Let's hide 'types.new' when building to match 'cancel' appearing.
|
||||||
|
});
|
||||||
|
}, [isBuilding, loading, updateAction]);
|
||||||
|
|
||||||
if (builderInitialData) {
|
|
||||||
// Editing existing type
|
|
||||||
if (!editedType) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// For structures, we need to update structure_fields
|
|
||||||
if (output.mode === 'structure') {
|
|
||||||
// Create/update field types for each element
|
|
||||||
const fieldUpdates = await Promise.all(output.elements.map(async (el) => {
|
|
||||||
// Find or create the field type
|
|
||||||
let fieldType = types.find(t => t.name === `${editedType.name}.${el.name}` && t.kind === 'field');
|
|
||||||
|
|
||||||
// Find the parent type for this field (could be primitive or custom)
|
|
||||||
// First check if element has a refId (for custom types dragged from palette)
|
|
||||||
let parentType = (el as any).refId
|
|
||||||
? types.find(t => t.id === (el as any).refId)
|
|
||||||
: types.find(t => t.name === el.type);
|
|
||||||
|
|
||||||
if (!parentType) {
|
|
||||||
console.error(`Parent type not found: ${el.type} (refId: ${(el as any).refId})`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldTypeData = {
|
|
||||||
name: `${editedType.name}.${el.name}`,
|
|
||||||
kind: 'field' as const,
|
|
||||||
description: el.description || `Field ${el.name}`,
|
|
||||||
parent_type_id: parentType.id,
|
|
||||||
meta: {}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (fieldType) {
|
|
||||||
// Update existing field type
|
|
||||||
await updateType(fieldType.id, fieldTypeData);
|
|
||||||
return { ...fieldType, ...fieldTypeData };
|
|
||||||
} else {
|
|
||||||
// Create new field type
|
|
||||||
const newFieldType = await createType(fieldTypeData as any);
|
|
||||||
return newFieldType;
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Filter out any null results
|
|
||||||
const validFieldTypes = fieldUpdates.filter(f => f !== null);
|
|
||||||
|
|
||||||
// Update the structure with new structure_fields
|
|
||||||
const structureFields = output.elements.map((el, idx) => ({
|
|
||||||
field_name: el.name,
|
|
||||||
field_type_id: validFieldTypes[idx]?.id || '',
|
|
||||||
required: false,
|
|
||||||
order: idx
|
|
||||||
}));
|
|
||||||
|
|
||||||
await updateType(editedType.id, {
|
|
||||||
name: output.name,
|
|
||||||
description: output.description,
|
|
||||||
structure_fields: structureFields
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("Type updated");
|
|
||||||
setIsBuilding(false);
|
|
||||||
loadTypes();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update type", error);
|
|
||||||
toast.error("Failed to update type");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Creating new type
|
|
||||||
try {
|
|
||||||
const newType: Partial<TypeDefinition> = {
|
|
||||||
name: output.name,
|
|
||||||
description: output.description,
|
|
||||||
kind: output.mode,
|
|
||||||
};
|
|
||||||
|
|
||||||
// For structures, create field types first
|
|
||||||
if (output.mode === 'structure') {
|
|
||||||
const fieldTypes = await Promise.all(output.elements.map(async (el) => {
|
|
||||||
// Find the parent type (could be primitive or custom)
|
|
||||||
const parentType = (el as any).refId
|
|
||||||
? types.find(t => t.id === (el as any).refId)
|
|
||||||
: types.find(t => t.name === el.type);
|
|
||||||
|
|
||||||
if (!parentType) {
|
|
||||||
throw new Error(`Parent type not found: ${el.type} (refId: ${(el as any).refId})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await createType({
|
|
||||||
name: `${output.name}.${el.name}`,
|
|
||||||
kind: 'field',
|
|
||||||
description: el.description || `Field ${el.name}`,
|
|
||||||
parent_type_id: parentType.id,
|
|
||||||
meta: {}
|
|
||||||
} as any);
|
|
||||||
}));
|
|
||||||
|
|
||||||
newType.structure_fields = output.elements.map((el, idx) => ({
|
|
||||||
field_name: el.name,
|
|
||||||
field_type_id: fieldTypes[idx].id,
|
|
||||||
required: false,
|
|
||||||
order: idx
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
await createType(newType as any);
|
|
||||||
toast.success("Type created successfully");
|
|
||||||
setIsBuilding(false);
|
|
||||||
loadTypes();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to create type", error);
|
|
||||||
toast.error("Failed to create type");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Group types by kind (exclude field types from display)
|
|
||||||
const groupedTypes = useMemo(() => {
|
|
||||||
const groups: Record<string, TypeDefinition[]> = {};
|
|
||||||
types
|
|
||||||
.filter(t => t.kind !== 'field') // Don't show field types in the main list
|
|
||||||
.forEach(t => {
|
|
||||||
const kind = t.kind || 'other';
|
|
||||||
if (!groups[kind]) groups[kind] = [];
|
|
||||||
groups[kind].push(t);
|
|
||||||
});
|
|
||||||
return groups;
|
|
||||||
}, [types]);
|
|
||||||
|
|
||||||
const kindOrder = ['primitive', 'enum', 'flags', 'structure', 'alias', 'other'];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="border-b px-6 py-4 flex items-center justify-between">
|
<div className="border-b px-6 py-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Types Playground</h1>
|
<h1 className="text-2xl font-bold">Types Editor</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Manage and preview your type definitions
|
Manage and preview your type definitions
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleCreateNew} size="sm">
|
<TypeEditorActions
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
actionIds={[
|
||||||
New Type
|
'types.new',
|
||||||
</Button>
|
'types.edit.visual',
|
||||||
|
'types.preview.toggle',
|
||||||
|
'types.delete',
|
||||||
|
'types.cancel',
|
||||||
|
'types.save'
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
@ -239,111 +110,29 @@ const TypesPlayground: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-12 gap-6 h-full">
|
<div className="grid grid-cols-12 gap-6 h-full">
|
||||||
{/* List Sidebar */}
|
{/* List Sidebar */}
|
||||||
<Card className={`flex flex-col min-h-0 ${isBuilding ? 'hidden' : 'col-span-3'}`}>
|
<TypesList
|
||||||
<CardHeader className="pb-3 border-b px-4 py-3">
|
types={types}
|
||||||
<CardTitle className="text-sm font-medium">Available Types</CardTitle>
|
selectedTypeId={selectedTypeId}
|
||||||
</CardHeader>
|
onSelect={handleSelectType}
|
||||||
<CardContent className="flex-1 min-h-0 p-0">
|
className={`col-span-3 ${isBuilding ? 'hidden' : ''}`}
|
||||||
<ScrollArea className="h-full">
|
/>
|
||||||
<div className="p-3 space-y-4">
|
|
||||||
{kindOrder.map(kind => {
|
|
||||||
const group = groupedTypes[kind];
|
|
||||||
if (!group || group.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={kind} className="space-y-1">
|
|
||||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-2 mb-2">
|
|
||||||
{kind}
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
{group.map(t => (
|
|
||||||
<button
|
|
||||||
key={t.id}
|
|
||||||
onClick={() => selectType(t)}
|
|
||||||
className={`w-full text-left px-2 py-1.5 rounded-md text-xs transition-colors flex items-center justify-between ${selectedTypeId === t.id
|
|
||||||
? 'bg-secondary text-secondary-foreground font-medium'
|
|
||||||
: 'hover:bg-muted text-muted-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="truncate">{t.name}</span>
|
|
||||||
{selectedTypeId === t.id && (
|
|
||||||
<div className="w-1 h-1 rounded-full bg-primary flex-shrink-0 ml-2" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className={`${isBuilding ? 'col-span-12' : 'col-span-9'} flex flex-col min-h-0 overflow-hidden`}>
|
<div className={`${isBuilding ? 'col-span-12' : 'col-span-9'} flex flex-col min-h-0 overflow-hidden`}>
|
||||||
{isBuilding ? (
|
<TypesEditor
|
||||||
<TypeBuilder
|
types={types}
|
||||||
onSave={handleBuilderSave}
|
selectedType={types.find(t => t.id === selectedTypeId) || null}
|
||||||
onCancel={() => { setIsBuilding(false); setBuilderInitialData(undefined); if (types.length > 0 && selectedTypeId) selectType(types.find(t => t.id === selectedTypeId)!); }}
|
isBuilding={isBuilding}
|
||||||
availableTypes={types}
|
onIsBuildingChange={setIsBuilding}
|
||||||
initialData={builderInitialData}
|
onSave={loadTypes}
|
||||||
/>
|
onDeleteRaw={deleteType}
|
||||||
) : (
|
/>
|
||||||
<Card className="flex flex-col h-full overflow-hidden">
|
|
||||||
{editedType ? (
|
|
||||||
<TypeRenderer
|
|
||||||
editedType={editedType}
|
|
||||||
types={types}
|
|
||||||
onSave={async (jsonSchemaString, uiSchemaString) => {
|
|
||||||
try {
|
|
||||||
const jsonSchema = JSON.parse(jsonSchemaString);
|
|
||||||
const uiSchema = JSON.parse(uiSchemaString);
|
|
||||||
await updateType(editedType.id, {
|
|
||||||
json_schema: jsonSchema,
|
|
||||||
meta: { ...editedType.meta, uiSchema }
|
|
||||||
});
|
|
||||||
toast.success("Type updated");
|
|
||||||
loadTypes();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
toast.error("Failed to update type");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDelete={async () => {
|
|
||||||
if (confirm("Are you sure you want to delete this type?")) {
|
|
||||||
try {
|
|
||||||
await deleteType(editedType.id);
|
|
||||||
toast.success("Type deleted");
|
|
||||||
setEditedType(null);
|
|
||||||
setSelectedTypeId(null);
|
|
||||||
loadTypes();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
toast.error("Failed to delete type");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onVisualEdit={handleEditVisual}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="h-full flex items-center justify-center text-muted-foreground flex-col gap-2 p-8 text-center">
|
|
||||||
<div className="bg-muted p-4 rounded-full mb-2">
|
|
||||||
<RefreshCw className="h-8 w-8 opacity-20" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium">No Type Selected</h3>
|
|
||||||
<p className="max-w-sm text-sm">Select a type from the sidebar to view its details, edit the schema, and preview the generated form.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TypesPlayground;
|
export default TypesPlayground;
|
||||||
|
|||||||
@ -31,7 +31,7 @@ export interface TypeDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const fetchTypes = async (options?: {
|
export const fetchTypes = async (options?: {
|
||||||
kind?: TypeDefinition['kind'] | string; // Allow string for flexibility or specific enum
|
kind?: TypeDefinition['kind'] | string;
|
||||||
parentTypeId?: string;
|
parentTypeId?: string;
|
||||||
visibility?: string;
|
visibility?: string;
|
||||||
}) => {
|
}) => {
|
||||||
@ -39,64 +39,39 @@ export const fetchTypes = async (options?: {
|
|||||||
const key = `types-${JSON.stringify(options || {})}`;
|
const key = `types-${JSON.stringify(options || {})}`;
|
||||||
|
|
||||||
return fetchWithDeduplication(key, async () => {
|
return fetchWithDeduplication(key, async () => {
|
||||||
let query = supabase
|
const params = new URLSearchParams();
|
||||||
.from('types')
|
if (options?.kind) params.append('kind', options.kind);
|
||||||
.select(`
|
if (options?.parentTypeId) params.append('parentTypeId', options.parentTypeId);
|
||||||
*,
|
if (options?.visibility) params.append('visibility', options.visibility);
|
||||||
structure_fields:type_structure_fields!type_structure_fields_structure_type_id_fkey(*)
|
|
||||||
`)
|
|
||||||
.order('name');
|
|
||||||
|
|
||||||
if (options?.kind) {
|
const { data: sessionData } = await supabase.auth.getSession();
|
||||||
query = query.eq('kind', options.kind as any);
|
const token = sessionData.session?.access_token;
|
||||||
}
|
const headers: HeadersInit = {};
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
if (options?.parentTypeId) {
|
const res = await fetch(`/api/types?${params.toString()}`, { headers });
|
||||||
query = query.eq('parent_type_id', options.parentTypeId);
|
if (!res.ok) throw new Error(`Failed to fetch types: ${res.statusText}`);
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.visibility) {
|
const data = await res.json();
|
||||||
query = query.eq('visibility', options.visibility as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, error } = await query;
|
|
||||||
if (error) throw error;
|
|
||||||
return data as TypeDefinition[];
|
return data as TypeDefinition[];
|
||||||
}, 1); // 5 min cache
|
}, 1); // 5 min cache (client side)
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchTypeById = async (id: string) => {
|
export const fetchTypeById = async (id: string) => {
|
||||||
const key = `type-${id}`;
|
const key = `type-${id}`;
|
||||||
return fetchWithDeduplication(key, async () => {
|
return fetchWithDeduplication(key, async () => {
|
||||||
// We can call the API endpoint or Supabase directly.
|
const { data: sessionData } = await supabase.auth.getSession();
|
||||||
// Using API might yield more enriched data if the server does heavy lifting.
|
const token = sessionData.session?.access_token;
|
||||||
// But for consistency with lib/db.ts, let's use supabase client directly or the API route?
|
const headers: HeadersInit = {};
|
||||||
// lib/db.ts uses supabase client directly mostly.
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
// However, the server does a nice join:
|
|
||||||
/*
|
|
||||||
.select(`
|
|
||||||
*,
|
|
||||||
enum_values:type_enum_values(*),
|
|
||||||
flag_values:type_flag_values(*),
|
|
||||||
structure_fields:type_structure_fields(*),
|
|
||||||
casts_from:type_casts!from_type_id(*),
|
|
||||||
casts_to:type_casts!to_type_id(*)
|
|
||||||
`)
|
|
||||||
*/
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('types')
|
|
||||||
.select(`
|
|
||||||
*,
|
|
||||||
enum_values:type_enum_values(*),
|
|
||||||
flag_values:type_flag_values(*),
|
|
||||||
structure_fields:type_structure_fields(*),
|
|
||||||
casts_from:type_casts!from_type_id(*),
|
|
||||||
casts_to:type_casts!to_type_id(*)
|
|
||||||
`)
|
|
||||||
.eq('id', id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) throw error;
|
const res = await fetch(`/api/types/${id}`, { headers });
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
throw new Error(`Failed to fetch type ${id}: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
return data as TypeDefinition;
|
return data as TypeDefinition;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { T, translate } from "@/i18n";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { invalidateUserPageCache } from "@/lib/db";
|
import { invalidateUserPageCache } from "@/lib/db";
|
||||||
import { PageActions } from "@/components/PageActions";
|
const PageActions = React.lazy(() => import("@/components/PageActions").then(module => ({ default: module.PageActions })));
|
||||||
import {
|
import {
|
||||||
FileText, Check, X, Calendar, FolderTree, EyeOff, Plus
|
FileText, Check, X, Calendar, FolderTree, EyeOff, Plus
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@ -356,19 +356,21 @@ export const UserPageDetails: React.FC<UserPageDetailsProps> = ({
|
|||||||
|
|
||||||
{/* PageActions - Only visible in View Mode (Edit Mode uses PageRibbonBar) */}
|
{/* PageActions - Only visible in View Mode (Edit Mode uses PageRibbonBar) */}
|
||||||
{!isEditMode && (
|
{!isEditMode && (
|
||||||
<PageActions
|
<React.Suspense fallback={<div className="h-9 w-24 bg-muted animate-pulse rounded" />}>
|
||||||
page={page}
|
<PageActions
|
||||||
isOwner={isOwner}
|
page={page}
|
||||||
isEditMode={isEditMode}
|
isOwner={isOwner}
|
||||||
onToggleEditMode={() => {
|
isEditMode={isEditMode}
|
||||||
onToggleEditMode();
|
onToggleEditMode={() => {
|
||||||
if (isEditMode) onWidgetRename(null);
|
onToggleEditMode();
|
||||||
}}
|
if (isEditMode) onWidgetRename(null);
|
||||||
onPageUpdate={onPageUpdate}
|
}}
|
||||||
onMetaUpdated={() => userId && page.slug && invalidateUserPageCache(userId, page.slug)} // Simple invalidation trigger
|
onPageUpdate={onPageUpdate}
|
||||||
templates={templates}
|
onMetaUpdated={() => userId && page.slug && invalidateUserPageCache(userId, page.slug)} // Simple invalidation trigger
|
||||||
onLoadTemplate={onLoadTemplate}
|
templates={templates}
|
||||||
/>
|
onLoadTemplate={onLoadTemplate}
|
||||||
|
/>
|
||||||
|
</React.Suspense>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -4,12 +4,17 @@ import validator from '@rjsf/validator-ajv8';
|
|||||||
import { TypeDefinition, fetchTypes } from '../types/db';
|
import { TypeDefinition, fetchTypes } from '../types/db';
|
||||||
import { generateSchemaForType, generateUiSchemaForType } from '@/lib/schema-utils';
|
import { generateSchemaForType, generateUiSchemaForType } from '@/lib/schema-utils';
|
||||||
import { customWidgets, customTemplates } from '../types/RJSFTemplates';
|
import { customWidgets, customTemplates } from '../types/RJSFTemplates';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Loader2 } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useLayout } from '@/contexts/LayoutContext';
|
import { useLayout } from '@/contexts/LayoutContext';
|
||||||
import { UpdatePageMetaCommand } from '@/lib/page-commands/commands';
|
import { UpdatePageMetaCommand } from '@/lib/page-commands/commands';
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
import { Accordion, AccordionContent, AccordionItem } from "@/components/ui/accordion";
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Loader2, Settings, ChevronDown } from 'lucide-react';
|
||||||
|
import { TypesEditor } from '../types/TypesEditor';
|
||||||
|
import { TypeEditorActions } from '../types/TypeEditorActions';
|
||||||
|
|
||||||
interface UserPageTypeFieldsProps {
|
interface UserPageTypeFieldsProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@ -77,6 +82,37 @@ export const UserPageTypeFields: React.FC<UserPageTypeFieldsProps> = ({
|
|||||||
await executeCommand(command);
|
await executeCommand(command);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [editingType, setEditingType] = useState<TypeDefinition | null>(null);
|
||||||
|
const [isBuilding, setIsBuilding] = useState(false);
|
||||||
|
|
||||||
|
// We need to import these dynamically or at top level?
|
||||||
|
// They are lazy loaded in UserPageEdit, so let's import them at top level for now,
|
||||||
|
// or use lazy here too if we want to code split.
|
||||||
|
// Since this component is already lazy loaded, direct import is fine.
|
||||||
|
// However, for circular dependency avoidance (if any), let's check.
|
||||||
|
// TypesEditor depends on TypesBuilder/Renderer.
|
||||||
|
// Let's assume direct import is safe.
|
||||||
|
|
||||||
|
const handleEditType = (e: React.MouseEvent, type: TypeDefinition) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingType(type);
|
||||||
|
setIsBuilding(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const handleTypeSave = () => {
|
||||||
|
// Invalidate types query to refresh schema
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['types', 'all'] });
|
||||||
|
|
||||||
|
// Also need to refresh the specific type in the list if name/desc changed
|
||||||
|
// assignedTypes prop comes from parent, which comes from fetching page.
|
||||||
|
// If type definition changes, the page might not need update, but the schema does.
|
||||||
|
// We might need to force re-render or refetch types.
|
||||||
|
// The useQuery['types', 'all'] invalidation should trigger re-render of this component
|
||||||
|
// because we use `allTypes` from that query.
|
||||||
|
};
|
||||||
|
|
||||||
if (assignedTypes.length === 0) return null;
|
if (assignedTypes.length === 0) return null;
|
||||||
|
|
||||||
if (typesLoading) {
|
if (typesLoading) {
|
||||||
@ -87,7 +123,7 @@ export const UserPageTypeFields: React.FC<UserPageTypeFieldsProps> = ({
|
|||||||
<div className="space-y-6 mt-8">
|
<div className="space-y-6 mt-8">
|
||||||
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Type Properties</h2>
|
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Type Properties</h2>
|
||||||
|
|
||||||
<Accordion type="multiple" className="w-full">
|
<Accordion type="multiple" className="w-full" defaultValue={assignedTypes.map(t => t.id)} key={assignedTypes.map(t => t.id).join(',')}>
|
||||||
{assignedTypes.map(type => {
|
{assignedTypes.map(type => {
|
||||||
const schema = generateSchemaForType(type.id, allTypes);
|
const schema = generateSchemaForType(type.id, allTypes);
|
||||||
// Ensure schema is object type for form rendering
|
// Ensure schema is object type for form rendering
|
||||||
@ -107,16 +143,39 @@ export const UserPageTypeFields: React.FC<UserPageTypeFieldsProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionItem key={type.id} value={type.id} className="border-b last:border-0 border-border">
|
<AccordionItem key={type.id} value={type.id} className="border-b last:border-0 border-border">
|
||||||
<AccordionTrigger className="px-4 py-3 hover:no-underline hover:bg-muted/50 transition-colors">
|
{/*
|
||||||
<div className="flex flex-col items-start text-left">
|
Fix for nested button warning:
|
||||||
<span className="font-semibold text-sm tracking-tight">{type.name}</span>
|
Use an overlay pattern where the Trigger is absolute and sits behind the content.
|
||||||
{type.description && (
|
The content is pointer-events-none, except for the interactive buttons which are pointer-events-auto.
|
||||||
<span className="text-[10px] text-muted-foreground font-normal mt-0.5 max-w-[200px] truncate">
|
*/}
|
||||||
{type.description}
|
<AccordionPrimitive.Header className="relative flex items-center group hover:bg-muted/50 transition-colors">
|
||||||
</span>
|
<AccordionPrimitive.Trigger className="peer absolute inset-0 w-full h-full z-0 cursor-pointer" />
|
||||||
)}
|
|
||||||
|
<div className="relative z-10 flex items-center justify-between w-full px-4 py-3 pointer-events-none">
|
||||||
|
<div className="flex flex-col items-start text-left">
|
||||||
|
<span className="font-semibold text-sm tracking-tight">{type.name}</span>
|
||||||
|
{type.description && (
|
||||||
|
<span className="text-[10px] text-muted-foreground font-normal mt-0.5 max-w-[200px] truncate">
|
||||||
|
{type.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{isEditMode && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity mr-2 pointer-events-auto"
|
||||||
|
onClick={(e) => handleEditType(e, type)}
|
||||||
|
title="Edit Type Definition"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200 text-muted-foreground peer-data-[state=open]:rotate-180" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionTrigger>
|
</AccordionPrimitive.Header>
|
||||||
<AccordionContent className="px-4 pb-4 pt-2">
|
<AccordionContent className="px-4 pb-4 pt-2">
|
||||||
<Form
|
<Form
|
||||||
schema={schema}
|
schema={schema}
|
||||||
@ -143,6 +202,41 @@ export const UserPageTypeFields: React.FC<UserPageTypeFieldsProps> = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
|
{/* Edit Type Dialog */}
|
||||||
|
<Dialog open={!!editingType} onOpenChange={(open) => !open && setEditingType(null)}>
|
||||||
|
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
|
||||||
|
<DialogHeader className="px-6 py-4 border-b flex flex-row items-center justify-between shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DialogTitle>Edit Type: {editingType?.name}</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mr-8">
|
||||||
|
<TypeEditorActions
|
||||||
|
actionIds={[
|
||||||
|
'types.save',
|
||||||
|
'types.edit.visual',
|
||||||
|
'types.preview.toggle',
|
||||||
|
'types.cancel'
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden bg-muted/10 p-4">
|
||||||
|
{editingType && (
|
||||||
|
<TypesEditor
|
||||||
|
types={allTypes}
|
||||||
|
selectedType={editingType}
|
||||||
|
isBuilding={isBuilding}
|
||||||
|
onIsBuildingChange={setIsBuilding}
|
||||||
|
onSave={handleTypeSave}
|
||||||
|
onDeleteRaw={async () => {
|
||||||
|
toast.error("Deleting currently used types is not recommended here.");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
@ -67,6 +68,16 @@ interface Page {
|
|||||||
slug: string;
|
slug: string;
|
||||||
} | null;
|
} | null;
|
||||||
meta?: any;
|
meta?: any;
|
||||||
|
categories?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}[];
|
||||||
|
category_paths?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}[][];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageRibbonBarProps {
|
interface PageRibbonBarProps {
|
||||||
@ -221,6 +232,17 @@ export const PageRibbonBar = ({
|
|||||||
hasTypeFields
|
hasTypeFields
|
||||||
}: PageRibbonBarProps) => {
|
}: PageRibbonBarProps) => {
|
||||||
const { executeCommand, saveToApi, loadPageLayout, clearHistory } = useLayout();
|
const { executeCommand, saveToApi, loadPageLayout, clearHistory } = useLayout();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { orgSlug } = useParams();
|
||||||
|
|
||||||
|
const handleOpenTypes = React.useCallback(() => {
|
||||||
|
if (orgSlug) {
|
||||||
|
navigate(`/org/${orgSlug}/types-editor`);
|
||||||
|
} else {
|
||||||
|
navigate('/types-editor');
|
||||||
|
}
|
||||||
|
}, [navigate, orgSlug]);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'page' | 'widgets' | 'layouts' | 'view' | 'advanced'>('page');
|
const [activeTab, setActiveTab] = useState<'page' | 'widgets' | 'layouts' | 'view' | 'advanced'>('page');
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -734,6 +756,12 @@ export const PageRibbonBar = ({
|
|||||||
{activeTab === 'advanced' && (
|
{activeTab === 'advanced' && (
|
||||||
<>
|
<>
|
||||||
<RibbonGroup label="Developer">
|
<RibbonGroup label="Developer">
|
||||||
|
<RibbonItemLarge
|
||||||
|
icon={Type}
|
||||||
|
label="Types"
|
||||||
|
onClick={handleOpenTypes}
|
||||||
|
iconColor="text-indigo-600 dark:text-indigo-400"
|
||||||
|
/>
|
||||||
<RibbonItemLarge
|
<RibbonItemLarge
|
||||||
icon={FileJson}
|
icon={FileJson}
|
||||||
label="Dump JSON"
|
label="Dump JSON"
|
||||||
@ -753,7 +781,7 @@ export const PageRibbonBar = ({
|
|||||||
onClose={() => setShowCategoryManager(false)}
|
onClose={() => setShowCategoryManager(false)}
|
||||||
currentPageId={page.id}
|
currentPageId={page.id}
|
||||||
currentPageMeta={page.meta}
|
currentPageMeta={page.meta}
|
||||||
onPageMetaUpdate={async (newMeta) => {
|
onPageMetaUpdate={async (newMeta, newCategories) => {
|
||||||
// Use UpdatePageMetaCommand for undo/redo support
|
// Use UpdatePageMetaCommand for undo/redo support
|
||||||
const pageId = `page-${page.id}`;
|
const pageId = `page-${page.id}`;
|
||||||
|
|
||||||
@ -767,12 +795,28 @@ export const PageRibbonBar = ({
|
|||||||
try {
|
try {
|
||||||
await executeCommand(new UpdatePageMetaCommand(
|
await executeCommand(new UpdatePageMetaCommand(
|
||||||
pageId,
|
pageId,
|
||||||
oldMeta,
|
{ meta: oldMeta },
|
||||||
newMeta,
|
{ meta: newMeta },
|
||||||
(meta) => {
|
(meta) => {
|
||||||
// Update local state for immediate feedback
|
// Update local state for immediate feedback
|
||||||
// Note: LayoutContext also updates its pendingMetadata
|
// Note: LayoutContext also updates its pendingMetadata
|
||||||
onPageUpdate({ ...page, meta: { ...page.meta, ...meta } });
|
// The command callback receives the payload we passed ({ meta: newMeta })
|
||||||
|
// We need to extract the inner meta for local state update if needed,
|
||||||
|
// but onPageUpdate expects the full page object.
|
||||||
|
// Since we wrapped it, 'meta' here is { meta: { ... } }
|
||||||
|
// So we need to access meta.meta
|
||||||
|
if (meta.meta) {
|
||||||
|
const updatedPage = { ...page, meta: { ...page.meta, ...meta.meta } };
|
||||||
|
|
||||||
|
// If we have resolved categories, update the page categories to show valid data immediately
|
||||||
|
// We also clear category_paths to force fallback to 'categories' in UserPageDetails
|
||||||
|
if (newCategories) {
|
||||||
|
updatedPage.categories = newCategories;
|
||||||
|
updatedPage.category_paths = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
onPageUpdate(updatedPage);
|
||||||
|
}
|
||||||
if (onMetaUpdated) onMetaUpdated();
|
if (onMetaUpdated) onMetaUpdated();
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
|
|||||||
@ -17,7 +17,7 @@ interface CategoryManagerProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
currentPageId?: string; // If provided, allows linking page to category
|
currentPageId?: string; // If provided, allows linking page to category
|
||||||
currentPageMeta?: any;
|
currentPageMeta?: any;
|
||||||
onPageMetaUpdate?: (newMeta: any) => void;
|
onPageMetaUpdate?: (newMeta: any, newCategories?: Category[]) => void;
|
||||||
filterByType?: string; // Filter categories by meta.type (e.g., 'layout', 'page', 'email')
|
filterByType?: string; // Filter categories by meta.type (e.g., 'layout', 'page', 'email')
|
||||||
defaultMetaType?: string; // Default type to set in meta when creating new categories
|
defaultMetaType?: string; // Default type to set in meta when creating new categories
|
||||||
}
|
}
|
||||||
@ -147,6 +147,37 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper to find category in tree
|
||||||
|
const findCategory = (id: string, cats: Category[]): Category | null => {
|
||||||
|
for (const cat of cats) {
|
||||||
|
if (cat.id === id) return cat;
|
||||||
|
if (cat.children) {
|
||||||
|
const found = cat.children.find(rel => rel.child.id === id)?.child;
|
||||||
|
if (found) return found; // Direct child match
|
||||||
|
|
||||||
|
// Deep search in children's children not strictly needed if flattened?
|
||||||
|
// Using recursive search on children
|
||||||
|
const deep = cat.children.map(c => findCategory(id, [c.child])).find(c => c);
|
||||||
|
if (deep) return deep;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flatten categories for easy lookup
|
||||||
|
const getAllCategoriesFlattened = (cats: Category[]): Category[] => {
|
||||||
|
let flat: Category[] = [];
|
||||||
|
cats.forEach(c => {
|
||||||
|
flat.push(c);
|
||||||
|
if (c.children) {
|
||||||
|
c.children.forEach(childRel => {
|
||||||
|
flat = [...flat, ...getAllCategoriesFlattened([childRel.child])];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return flat;
|
||||||
|
};
|
||||||
|
|
||||||
const handleLinkPage = async () => {
|
const handleLinkPage = async () => {
|
||||||
if (!currentPageId || !selectedCategoryId) return;
|
if (!currentPageId || !selectedCategoryId) return;
|
||||||
|
|
||||||
@ -158,9 +189,13 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
|
|||||||
const newIds = [...currentIds, selectedCategoryId];
|
const newIds = [...currentIds, selectedCategoryId];
|
||||||
const newMeta = { ...currentPageMeta, categoryIds: newIds, categoryId: null };
|
const newMeta = { ...currentPageMeta, categoryIds: newIds, categoryId: null };
|
||||||
|
|
||||||
|
// Resolve category objects
|
||||||
|
const allCats = getAllCategoriesFlattened(categories);
|
||||||
|
const resolvedCategories = newIds.map(id => findCategory(id, categories) || allCats.find(c => c.id === id)).filter(Boolean) as Category[];
|
||||||
|
|
||||||
// Use callback if provided, otherwise fall back to updatePageMeta
|
// Use callback if provided, otherwise fall back to updatePageMeta
|
||||||
if (onPageMetaUpdate) {
|
if (onPageMetaUpdate) {
|
||||||
await onPageMetaUpdate(newMeta);
|
await onPageMetaUpdate(newMeta, resolvedCategories);
|
||||||
} else {
|
} else {
|
||||||
await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null });
|
await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null });
|
||||||
}
|
}
|
||||||
@ -185,9 +220,13 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
|
|||||||
const newIds = currentIds.filter(id => id !== selectedCategoryId);
|
const newIds = currentIds.filter(id => id !== selectedCategoryId);
|
||||||
const newMeta = { ...currentPageMeta, categoryIds: newIds, categoryId: null };
|
const newMeta = { ...currentPageMeta, categoryIds: newIds, categoryId: null };
|
||||||
|
|
||||||
|
// Resolve category objects
|
||||||
|
const allCats = getAllCategoriesFlattened(categories);
|
||||||
|
const resolvedCategories = newIds.map(id => findCategory(id, categories) || allCats.find(c => c.id === id)).filter(Boolean) as Category[];
|
||||||
|
|
||||||
// Use callback if provided, otherwise fall back to updatePageMeta
|
// Use callback if provided, otherwise fall back to updatePageMeta
|
||||||
if (onPageMetaUpdate) {
|
if (onPageMetaUpdate) {
|
||||||
await onPageMetaUpdate(newMeta);
|
await onPageMetaUpdate(newMeta, resolvedCategories);
|
||||||
} else {
|
} else {
|
||||||
await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null });
|
await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { Gallery } from '@/pages/Post/renderers/components/Gallery';
|
import { Gallery } from '@/pages/Post/renderers/components/Gallery';
|
||||||
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
|
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
|
||||||
import SmartLightbox from '@/pages/Post/components/SmartLightbox';
|
import SmartLightbox from '@/pages/Post/components/SmartLightbox';
|
||||||
import { T } from '@/i18n';
|
import { T, translate } from '@/i18n';
|
||||||
import { ImageIcon, Plus, Settings } from 'lucide-react';
|
import { ImageIcon, Plus, Settings, Upload, Loader2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { PostMediaItem } from '@/pages/Post/types';
|
import { PostMediaItem } from '@/pages/Post/types';
|
||||||
import { isVideoType, normalizeMediaType, detectMediaType } from '@/lib/mediaRegistry';
|
import { isVideoType, normalizeMediaType, detectMediaType } from '@/lib/mediaRegistry';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { uploadImage } from '@/lib/uploadUtils';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
const { fetchMediaItemsByIds } = await import('@/lib/db');
|
const { fetchMediaItemsByIds } = await import('@/lib/db');
|
||||||
|
|
||||||
interface GalleryWidgetProps {
|
interface GalleryWidgetProps {
|
||||||
@ -52,6 +56,10 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
|
|||||||
const [showPicker, setShowPicker] = useState(false);
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
|
|
||||||
|
// Drag and drop state
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
// Sync local state with props
|
// Sync local state with props
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const normalizedProps = normalizePictureIds(propPictureIds);
|
const normalizedProps = normalizePictureIds(propPictureIds);
|
||||||
@ -90,7 +98,9 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
|
|||||||
setMediaItems(postMediaItems);
|
setMediaItems(postMediaItems);
|
||||||
|
|
||||||
// Always set first item as selected when items change
|
// Always set first item as selected when items change
|
||||||
if (postMediaItems.length > 0) {
|
// Only if we don't have a selected item or the selected item is no longer in the list
|
||||||
|
// preserving selection across updates if possible is nice, but simple logic is safer
|
||||||
|
if (postMediaItems.length > 0 && !selectedItem) {
|
||||||
setSelectedItem(postMediaItems[0]);
|
setSelectedItem(postMediaItems[0]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -136,19 +146,136 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
|
|||||||
console.log('Open in wizard:', selectedItem);
|
console.log('Open in wizard:', selectedItem);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Drag and Drop Handlers
|
||||||
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
|
if (!isEditMode) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (e.dataTransfer.types.includes('Files')) {
|
||||||
|
setIsDragging(true);
|
||||||
|
}
|
||||||
|
}, [isEditMode]);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
if (!isEditMode) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsDragging(false);
|
||||||
|
}, [isEditMode]);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
if (!isEditMode) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
}, [isEditMode]);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||||
|
if (!isEditMode) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
const imageFiles = files.filter(f => f.type.startsWith('image/'));
|
||||||
|
|
||||||
|
if (imageFiles.length === 0) {
|
||||||
|
if (files.length > 0) {
|
||||||
|
toast.error(translate('Please drop image files'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await processDroppedImages(imageFiles);
|
||||||
|
}, [isEditMode, pictureIds]);
|
||||||
|
|
||||||
|
const processDroppedImages = async (files: File[]) => {
|
||||||
|
setIsUploading(true);
|
||||||
|
try {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
toast.error(translate('You must be logged in to upload images'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPictureIds: string[] = [];
|
||||||
|
let successfulUploads = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
// Upload
|
||||||
|
const { publicUrl, meta } = await uploadImage(file, user.id);
|
||||||
|
|
||||||
|
// Create Record
|
||||||
|
const { data: pictureData, error: insertError } = await supabase
|
||||||
|
.from('pictures')
|
||||||
|
.insert({
|
||||||
|
user_id: user.id,
|
||||||
|
title: file.name.split('.')[0] || 'Uploaded Image',
|
||||||
|
description: null,
|
||||||
|
image_url: publicUrl,
|
||||||
|
type: 'supabase-image',
|
||||||
|
meta: meta || {},
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (insertError) throw insertError;
|
||||||
|
|
||||||
|
if (pictureData) {
|
||||||
|
newPictureIds.push(pictureData.id);
|
||||||
|
successfulUploads++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to upload ${file.name}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPictureIds.length > 0) {
|
||||||
|
const updatedIds = [...(pictureIds || []), ...newPictureIds];
|
||||||
|
handlePicturesSelected(updatedIds);
|
||||||
|
toast.success(translate(`Uploaded ${successfulUploads} images`));
|
||||||
|
} else {
|
||||||
|
toast.error(translate('Failed to upload images'));
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing dropped images:', error);
|
||||||
|
toast.error(translate('Error processing uploads'));
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Empty state
|
// Empty state
|
||||||
if (!pictureIds || pictureIds.length === 0) {
|
if (!pictureIds || pictureIds.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full min-h-[400px] bg-muted/30 border-1 border-dashed">
|
<div
|
||||||
|
className={`flex flex-col items-center justify-center h-full min-h-[400px] bg-muted/30 border-1 border-dashed relative transition-all duration-200 ${isDragging ? 'ring-4 ring-primary ring-inset rounded-lg scale-[0.99]' : ''}`}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
<ImageIcon className="w-16 h-16 text-muted-foreground mb-4" />
|
<ImageIcon className="w-16 h-16 text-muted-foreground mb-4" />
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
<T>No pictures selected</T>
|
<T>No pictures selected</T>
|
||||||
</p>
|
</p>
|
||||||
{isEditMode && (
|
{isEditMode && (
|
||||||
<Button onClick={() => setShowPicker(true)} variant="outline">
|
<>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Button onClick={() => setShowPicker(true)} variant="outline">
|
||||||
<T>Select Pictures</T>
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
</Button>
|
<T>Select Pictures</T>
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground mt-4">
|
||||||
|
<T>or drag and drop images here</T>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{showPicker && (
|
{showPicker && (
|
||||||
<ImagePickerDialog
|
<ImagePickerDialog
|
||||||
@ -159,12 +286,33 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
|
|||||||
currentValues={pictureIds}
|
currentValues={pictureIds}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Drop Overlay for Empty State */}
|
||||||
|
{isDragging && (
|
||||||
|
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex flex-col items-center justify-center rounded-lg z-50 border-2 border-dashed border-primary pointer-events-none">
|
||||||
|
<div className="bg-primary/10 p-6 rounded-full mb-4">
|
||||||
|
<Upload className="h-10 w-10 text-primary animate-bounce" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-semibold text-primary">
|
||||||
|
<T>Drop images to add</T>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Uploading Overlay */}
|
||||||
|
{isUploading && (
|
||||||
|
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex flex-col items-center justify-center rounded-lg z-50">
|
||||||
|
<Loader2 className="h-10 w-10 text-primary animate-spin mb-4" />
|
||||||
|
<p className="text-lg font-semibold text-foreground">
|
||||||
|
<T>Uploading images...</T>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (loading) {
|
if (loading && !isUploading) { // Show normal loading only if not uploading (upload shows its own overlay)
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full min-h-[400px]">
|
<div className="flex items-center justify-center h-full min-h-[400px]">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
@ -174,11 +322,26 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
|
|||||||
|
|
||||||
// Gallery view
|
// Gallery view
|
||||||
if (!selectedItem || mediaItems.length === 0) {
|
if (!selectedItem || mediaItems.length === 0) {
|
||||||
return null;
|
// Fallback if IDs exist but fetch failed or returned nothing
|
||||||
|
// Should probably show empty state or error
|
||||||
|
// But for now, null or empty state
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full min-h-[400px] bg-muted/30 border-1 border-dashed">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
<T>Loading gallery items...</T>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full aspect-video flex flex-col">
|
<div
|
||||||
|
className={`relative w-full aspect-video flex flex-col transition-all duration-200 ${isDragging ? 'ring-4 ring-primary ring-inset rounded-lg scale-[0.99]' : ''}`}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
{/* Edit Mode Controls */}
|
{/* Edit Mode Controls */}
|
||||||
{isEditMode && (
|
{isEditMode && (
|
||||||
<div className="absolute top-2 right-2 z-50">
|
<div className="absolute top-2 right-2 z-50">
|
||||||
@ -213,6 +376,28 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Drop Overlay */}
|
||||||
|
{isDragging && (
|
||||||
|
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex flex-col items-center justify-center rounded-lg z-50 border-2 border-dashed border-primary pointer-events-none">
|
||||||
|
<div className="bg-primary/10 p-6 rounded-full mb-4">
|
||||||
|
<Upload className="h-10 w-10 text-primary animate-bounce" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-semibold text-primary">
|
||||||
|
<T>Drop images to add</T>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Uploading Overlay */}
|
||||||
|
{isUploading && (
|
||||||
|
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex flex-col items-center justify-center rounded-lg z-50">
|
||||||
|
<Loader2 className="h-10 w-10 text-primary animate-spin mb-4" />
|
||||||
|
<p className="text-lg font-semibold text-foreground">
|
||||||
|
<T>Uploading images...</T>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Image Picker Dialog */}
|
{/* Image Picker Dialog */}
|
||||||
{showPicker && (
|
{showPicker && (
|
||||||
<ImagePickerDialog
|
<ImagePickerDialog
|
||||||
|
|||||||
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 {
|
interface HtmlWidgetProps {
|
||||||
src?: string;
|
src?: string;
|
||||||
html?: string;
|
html?: string;
|
||||||
|
content?: string; // New content prop
|
||||||
|
variables?: string | Record<string, any>; // JSON string or object
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
isEditMode?: boolean;
|
isEditMode?: boolean;
|
||||||
@ -18,14 +20,18 @@ interface HtmlWidgetProps {
|
|||||||
export const HtmlWidget: React.FC<HtmlWidgetProps> = ({
|
export const HtmlWidget: React.FC<HtmlWidgetProps> = ({
|
||||||
src,
|
src,
|
||||||
html: initialHtml,
|
html: initialHtml,
|
||||||
|
content: initialContent,
|
||||||
|
variables,
|
||||||
className = '',
|
className = '',
|
||||||
style,
|
style,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
onPropsChange,
|
onPropsChange,
|
||||||
...restProps
|
...restProps
|
||||||
}) => {
|
}) => {
|
||||||
const [content, setContent] = useState<string | null>(initialHtml || null);
|
// Prioritize content over html
|
||||||
const [processedContent, setProcessedContent] = useState<string | null>(initialHtml || null);
|
const sourceHtml = initialContent || initialHtml;
|
||||||
|
const [content, setContent] = useState<string | null>(sourceHtml || null);
|
||||||
|
const [processedContent, setProcessedContent] = useState<string | null>(sourceHtml || null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@ -36,8 +42,8 @@ export const HtmlWidget: React.FC<HtmlWidgetProps> = ({
|
|||||||
const [currentImageValue, setCurrentImageValue] = useState<string | null>(null);
|
const [currentImageValue, setCurrentImageValue] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialHtml) {
|
if (sourceHtml) {
|
||||||
setContent(initialHtml);
|
setContent(sourceHtml);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,7 +64,7 @@ export const HtmlWidget: React.FC<HtmlWidgetProps> = ({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [src, initialHtml]);
|
}, [src, sourceHtml]);
|
||||||
|
|
||||||
// Apply substitutions whenever content or props change
|
// Apply substitutions whenever content or props change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -69,8 +75,30 @@ export const HtmlWidget: React.FC<HtmlWidgetProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Pre-process props for markdown
|
// Parse variables if needed
|
||||||
const finalProps = { ...restProps };
|
let parsedVariables: Record<string, any> = {};
|
||||||
|
if (typeof variables === 'string') {
|
||||||
|
try {
|
||||||
|
parsedVariables = JSON.parse(variables);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to parse variables JSON', e);
|
||||||
|
}
|
||||||
|
} else if (typeof variables === 'object') {
|
||||||
|
parsedVariables = variables || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge props: contextVariables + restProps + variables
|
||||||
|
// Priority:
|
||||||
|
// 1. variables prop (explicitly set for this widget)
|
||||||
|
// 2. restProps (legacy or passed props)
|
||||||
|
// 3. contextVariables (global page variables) - LEAST priority to allow override?
|
||||||
|
// Actually, usually local overrides global. So contextVariables should be base.
|
||||||
|
|
||||||
|
const finalProps = {
|
||||||
|
...((restProps as any).contextVariables || {}), // From context
|
||||||
|
...restProps,
|
||||||
|
...parsedVariables
|
||||||
|
};
|
||||||
const markdownKeys = new Set<string>();
|
const markdownKeys = new Set<string>();
|
||||||
|
|
||||||
if (finalProps.widgetDefId) {
|
if (finalProps.widgetDefId) {
|
||||||
@ -173,7 +201,7 @@ export const HtmlWidget: React.FC<HtmlWidgetProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
processContent();
|
processContent();
|
||||||
}, [content, restProps, isEditMode]);
|
}, [content, restProps, variables, isEditMode]);
|
||||||
|
|
||||||
|
|
||||||
// Event Delegation for Inline Editing
|
// Event Delegation for Inline Editing
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import PhotoCard from '@/components/PhotoCard';
|
import PhotoCard from '@/components/PhotoCard';
|
||||||
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
|
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
|
||||||
import { T } from '@/i18n';
|
import { T, translate } from '@/i18n';
|
||||||
import { ImageIcon, Plus } from 'lucide-react';
|
import { ImageIcon, Plus, Upload, Loader2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { uploadImage } from '@/lib/uploadUtils';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface PhotoCardWidgetProps {
|
interface PhotoCardWidgetProps {
|
||||||
isEditMode?: boolean;
|
isEditMode?: boolean;
|
||||||
@ -30,6 +32,7 @@ interface Picture {
|
|||||||
interface UserProfile {
|
interface UserProfile {
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
|
avatar_url?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
||||||
@ -48,6 +51,10 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
|
// Drag and drop state
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
// Sync local state with props
|
// Sync local state with props
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPictureId(propPictureId);
|
setPictureId(propPictureId);
|
||||||
@ -63,7 +70,31 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
|||||||
const fetchPictureData = async () => {
|
const fetchPictureData = async () => {
|
||||||
if (!pictureId) return;
|
if (!pictureId) return;
|
||||||
|
|
||||||
// Validate that pictureId is a UUID, not an image URL
|
// Check if pictureId is a URL
|
||||||
|
const isUrl = pictureId.startsWith('http://') || pictureId.startsWith('https://');
|
||||||
|
|
||||||
|
if (isUrl) {
|
||||||
|
// Create dummy picture object for external URL
|
||||||
|
setPicture({
|
||||||
|
id: pictureId,
|
||||||
|
title: 'External Image',
|
||||||
|
description: null,
|
||||||
|
image_url: pictureId,
|
||||||
|
likes_count: 0,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
user_id: 'external'
|
||||||
|
});
|
||||||
|
setUserProfile({
|
||||||
|
display_name: 'External Source',
|
||||||
|
username: 'external',
|
||||||
|
avatar_url: null
|
||||||
|
});
|
||||||
|
setCommentsCount(0);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that pictureId is a UUID
|
||||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
if (!uuidRegex.test(pictureId)) {
|
if (!uuidRegex.test(pictureId)) {
|
||||||
console.error('Invalid picture ID format. Expected UUID, got:', pictureId);
|
console.error('Invalid picture ID format. Expected UUID, got:', pictureId);
|
||||||
@ -87,7 +118,7 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
|||||||
// Fetch user profile
|
// Fetch user profile
|
||||||
const { data: profileData } = await supabase
|
const { data: profileData } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('display_name, username')
|
.select('display_name, username, avatar_url')
|
||||||
.eq('user_id', pictureData.user_id)
|
.eq('user_id', pictureData.user_id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
@ -127,11 +158,125 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!pictureId) {
|
// Drag and Drop Handlers
|
||||||
return (
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
<>
|
if (!isEditMode) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Check if dragging files
|
||||||
|
if (e.dataTransfer.types.includes('Files')) {
|
||||||
|
setIsDragging(true);
|
||||||
|
}
|
||||||
|
}, [isEditMode]);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
if (!isEditMode) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Only set dragging to false if we're leaving the main container
|
||||||
|
// This is a simple check, typically you'd check relatedTarget
|
||||||
|
if (e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsDragging(false);
|
||||||
|
}, [isEditMode]);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
if (!isEditMode) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Important to allow drop
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
}, [isEditMode]);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||||
|
if (!isEditMode) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
|
||||||
|
// Filter for images
|
||||||
|
const imageFiles = files.filter(f => f.type.startsWith('image/'));
|
||||||
|
|
||||||
|
if (imageFiles.length === 0) {
|
||||||
|
if (files.length > 0) {
|
||||||
|
toast.error(translate('Please drop an image file'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = imageFiles[0];
|
||||||
|
await processDroppedImage(file);
|
||||||
|
}, [isEditMode]); // Removed processDroppedImage from deps to avoid circular dependency if define outside
|
||||||
|
|
||||||
|
const processDroppedImage = async (file: File) => {
|
||||||
|
setIsUploading(true);
|
||||||
|
try {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
toast.error(translate('You must be logged in to upload images'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Upload to storage
|
||||||
|
const { publicUrl, meta } = await uploadImage(file, user.id);
|
||||||
|
|
||||||
|
// 2. Create picture record
|
||||||
|
const { data: pictureData, error: insertError } = await supabase
|
||||||
|
.from('pictures')
|
||||||
|
.insert({
|
||||||
|
user_id: user.id,
|
||||||
|
title: file.name.split('.')[0] || 'Uploaded Image',
|
||||||
|
description: null,
|
||||||
|
image_url: publicUrl,
|
||||||
|
type: 'supabase-image',
|
||||||
|
meta: meta || {},
|
||||||
|
// We don't link to a post here yet, it's just a picture in their library
|
||||||
|
// unless we want to auto-create a post?
|
||||||
|
// For a widget, we usually just point to the picture.
|
||||||
|
// The widget displays a "Picture", so we need a picture record.
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (insertError) throw insertError;
|
||||||
|
|
||||||
|
if (pictureData) {
|
||||||
|
// 3. Update widget props
|
||||||
|
handleSelectPicture(pictureData.id);
|
||||||
|
toast.success(translate('Image uploaded successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading dropped image:', error);
|
||||||
|
toast.error(translate('Failed to upload image'));
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render content based on state
|
||||||
|
const renderContent = () => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card border rounded-lg p-8 text-center min-h-[300px] flex flex-col items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<T>Loading picture...</T>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pictureId || !picture) {
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className={`bg-card border rounded-lg p-8 text-center ${isEditMode ? 'cursor-pointer hover:border-primary hover:bg-accent/50 transition-colors' : ''
|
className={`bg-card border rounded-lg p-8 text-center min-h-[300px] flex flex-col items-center justify-center relative ${isEditMode ? 'cursor-pointer hover:border-primary hover:bg-accent/50 transition-colors' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={handleOpenPicker}
|
onClick={handleOpenPicker}
|
||||||
>
|
>
|
||||||
@ -140,64 +285,32 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
|||||||
{isEditMode ? (
|
{isEditMode ? (
|
||||||
<T>No picture selected</T>
|
<T>No picture selected</T>
|
||||||
) : (
|
) : (
|
||||||
<T>No picture selected</T>
|
<T>Empty Photo Card</T>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{isEditMode && (
|
{isEditMode && (
|
||||||
<Button variant="outline" size="sm" onClick={(e) => {
|
<>
|
||||||
e.stopPropagation();
|
<Button variant="outline" size="sm" onClick={(e) => {
|
||||||
handleOpenPicker();
|
e.stopPropagation();
|
||||||
}}>
|
handleOpenPicker();
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
}}>
|
||||||
<T>Select Picture</T>
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
<T>Select Picture</T>
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground mt-4">
|
||||||
|
<T>or drag and drop an image here</T>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{isEditMode && (
|
const authorName = userProfile?.display_name || userProfile?.username || `User ${picture.user_id.slice(0, 8)}`;
|
||||||
<ImagePickerDialog
|
const isExternal = picture.user_id === 'external';
|
||||||
isOpen={pickerOpen}
|
|
||||||
onClose={() => setPickerOpen(false)}
|
|
||||||
onSelect={handleSelectPicture}
|
|
||||||
currentValue={pictureId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border rounded-lg p-8 text-center">
|
<div className="w-full relative">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
<T>Loading picture...</T>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!picture) {
|
|
||||||
return (
|
|
||||||
<div className="bg-card border rounded-lg p-8 text-center">
|
|
||||||
<ImageIcon className="h-12 w-12 mx-auto mb-4 text-destructive opacity-50" />
|
|
||||||
<p className="text-sm text-muted-foreground mb-2">
|
|
||||||
<T>Picture not found</T>
|
|
||||||
</p>
|
|
||||||
{isEditMode && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
<T>Please select a new picture using the image picker</T>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const authorName = userProfile?.display_name || userProfile?.username || `User ${picture.user_id.slice(0, 8)}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="w-full">
|
|
||||||
<PhotoCard
|
<PhotoCard
|
||||||
pictureId={picture.id}
|
pictureId={picture.id}
|
||||||
image={picture.image_url}
|
image={picture.image_url}
|
||||||
@ -209,15 +322,70 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
|||||||
isLiked={isLiked}
|
isLiked={isLiked}
|
||||||
description={picture.description}
|
description={picture.description}
|
||||||
onClick={(id) => {
|
onClick={(id) => {
|
||||||
// Navigate to post page
|
if (isExternal) {
|
||||||
window.location.href = `/post/${id}`;
|
window.open(picture.image_url, '_blank');
|
||||||
|
} else {
|
||||||
|
// Navigate to post page
|
||||||
|
window.location.href = `/post/${id}`;
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onLike={handleLike}
|
onLike={handleLike}
|
||||||
variant={contentDisplay === 'overlay' || contentDisplay === 'overlay-always' ? 'grid' : 'feed'}
|
variant={contentDisplay === 'overlay' || contentDisplay === 'overlay-always' ? 'grid' : 'feed'}
|
||||||
overlayMode={contentDisplay === 'overlay-always' ? 'always' : 'hover'}
|
overlayMode={contentDisplay === 'overlay-always' ? 'always' : 'hover'}
|
||||||
showHeader={showHeader}
|
showHeader={showHeader}
|
||||||
showContent={showFooter}
|
showContent={showFooter}
|
||||||
|
isExternal={isExternal}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Overlay trigger for editing existing image */}
|
||||||
|
{isEditMode && !isDragging && (
|
||||||
|
<div
|
||||||
|
className="absolute top-2 right-2 p-2 bg-black/50 rounded-full cursor-pointer hover:bg-black/70 transition-colors z-10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleOpenPicker();
|
||||||
|
}}
|
||||||
|
title={translate("Change Picture")}
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`w-full relative transition-all duration-200 ${isDragging ? 'ring-4 ring-primary ring-inset rounded-lg scale-[0.99]' : ''}`}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{renderContent()}
|
||||||
|
|
||||||
|
{/* Drop Overlay */}
|
||||||
|
{isDragging && (
|
||||||
|
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex flex-col items-center justify-center rounded-lg z-50 border-2 border-dashed border-primary pointer-events-none">
|
||||||
|
<div className="bg-primary/10 p-6 rounded-full mb-4">
|
||||||
|
<Upload className="h-10 w-10 text-primary animate-bounce" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-semibold text-primary">
|
||||||
|
<T>Drop image to upload</T>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Uploading Overlay */}
|
||||||
|
{isUploading && (
|
||||||
|
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex flex-col items-center justify-center rounded-lg z-50">
|
||||||
|
<Loader2 className="h-10 w-10 text-primary animate-spin mb-4" />
|
||||||
|
<p className="text-lg font-semibold text-foreground">
|
||||||
|
<T>Uploading image...</T>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isEditMode && (
|
{isEditMode && (
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { GenericCanvas } from '@/components/hmi/GenericCanvas';
|
import { GenericCanvas } from '@/components/hmi/GenericCanvas';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { T } from '@/i18n';
|
import { T } from '@/i18n';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
|
|
||||||
export interface TabDefinition {
|
export interface TabDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
@ -23,6 +22,11 @@ interface TabsWidgetProps {
|
|||||||
contentClassName?: string; // Content area classes
|
contentClassName?: string; // Content area classes
|
||||||
isEditMode?: boolean;
|
isEditMode?: boolean;
|
||||||
onPropsChange: (props: Record<string, any>) => void;
|
onPropsChange: (props: Record<string, any>) => void;
|
||||||
|
selectedWidgetId?: string | null;
|
||||||
|
onSelectWidget?: (id: string, pageId?: string) => void;
|
||||||
|
onSelectContainer?: (containerId: string | null, pageId?: string) => void;
|
||||||
|
editingWidgetId?: string | null;
|
||||||
|
onEditWidget?: (id: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TabsWidget: React.FC<TabsWidgetProps> = ({
|
const TabsWidget: React.FC<TabsWidgetProps> = ({
|
||||||
@ -35,7 +39,12 @@ const TabsWidget: React.FC<TabsWidgetProps> = ({
|
|||||||
tabBarClassName = '',
|
tabBarClassName = '',
|
||||||
contentClassName = '',
|
contentClassName = '',
|
||||||
isEditMode = false,
|
isEditMode = false,
|
||||||
onPropsChange
|
onPropsChange,
|
||||||
|
selectedWidgetId,
|
||||||
|
onSelectWidget,
|
||||||
|
onSelectContainer,
|
||||||
|
editingWidgetId,
|
||||||
|
onEditWidget,
|
||||||
}) => {
|
}) => {
|
||||||
const [currentTabId, setCurrentTabId] = useState<string | undefined>(activeTabId);
|
const [currentTabId, setCurrentTabId] = useState<string | undefined>(activeTabId);
|
||||||
|
|
||||||
@ -138,6 +147,11 @@ const TabsWidget: React.FC<TabsWidgetProps> = ({
|
|||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
showControls={false} // Tabs usually hide nested canvas controls to look cleaner
|
showControls={false} // Tabs usually hide nested canvas controls to look cleaner
|
||||||
className="p-4"
|
className="p-4"
|
||||||
|
selectedWidgetId={selectedWidgetId}
|
||||||
|
onSelectWidget={onSelectWidget}
|
||||||
|
onSelectContainer={onSelectContainer}
|
||||||
|
editingWidgetId={editingWidgetId}
|
||||||
|
onEditWidget={onEditWidget}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center h-full text-slate-400">
|
<div className="flex items-center justify-center h-full text-slate-400">
|
||||||
|
|||||||
@ -8,12 +8,13 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { WidgetDefinition } from '@/lib/widgetRegistry';
|
import { WidgetDefinition } from '@/lib/widgetRegistry';
|
||||||
import { ImagePickerDialog } from './ImagePickerDialog';
|
import { ImagePickerDialog } from './ImagePickerDialog';
|
||||||
import { PagePickerDialog } from './PagePickerDialog';
|
import { PagePickerDialog } from './PagePickerDialog';
|
||||||
import { Image as ImageIcon, Maximize2, FileText } from 'lucide-react';
|
import { Image as ImageIcon, Maximize2, FileText, Sparkles } from 'lucide-react';
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import MarkdownEditor from '@/components/MarkdownEditorEx';
|
import MarkdownEditor from '@/components/MarkdownEditorEx';
|
||||||
import { TailwindClassPicker } from './TailwindClassPicker';
|
import { TailwindClassPicker } from './TailwindClassPicker';
|
||||||
import { TabsPropertyEditor } from './TabsPropertyEditor';
|
import { TabsPropertyEditor } from './TabsPropertyEditor';
|
||||||
|
import { HtmlGeneratorWizard } from './HtmlGeneratorWizard';
|
||||||
|
|
||||||
export interface WidgetPropertiesFormProps {
|
export interface WidgetPropertiesFormProps {
|
||||||
widgetDefinition: WidgetDefinition;
|
widgetDefinition: WidgetDefinition;
|
||||||
@ -24,6 +25,8 @@ export interface WidgetPropertiesFormProps {
|
|||||||
onSave?: () => void;
|
onSave?: () => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
showActions?: boolean;
|
showActions?: boolean;
|
||||||
|
contextVariables?: Record<string, any>;
|
||||||
|
pageContext?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
|
export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
|
||||||
@ -34,7 +37,9 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
|
|||||||
onSettingsChange,
|
onSettingsChange,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
showActions = false
|
showActions = false,
|
||||||
|
contextVariables = {},
|
||||||
|
pageContext
|
||||||
}) => {
|
}) => {
|
||||||
// Local state for immediate feedback in the form, though we also prop up changes
|
// Local state for immediate feedback in the form, though we also prop up changes
|
||||||
const [settings, setSettings] = useState<Record<string, any>>(currentProps);
|
const [settings, setSettings] = useState<Record<string, any>>(currentProps);
|
||||||
@ -44,6 +49,8 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
|
|||||||
const [pagePickerField, setPagePickerField] = useState<string | null>(null);
|
const [pagePickerField, setPagePickerField] = useState<string | null>(null);
|
||||||
const [markdownEditorOpen, setMarkdownEditorOpen] = useState(false);
|
const [markdownEditorOpen, setMarkdownEditorOpen] = useState(false);
|
||||||
const [activeMarkdownField, setActiveMarkdownField] = useState<string | null>(null);
|
const [activeMarkdownField, setActiveMarkdownField] = useState<string | null>(null);
|
||||||
|
const [htmlWizardOpen, setHtmlWizardOpen] = useState(false);
|
||||||
|
const [activeHtmlField, setActiveHtmlField] = useState<string | null>(null);
|
||||||
|
|
||||||
// Sync with prop changes (e.g. selection change)
|
// Sync with prop changes (e.g. selection change)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -238,19 +245,35 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
|
|||||||
<Label htmlFor={key} className="text-xs font-medium text-slate-500 dark:text-slate-400">
|
<Label htmlFor={key} className="text-xs font-medium text-slate-500 dark:text-slate-400">
|
||||||
<T>{config.label}</T>
|
<T>{config.label}</T>
|
||||||
</Label>
|
</Label>
|
||||||
<Button
|
<div className="flex gap-1">
|
||||||
type="button"
|
<Button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="sm"
|
variant="ghost"
|
||||||
className="h-6 px-2 text-xs"
|
size="sm"
|
||||||
onClick={() => {
|
className="h-6 px-2 text-xs text-blue-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-950/20"
|
||||||
setActiveMarkdownField(key);
|
onClick={() => {
|
||||||
setMarkdownEditorOpen(true);
|
setActiveHtmlField(key);
|
||||||
}}
|
setHtmlWizardOpen(true);
|
||||||
>
|
}}
|
||||||
<Maximize2 className="h-3.5 w-3.5 mr-1" />
|
title="Generate HTML with AI"
|
||||||
<T>Fullscreen</T>
|
>
|
||||||
</Button>
|
<Sparkles className="h-3.5 w-3.5 mr-1" />
|
||||||
|
<T>Generate</T>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveMarkdownField(key);
|
||||||
|
setMarkdownEditorOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-3.5 w-3.5 mr-1" />
|
||||||
|
<T>Fullscreen</T>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
id={key}
|
id={key}
|
||||||
@ -471,6 +494,28 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* HTML Generator Wizard */}
|
||||||
|
<HtmlGeneratorWizard
|
||||||
|
isOpen={htmlWizardOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setHtmlWizardOpen(false);
|
||||||
|
setActiveHtmlField(null);
|
||||||
|
}}
|
||||||
|
onHtmlGenerated={(html) => {
|
||||||
|
if (activeHtmlField) {
|
||||||
|
updateSetting(activeHtmlField, html);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onHtmlGenerated={(html) => {
|
||||||
|
if (activeHtmlField) {
|
||||||
|
updateSetting(activeHtmlField, html);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
contextVariables={contextVariables}
|
||||||
|
pageContext={pageContext}
|
||||||
|
initialPrompt={activeHtmlField ? settings[activeHtmlField] : ''}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -11,13 +11,15 @@ interface WidgetPropertyPanelProps {
|
|||||||
selectedWidgetId: string | null;
|
selectedWidgetId: string | null;
|
||||||
onWidgetRenamed?: (newId: string) => void;
|
onWidgetRenamed?: (newId: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
contextVariables?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WidgetPropertyPanel: React.FC<WidgetPropertyPanelProps> = ({
|
export const WidgetPropertyPanel: React.FC<WidgetPropertyPanelProps> = ({
|
||||||
pageId,
|
pageId,
|
||||||
selectedWidgetId,
|
selectedWidgetId,
|
||||||
onWidgetRenamed,
|
onWidgetRenamed,
|
||||||
className = ''
|
className = '',
|
||||||
|
contextVariables = {}
|
||||||
}) => {
|
}) => {
|
||||||
const { loadedPages, updateWidgetProps, renameWidget } = useLayout();
|
const { loadedPages, updateWidgetProps, renameWidget } = useLayout();
|
||||||
const page = loadedPages.get(pageId);
|
const page = loadedPages.get(pageId);
|
||||||
@ -86,15 +88,10 @@ export const WidgetPropertyPanel: React.FC<WidgetPropertyPanelProps> = ({
|
|||||||
onSettingsChange={handleSettingsChange}
|
onSettingsChange={handleSettingsChange}
|
||||||
widgetInstanceId={widget.id}
|
widgetInstanceId={widget.id}
|
||||||
onRename={(newId) => {
|
onRename={(newId) => {
|
||||||
// We need to propagate this up because the selection state depends on the ID
|
|
||||||
// If `renameWidget` succeeds, we should probably tell the parent to select the new ID.
|
|
||||||
// Since I can't easily change the parent's selection state from here without a prop,
|
|
||||||
// I'll add an `onWidgetRenamed` prop to `WidgetPropertyPanel`.
|
|
||||||
// Wait, I can't add a prop without updating usage in PlaygroundCanvas.
|
|
||||||
|
|
||||||
// Let's implement the internal logic first.
|
|
||||||
handleRename(newId);
|
handleRename(newId);
|
||||||
}}
|
}}
|
||||||
|
contextVariables={contextVariables}
|
||||||
|
pageContext={page}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-slate-500 text-xs py-8">
|
<div className="text-center text-slate-500 text-xs py-8">
|
||||||
|
|||||||
@ -67,13 +67,15 @@ const WidgetSettingsManagerComponent: React.FC<WidgetSettingsManagerProps> = ({
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<WidgetPropertiesForm
|
<div className="max-h-[70vh] overflow-y-auto scrollbar-custom py-2">
|
||||||
widgetDefinition={widgetDefinition}
|
<WidgetPropertiesForm
|
||||||
currentProps={settings}
|
widgetDefinition={widgetDefinition}
|
||||||
onSettingsChange={setSettings}
|
currentProps={settings}
|
||||||
widgetInstanceId={widgetInstanceId}
|
onSettingsChange={setSettings}
|
||||||
onRename={onRename}
|
widgetInstanceId={widgetInstanceId}
|
||||||
/>
|
onRename={onRename}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={handleCancel}>
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { toast } from 'sonner';
|
|||||||
import { useWidgetLoader } from './useWidgetLoader.tsx';
|
import { useWidgetLoader } from './useWidgetLoader.tsx';
|
||||||
import { useLayouts } from './useLayouts';
|
import { useLayouts } from './useLayouts';
|
||||||
import { Database } from '@/integrations/supabase/types';
|
import { Database } from '@/integrations/supabase/types';
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
|
||||||
type Layout = Database['public']['Tables']['layouts']['Row'];
|
type Layout = Database['public']['Tables']['layouts']['Row'];
|
||||||
type LayoutVisibility = Database['public']['Enums']['layout_visibility'];
|
type LayoutVisibility = Database['public']['Enums']['layout_visibility'];
|
||||||
@ -390,11 +391,18 @@ export function usePlaygroundLogic() {
|
|||||||
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL;
|
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL;
|
||||||
|
|
||||||
toast.info("Sending test email...");
|
toast.info("Sending test email...");
|
||||||
|
|
||||||
|
const { data: sessionData } = await supabase.auth.getSession();
|
||||||
|
const token = sessionData.session?.access_token;
|
||||||
|
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
const response = await fetch(`${serverUrl}/api/send/email/${dummyId}`, {
|
const response = await fetch(`${serverUrl}/api/send/email/${dummyId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers,
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
html,
|
html,
|
||||||
subject: `[Test] ${layout.name} - ${new Date().toLocaleTimeString()}`
|
subject: `[Test] ${layout.name} - ${new Date().toLocaleTimeString()}`
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,5 @@
|
|||||||
import { supabase as defaultSupabase } from "@/integrations/supabase/client";
|
import { supabase as defaultSupabase } from "@/integrations/supabase/client";
|
||||||
|
import { z } from "zod";
|
||||||
import { UserProfile, PostMediaItem } from "@/pages/Post/types";
|
import { UserProfile, PostMediaItem } from "@/pages/Post/types";
|
||||||
import { MediaType, MediaItem } from "@/types";
|
import { MediaType, MediaItem } from "@/types";
|
||||||
import { SupabaseClient } from "@supabase/supabase-js";
|
import { SupabaseClient } from "@supabase/supabase-js";
|
||||||
@ -1210,3 +1211,109 @@ export const getLayouts = async (filters?: { type?: string, visibility?: string,
|
|||||||
return { data, error: null };
|
return { data, error: null };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// --- i18n ---
|
||||||
|
|
||||||
|
export const TargetLanguageCodeSchema = z.union([
|
||||||
|
z.enum(["en", "pt", "bg", "cs", "da", "de", "el", "es", "et", "fi", "fr", "hu", "id", "it", "ja", "ko", "lt", "lv", "nb", "nl", "pl", "ro", "ru", "sk", "sl", "sv", "tr", "uk", "zh"]),
|
||||||
|
z.enum(["en-GB", "en-US", "pt-BR", "pt-PT"])
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type TargetLanguageCode = z.infer<typeof TargetLanguageCodeSchema>;
|
||||||
|
|
||||||
|
export const SourceLanguageCodeSchema = z.enum(["bg", "cs", "da", "de", "el", "en", "es", "et", "fi", "fr", "hu", "id", "it", "ja", "ko", "lt", "lv", "nb", "nl", "pl", "pt", "ro", "ru", "sk", "sl", "sv", "tr", "uk", "zh"]);
|
||||||
|
export type SourceLanguageCode = z.infer<typeof SourceLanguageCodeSchema>;
|
||||||
|
|
||||||
|
export interface Glossary {
|
||||||
|
glossary_id: string;
|
||||||
|
name: string;
|
||||||
|
ready: boolean;
|
||||||
|
source_lang: string;
|
||||||
|
target_lang: string;
|
||||||
|
entry_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const translateText = async (text: string, srcLang: string, dstLang: string, glossaryId?: string) => {
|
||||||
|
// POST /api/i18n/translate
|
||||||
|
const { data: sessionData } = await defaultSupabase.auth.getSession();
|
||||||
|
const token = sessionData.session?.access_token;
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const res = await fetch('/api/i18n/translate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
srcLang,
|
||||||
|
dstLang,
|
||||||
|
text,
|
||||||
|
meta: glossaryId ? { glossary_id: glossaryId } : undefined
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`Translation failed: ${res.statusText}`);
|
||||||
|
return await res.json(); // { translation: string }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchGlossaries = async () => {
|
||||||
|
// GET /api/i18n/glossaries
|
||||||
|
return fetchWithDeduplication('i18n-glossaries', async () => {
|
||||||
|
const { data: sessionData } = await defaultSupabase.auth.getSession();
|
||||||
|
const token = sessionData.session?.access_token;
|
||||||
|
const headers: HeadersInit = {};
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const res = await fetch('/api/i18n/glossaries', { headers });
|
||||||
|
if (!res.ok) throw new Error(`Fetch glossaries failed: ${res.statusText}`);
|
||||||
|
return await res.json() as Glossary[];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createGlossary = async (name: string, srcLang: string, dstLang: string, entries: Record<string, string>) => {
|
||||||
|
// POST /api/i18n/glossaries
|
||||||
|
const { data: sessionData } = await defaultSupabase.auth.getSession();
|
||||||
|
const token = sessionData.session?.access_token;
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const res = await fetch('/api/i18n/glossaries', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
source_lang: srcLang,
|
||||||
|
target_lang: dstLang,
|
||||||
|
entries
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(`Create glossary failed: ${err.error || res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateCache('i18n-glossaries');
|
||||||
|
return await res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteGlossary = async (id: string) => {
|
||||||
|
// DELETE /api/i18n/glossaries/:id
|
||||||
|
const { data: sessionData } = await defaultSupabase.auth.getSession();
|
||||||
|
const token = sessionData.session?.access_token;
|
||||||
|
const headers: HeadersInit = {};
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const res = await fetch(`/api/i18n/glossaries/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`Delete glossary failed: ${res.statusText}`);
|
||||||
|
|
||||||
|
invalidateCache('i18n-glossaries');
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|||||||
@ -601,9 +601,73 @@ Optimized: "A fluffy tabby cat sitting gracefully on a vintage wooden chair, sof
|
|||||||
}, null, apiKey);
|
}, null, apiKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ====================================================================
|
// Generate HTML snippet with Tailwind CSS
|
||||||
// TOOL SYSTEM - LLM with Function Calling
|
export const generateHtmlSnippet = async (
|
||||||
// ====================================================================
|
userPrompt: string,
|
||||||
|
contextVariables: Record<string, any> = {},
|
||||||
|
pageContext: any = null,
|
||||||
|
apiKey?: string
|
||||||
|
): Promise<string | null> => {
|
||||||
|
return withOpenAI(async (client) => {
|
||||||
|
try {
|
||||||
|
consoleLogger.info('Starting HTML snippet generation', {
|
||||||
|
promptLength: userPrompt.length,
|
||||||
|
hasContext: Object.keys(contextVariables).length > 0,
|
||||||
|
hasPageContext: !!pageContext
|
||||||
|
});
|
||||||
|
|
||||||
|
const variableList = Object.keys(contextVariables).map(k => `\${${k}}`).join(', ');
|
||||||
|
|
||||||
|
const contextPrompt = Object.keys(contextVariables).length > 0
|
||||||
|
? `\nAvailable Variables: You can use these variables in your HTML: ${variableList}. Use the syntax \${variableName} to insert them.`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const pageContextPrompt = pageContext
|
||||||
|
? `\nPage Context (JSON): Use this to understand the surrounding page structure/data if relevant:\n\`\`\`json\n${JSON.stringify(pageContext, null, 2).slice(0, 5000)}\n\`\`\``
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const systemPrompt = `You are an expert Tailwind CSS and HTML developer.
|
||||||
|
Your task is to generate or modify a standalone HTML snippet based on the user's request.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
1. Return ONLY the HTML code. Do NOT wrap it in markdown code blocks (\`\`\`html ... \`\`\`\`).
|
||||||
|
2. Do NOT include any explanations, comments, or conversational text.
|
||||||
|
3. Use Tailwind CSS classes for styling.
|
||||||
|
4. The HTML should be a document fragment (e.g., a <div>, <section>, or <article>), NOT a full <html> document.
|
||||||
|
5. Make the design modern, clean, and responsive.${contextPrompt}${pageContextPrompt}
|
||||||
|
6. If icons are needed, use valid inline <svg> elements with Tailwind classes. Do NOT use React component names (like <LucideIcon />) as they will not render.
|
||||||
|
7. Ensure strict accessibility compliance (aria-labels, roles).`;
|
||||||
|
|
||||||
|
console.log('System prompt:', systemPrompt);
|
||||||
|
console.log('User prompt:', userPrompt);
|
||||||
|
|
||||||
|
const response = await client.chat.completions.create({
|
||||||
|
model: "gpt-4o", // Stronger model for code generation
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: systemPrompt },
|
||||||
|
{ role: "user", content: userPrompt }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
let content = response.choices[0]?.message?.content?.trim();
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
consoleLogger.warn('No HTML returned from OpenAI');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup if the model ignored the "no markdown" rule
|
||||||
|
content = content.replace(/^```html\s*/i, '').replace(/^```\s*/, '').replace(/\s*```$/, '');
|
||||||
|
|
||||||
|
return content;
|
||||||
|
} catch (error: any) {
|
||||||
|
consoleLogger.error('OpenAI HTML generation failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, null, apiKey);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to create Zod-validated OpenAI tools
|
* Helper function to create Zod-validated OpenAI tools
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
ListFilter,
|
ListFilter,
|
||||||
Layout,
|
Layout,
|
||||||
FileText,
|
FileText,
|
||||||
|
Code,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
// Import your components
|
// Import your components
|
||||||
@ -15,11 +16,51 @@ import LayoutContainerWidget from '@/components/widgets/LayoutContainerWidget';
|
|||||||
import MarkdownTextWidget from '@/components/widgets/MarkdownTextWidget';
|
import MarkdownTextWidget from '@/components/widgets/MarkdownTextWidget';
|
||||||
import GalleryWidget from '@/components/widgets/GalleryWidget';
|
import GalleryWidget from '@/components/widgets/GalleryWidget';
|
||||||
import TabsWidget from '@/components/widgets/TabsWidget';
|
import TabsWidget from '@/components/widgets/TabsWidget';
|
||||||
|
import { HtmlWidget } from '@/components/widgets/HtmlWidget';
|
||||||
|
|
||||||
export function registerAllWidgets() {
|
export function registerAllWidgets() {
|
||||||
// Clear existing registrations (useful for HMR)
|
// Clear existing registrations (useful for HMR)
|
||||||
widgetRegistry.clear();
|
widgetRegistry.clear();
|
||||||
|
|
||||||
|
// HTML Widget
|
||||||
|
widgetRegistry.register({
|
||||||
|
component: HtmlWidget,
|
||||||
|
metadata: {
|
||||||
|
id: 'html-widget',
|
||||||
|
name: 'HTML Content',
|
||||||
|
category: 'display',
|
||||||
|
description: 'Render HTML content with variable substitution',
|
||||||
|
icon: Code,
|
||||||
|
defaultProps: {
|
||||||
|
content: '<div>\n <h3 class="text-xl font-bold">Hello ${name}</h3>\n <p>Welcome to our custom widget!</p>\n</div>',
|
||||||
|
variables: '{\n "name": "World"\n}'
|
||||||
|
},
|
||||||
|
configSchema: {
|
||||||
|
content: {
|
||||||
|
type: 'markdown', // Using markdown editor for larger text area
|
||||||
|
label: 'HTML Content',
|
||||||
|
description: 'Enter your HTML code here. Use ${varName} for substitutions.',
|
||||||
|
default: '<div>Hello World</div>'
|
||||||
|
},
|
||||||
|
variables: {
|
||||||
|
type: 'markdown', // Using markdown/textarea for JSON input for now
|
||||||
|
label: 'Variables (JSON)',
|
||||||
|
description: 'JSON object defining variables for substitution.',
|
||||||
|
default: '{}'
|
||||||
|
},
|
||||||
|
className: {
|
||||||
|
type: 'classname',
|
||||||
|
label: 'CSS Class',
|
||||||
|
description: 'Tailwind classes for the container',
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
minSize: { width: 300, height: 100 },
|
||||||
|
resizable: true,
|
||||||
|
tags: ['html', 'code', 'custom', 'embed']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Photo widgets
|
// Photo widgets
|
||||||
widgetRegistry.register({
|
widgetRegistry.register({
|
||||||
component: PhotoGrid,
|
component: PhotoGrid,
|
||||||
@ -130,6 +171,7 @@ export function registerAllWidgets() {
|
|||||||
orientation: 'horizontal',
|
orientation: 'horizontal',
|
||||||
tabBarPosition: 'top'
|
tabBarPosition: 'top'
|
||||||
},
|
},
|
||||||
|
|
||||||
configSchema: {
|
configSchema: {
|
||||||
tabs: {
|
tabs: {
|
||||||
type: 'tabs-editor',
|
type: 'tabs-editor',
|
||||||
@ -165,6 +207,14 @@ export function registerAllWidgets() {
|
|||||||
minSize: { width: 400, height: 300 },
|
minSize: { width: 400, height: 300 },
|
||||||
resizable: true,
|
resizable: true,
|
||||||
tags: ['layout', 'tabs', 'container']
|
tags: ['layout', 'tabs', 'container']
|
||||||
|
},
|
||||||
|
getNestedLayouts: (props) => {
|
||||||
|
if (!props.tabs || !Array.isArray(props.tabs)) return [];
|
||||||
|
return props.tabs.map((tab: any) => ({
|
||||||
|
id: tab.id,
|
||||||
|
label: tab.label,
|
||||||
|
layoutId: tab.layoutId
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -355,6 +405,16 @@ export function registerAllWidgets() {
|
|||||||
minSize: { width: 300, height: 200 },
|
minSize: { width: 300, height: 200 },
|
||||||
resizable: true,
|
resizable: true,
|
||||||
tags: ['layout', 'container', 'nested', 'canvas']
|
tags: ['layout', 'container', 'nested', 'canvas']
|
||||||
|
},
|
||||||
|
getNestedLayouts: (props) => {
|
||||||
|
if (props.nestedPageId) {
|
||||||
|
return [{
|
||||||
|
id: 'nested-container',
|
||||||
|
label: props.nestedPageName || 'Nested Container',
|
||||||
|
layoutId: props.nestedPageId
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export interface WidgetDefinition {
|
|||||||
component: React.ComponentType<any>;
|
component: React.ComponentType<any>;
|
||||||
metadata: WidgetMetadata;
|
metadata: WidgetMetadata;
|
||||||
previewComponent?: React.ComponentType<any>;
|
previewComponent?: React.ComponentType<any>;
|
||||||
|
getNestedLayouts?: (props: Record<string, any>) => { id: string; label: string; layoutId: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
class WidgetRegistry {
|
class WidgetRegistry {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { LayoutGrid, GalleryVerticalEnd, TrendingUp, Clock, List, FolderTree } f
|
|||||||
import { ListLayout } from "@/components/ListLayout";
|
import { ListLayout } from "@/components/ListLayout";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import type { FeedSortOption } from "@/hooks/useFeedData";
|
import type { FeedSortOption } from "@/hooks/useFeedData";
|
||||||
|
import { SEO } from "@/components/SEO";
|
||||||
|
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
const { slug } = useParams<{ slug?: string }>();
|
const { slug } = useParams<{ slug?: string }>();
|
||||||
@ -41,6 +42,7 @@ const Index = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background">
|
<div className="bg-background">
|
||||||
|
<SEO title="PolyMech Home" />
|
||||||
<div className="md:py-2">
|
<div className="md:py-2">
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { usePostActions } from "./Post/usePostActions";
|
|||||||
import { exportMarkdown, downloadMediaItem } from "./Post/PostActions";
|
import { exportMarkdown, downloadMediaItem } from "./Post/PostActions";
|
||||||
import { DeleteDialog } from "./Post/components/DeleteDialogs";
|
import { DeleteDialog } from "./Post/components/DeleteDialogs";
|
||||||
import { CategoryManager } from "@/components/widgets/CategoryManager";
|
import { CategoryManager } from "@/components/widgets/CategoryManager";
|
||||||
|
import { SEO } from "@/components/SEO";
|
||||||
|
|
||||||
|
|
||||||
import '@vidstack/react/player/styles/default/theme.css';
|
import '@vidstack/react/player/styles/default/theme.css';
|
||||||
@ -954,7 +955,15 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
|
|||||||
: "bg-background flex flex-col h-full";
|
: "bg-background flex flex-col h-full";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={containerClassName}>
|
<div className={`min-h-screen bg-background ${className}`}>
|
||||||
|
{post && (
|
||||||
|
<SEO
|
||||||
|
title={post.title || mediaItem?.title}
|
||||||
|
description={post.description || mediaItem?.description || `View ${post.title} on Polymech`}
|
||||||
|
image={mediaItem?.image_url}
|
||||||
|
type={isVideo ? 'video.other' : 'article'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className={embedded ? "w-full h-full" : "w-full h-full max-w-[1600px] mx-auto"}>
|
<div className={embedded ? "w-full h-full" : "w-full h-full max-w-[1600px] mx-auto"}>
|
||||||
|
|
||||||
{viewMode === 'article' ? (
|
{viewMode === 'article' ? (
|
||||||
|
|||||||
@ -41,12 +41,6 @@ export const CompactRenderer: React.FC<PostRendererProps> = (props) => {
|
|||||||
const effectiveType = mediaItem.type || detectMediaType(mediaItem.image_url);
|
const effectiveType = mediaItem.type || detectMediaType(mediaItem.image_url);
|
||||||
const isVideo = isVideoType(normalizeMediaType(effectiveType));
|
const isVideo = isVideoType(normalizeMediaType(effectiveType));
|
||||||
|
|
||||||
console.log('mediaItem', mediaItem);
|
|
||||||
console.log('isVideo', isVideo);
|
|
||||||
console.log('effectiveType', effectiveType);
|
|
||||||
console.log('mediaItems', mediaItems);
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={props.className || "h-full"}>
|
<div className={props.className || "h-full"}>
|
||||||
{/* Mobile Header - Controls and Info at Top */}
|
{/* Mobile Header - Controls and Info at Top */}
|
||||||
|
|||||||
@ -5,11 +5,13 @@ import { toast } from "sonner";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||||
import { T, translate } from "@/i18n";
|
import { T, translate } from "@/i18n";
|
||||||
|
import { Database } from "@/integrations/supabase/types";
|
||||||
|
|
||||||
import { GenericCanvas } from "@/components/hmi/GenericCanvas";
|
import { GenericCanvas } from "@/components/hmi/GenericCanvas";
|
||||||
import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||||
|
|
||||||
import MarkdownRenderer from "@/components/MarkdownRenderer";
|
import MarkdownRenderer from "@/components/MarkdownRenderer";
|
||||||
|
|
||||||
import { Sidebar } from "@/components/sidebar/Sidebar";
|
import { Sidebar } from "@/components/sidebar/Sidebar";
|
||||||
import { TableOfContents } from "@/components/sidebar/TableOfContents";
|
import { TableOfContents } from "@/components/sidebar/TableOfContents";
|
||||||
import { MobileTOC } from "@/components/sidebar/MobileTOC";
|
import { MobileTOC } from "@/components/sidebar/MobileTOC";
|
||||||
@ -18,8 +20,7 @@ import { useLayout } from "@/contexts/LayoutContext";
|
|||||||
import { fetchUserPage } from "@/lib/db";
|
import { fetchUserPage } from "@/lib/db";
|
||||||
import { UserPageTopBar } from "@/components/user-page/UserPageTopBar";
|
import { UserPageTopBar } from "@/components/user-page/UserPageTopBar";
|
||||||
import { UserPageDetails } from "@/components/user-page/UserPageDetails";
|
import { UserPageDetails } from "@/components/user-page/UserPageDetails";
|
||||||
import { Database } from "@/integrations/supabase/types";
|
import { SEO } from "@/components/SEO";
|
||||||
|
|
||||||
|
|
||||||
const UserPageEdit = lazy(() => import("./UserPageEdit"));
|
const UserPageEdit = lazy(() => import("./UserPageEdit"));
|
||||||
|
|
||||||
@ -183,7 +184,7 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const element = document.getElementById(id);
|
const element = document.getElementById(id);
|
||||||
if (element) {
|
if (element) {
|
||||||
element.scrollIntoView({ behavior: 'smooth' });
|
// element.scrollIntoView({ behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
@ -249,6 +250,15 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${embedded ? 'h-full' : 'h-[calc(100vh-3.5rem)]'} bg-background flex flex-col overflow-hidden`}>
|
<div className={`${embedded ? 'h-full' : 'h-[calc(100vh-3.5rem)]'} bg-background flex flex-col overflow-hidden`}>
|
||||||
|
{/* SEO Metadata */}
|
||||||
|
{page && (
|
||||||
|
<SEO
|
||||||
|
title={page.title}
|
||||||
|
description={page.meta?.description || `View ${page.title} on Polymech`}
|
||||||
|
image={page.meta?.ogImage} // Assuming meta might have ogImage
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Top Header (Back button) or Ribbon Bar - Fixed if not embedded */}
|
{/* Top Header (Back button) or Ribbon Bar - Fixed if not embedded */}
|
||||||
{!embedded && (
|
{!embedded && (
|
||||||
<UserPageTopBar
|
<UserPageTopBar
|
||||||
@ -359,6 +369,12 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
|
|||||||
initialLayout={page.content}
|
initialLayout={page.content}
|
||||||
selectedWidgetId={null}
|
selectedWidgetId={null}
|
||||||
onSelectWidget={() => { }}
|
onSelectWidget={() => { }}
|
||||||
|
contextVariables={(() => {
|
||||||
|
const typeValues = page.meta?.typeValues || {};
|
||||||
|
// Flatten all type values into a single object
|
||||||
|
// Later types override earlier ones if keys collide, but order isn't guaranteed in object
|
||||||
|
return Object.values(typeValues).reduce((acc: any, val: any) => ({ ...acc, ...val }), {});
|
||||||
|
})()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, lazy, Suspense } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -7,9 +7,14 @@ import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
|||||||
import { T, translate } from "@/i18n";
|
import { T, translate } from "@/i18n";
|
||||||
import { GenericCanvas } from "@/components/hmi/GenericCanvas";
|
import { GenericCanvas } from "@/components/hmi/GenericCanvas";
|
||||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
// Editor components lazy loaded
|
||||||
|
const WidgetPropertyPanel = lazy(() => import("@/components/widgets/WidgetPropertyPanel").then(module => ({ default: module.WidgetPropertyPanel })));
|
||||||
|
const HierarchyTree = lazy(() => import("@/components/sidebar/HierarchyTree").then(module => ({ default: module.HierarchyTree })));
|
||||||
|
const UserPageTypeFields = lazy(() => import("@/components/user-page/UserPageTypeFields").then(module => ({ default: module.UserPageTypeFields })));
|
||||||
|
const SaveTemplateDialog = lazy(() => import("@/components/user-page/SaveTemplateDialog").then(module => ({ default: module.SaveTemplateDialog })));
|
||||||
|
|
||||||
import { PageActions } from "@/components/PageActions";
|
|
||||||
import { WidgetPropertyPanel } from "@/components/widgets/WidgetPropertyPanel";
|
|
||||||
import MarkdownRenderer from "@/components/MarkdownRenderer";
|
import MarkdownRenderer from "@/components/MarkdownRenderer";
|
||||||
import { Sidebar } from "@/components/sidebar/Sidebar";
|
import { Sidebar } from "@/components/sidebar/Sidebar";
|
||||||
import { TableOfContents } from "@/components/sidebar/TableOfContents";
|
import { TableOfContents } from "@/components/sidebar/TableOfContents";
|
||||||
@ -20,10 +25,6 @@ import { UserPageDetails } from "@/components/user-page/UserPageDetails";
|
|||||||
import { useLayouts } from "@/hooks/useLayouts";
|
import { useLayouts } from "@/hooks/useLayouts";
|
||||||
import { Database } from "@/integrations/supabase/types";
|
import { Database } from "@/integrations/supabase/types";
|
||||||
import PageRibbonBar from "@/components/user-page/ribbons/PageRibbonBar";
|
import PageRibbonBar from "@/components/user-page/ribbons/PageRibbonBar";
|
||||||
import { SaveTemplateDialog } from "@/components/user-page/SaveTemplateDialog";
|
|
||||||
import { getLayouts } from "@/lib/db";
|
|
||||||
import { HierarchyTree } from "@/components/sidebar/HierarchyTree";
|
|
||||||
import { UserPageTypeFields } from "@/components/user-page/UserPageTypeFields";
|
|
||||||
|
|
||||||
type Layout = Database['public']['Tables']['layouts']['Row'];
|
type Layout = Database['public']['Tables']['layouts']['Row'];
|
||||||
|
|
||||||
@ -89,6 +90,7 @@ const UserPageEdit = ({
|
|||||||
}: UserPageEditProps) => {
|
}: UserPageEditProps) => {
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||||
const [selectedWidgetId, setSelectedWidgetId] = useState<string | null>(null);
|
const [selectedWidgetId, setSelectedWidgetId] = useState<string | null>(null);
|
||||||
|
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
|
||||||
const [showHierarchy, setShowHierarchy] = useState(false);
|
const [showHierarchy, setShowHierarchy] = useState(false);
|
||||||
|
|
||||||
// Auto-collapse sidebar if no TOC headings
|
// Auto-collapse sidebar if no TOC headings
|
||||||
@ -120,6 +122,23 @@ const UserPageEdit = ({
|
|||||||
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(null);
|
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(null);
|
||||||
const [showSaveTemplateDialog, setShowSaveTemplateDialog] = useState(false);
|
const [showSaveTemplateDialog, setShowSaveTemplateDialog] = useState(false);
|
||||||
|
|
||||||
|
// Settings Dialog State
|
||||||
|
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||||
|
const [settingsWidgetId, setSettingsWidgetId] = useState<string | null>(null);
|
||||||
|
const [settingsLayoutId, setSettingsLayoutId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleOpenSettings = (id: string, type: 'widget' | 'container', layoutId: string) => {
|
||||||
|
if (type === 'widget') {
|
||||||
|
setSettingsWidgetId(id);
|
||||||
|
setSettingsLayoutId(layoutId);
|
||||||
|
setSettingsDialogOpen(true);
|
||||||
|
} else if (type === 'container') {
|
||||||
|
// For now just select it, or implement container settings
|
||||||
|
setSelectedContainerId(id);
|
||||||
|
// TODO: Container Settings Dialog
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOwner) {
|
if (isOwner) {
|
||||||
loadTemplates();
|
loadTemplates();
|
||||||
@ -140,7 +159,8 @@ const UserPageEdit = ({
|
|||||||
|
|
||||||
const handleAddWidget = async (widgetId: string) => {
|
const handleAddWidget = async (widgetId: string) => {
|
||||||
if (!page) return;
|
if (!page) return;
|
||||||
const pageId = `page-${page.id}`;
|
// Use the selected page ID (nested layout) if available, otherwise default to the main page
|
||||||
|
const pageId = selectedPageId || `page-${page.id}`;
|
||||||
|
|
||||||
// Determine target container
|
// Determine target container
|
||||||
let targetContainerId = selectedContainerId;
|
let targetContainerId = selectedContainerId;
|
||||||
@ -431,6 +451,11 @@ const UserPageEdit = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const contextVariables = (() => {
|
||||||
|
const typeValues = page.meta?.typeValues || {};
|
||||||
|
return Object.values(typeValues).reduce((acc: any, val: any) => ({ ...acc, ...val }), {});
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageRibbonBar
|
<PageRibbonBar
|
||||||
@ -512,18 +537,23 @@ const UserPageEdit = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showHierarchy && currentLayout && (
|
{showHierarchy && currentLayout && (
|
||||||
<HierarchyTree
|
<Suspense fallback={<div className="p-4 text-xs text-muted-foreground">Loading hierarchy...</div>}>
|
||||||
containers={currentLayout.containers}
|
<HierarchyTree
|
||||||
selectedWidgetId={selectedWidgetId}
|
containers={currentLayout.containers}
|
||||||
selectedContainerId={selectedContainerId}
|
selectedWidgetId={selectedWidgetId}
|
||||||
onSelectWidget={(id) => {
|
selectedContainerId={selectedContainerId}
|
||||||
setSelectedWidgetId(id);
|
onSelectWidget={(id) => {
|
||||||
// Ensure editor is open/focused if needed
|
setSelectedWidgetId(id);
|
||||||
const widget = currentLayout.containers.flatMap((c: any) => [c, ...c.children]).flatMap((c: any) => c.widgets).find((w: any) => w.id === id);
|
setSelectedPageId(`page-${page.id}`);
|
||||||
if (widget) setEditingWidgetId(id);
|
// Ensure editor is open/focused if needed
|
||||||
}}
|
const widget = currentLayout.containers.flatMap((c: any) => [c, ...c.children]).flatMap((c: any) => c.widgets).find((w: any) => w.id === id);
|
||||||
onSelectContainer={setSelectedContainerId}
|
if (widget) setEditingWidgetId(id);
|
||||||
/>
|
}}
|
||||||
|
onSelectContainer={setSelectedContainerId}
|
||||||
|
onSettingsClick={handleOpenSettings}
|
||||||
|
layoutId={currentLayout.id}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -568,12 +598,23 @@ const UserPageEdit = ({
|
|||||||
showControls={true}
|
showControls={true}
|
||||||
initialLayout={page.content}
|
initialLayout={page.content}
|
||||||
selectedWidgetId={selectedWidgetId}
|
selectedWidgetId={selectedWidgetId}
|
||||||
onSelectWidget={setSelectedWidgetId}
|
onSelectWidget={(id, pageId) => {
|
||||||
|
setSelectedWidgetId(id);
|
||||||
|
setSelectedPageId(pageId || `page-${page.id}`);
|
||||||
|
}}
|
||||||
selectedContainerId={selectedContainerId}
|
selectedContainerId={selectedContainerId}
|
||||||
onSelectContainer={setSelectedContainerId}
|
onSelectContainer={(id, pageId) => {
|
||||||
|
setSelectedContainerId(id);
|
||||||
|
if (id) {
|
||||||
|
setSelectedPageId(pageId || `page-${page.id}`);
|
||||||
|
}
|
||||||
|
// If deselecting, we might want to reset pageId to main page or keep last context?
|
||||||
|
// Keeping last context is fine.
|
||||||
|
}}
|
||||||
editingWidgetId={editingWidgetId}
|
editingWidgetId={editingWidgetId}
|
||||||
onEditWidget={handleEditWidget}
|
onEditWidget={handleEditWidget}
|
||||||
newlyAddedWidgetId={newlyAddedWidgetId}
|
newlyAddedWidgetId={newlyAddedWidgetId}
|
||||||
|
contextVariables={contextVariables}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -611,20 +652,25 @@ const UserPageEdit = ({
|
|||||||
<ResizablePanel defaultSize={25} minSize={20} maxSize={50} order={2} id="user-page-props">
|
<ResizablePanel defaultSize={25} minSize={20} maxSize={50} order={2} id="user-page-props">
|
||||||
<div className="h-full flex flex-col shrink-0 transition-all duration-300 overflow-hidden bg-background">
|
<div className="h-full flex flex-col shrink-0 transition-all duration-300 overflow-hidden bg-background">
|
||||||
{selectedWidgetId ? (
|
{selectedWidgetId ? (
|
||||||
<WidgetPropertyPanel
|
<Suspense fallback={<div className="h-full flex items-center justify-center text-muted-foreground">Loading settings...</div>}>
|
||||||
pageId={`page-${page.id}`}
|
<WidgetPropertyPanel
|
||||||
selectedWidgetId={selectedWidgetId}
|
pageId={selectedPageId || `page-${page.id}`}
|
||||||
onWidgetRenamed={setSelectedWidgetId}
|
selectedWidgetId={selectedWidgetId}
|
||||||
/>
|
onWidgetRenamed={setSelectedWidgetId}
|
||||||
|
contextVariables={contextVariables}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
) : showTypeFields ? (
|
) : showTypeFields ? (
|
||||||
<div className="h-full overflow-y-auto p-4">
|
<div className="h-full overflow-y-auto p-4">
|
||||||
<UserPageTypeFields
|
<Suspense fallback={<div className="h-full flex items-center justify-center text-muted-foreground">Loading types...</div>}>
|
||||||
pageId={page.id}
|
<UserPageTypeFields
|
||||||
pageMeta={page.meta}
|
pageId={page.id}
|
||||||
assignedTypes={assignedTypes}
|
pageMeta={page.meta}
|
||||||
isEditMode={true}
|
assignedTypes={assignedTypes}
|
||||||
onMetaUpdate={(newMeta) => onPageUpdate({ ...page, meta: newMeta })}
|
isEditMode={true}
|
||||||
/>
|
onMetaUpdate={(newMeta) => onPageUpdate({ ...page, meta: newMeta })}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@ -634,11 +680,37 @@ const UserPageEdit = ({
|
|||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SaveTemplateDialog
|
<Suspense fallback={null}>
|
||||||
isOpen={showSaveTemplateDialog}
|
<SaveTemplateDialog
|
||||||
onClose={() => setShowSaveTemplateDialog(false)}
|
isOpen={showSaveTemplateDialog}
|
||||||
onSave={onSaveTemplate}
|
onClose={() => setShowSaveTemplateDialog(false)}
|
||||||
/>
|
onSave={onSaveTemplate}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
{/* Widget Settings Dialog */}
|
||||||
|
<Dialog open={settingsDialogOpen} onOpenChange={setSettingsDialogOpen}>
|
||||||
|
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle><T>Widget Settings</T></DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{settingsWidgetId && settingsLayoutId && (
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<WidgetPropertyPanel
|
||||||
|
pageId={settingsLayoutId}
|
||||||
|
selectedWidgetId={settingsWidgetId}
|
||||||
|
onWidgetRenamed={(newId) => {
|
||||||
|
setSettingsWidgetId(newId);
|
||||||
|
if (selectedWidgetId === settingsWidgetId) {
|
||||||
|
setSelectedWidgetId(newId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
contextVariables={contextVariables}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { T, translate } from "@/i18n";
|
|||||||
import { normalizeMediaType } from "@/lib/mediaRegistry";
|
import { normalizeMediaType } from "@/lib/mediaRegistry";
|
||||||
import { useFeedData } from "@/hooks/useFeedData";
|
import { useFeedData } from "@/hooks/useFeedData";
|
||||||
import * as db from "@/lib/db";
|
import * as db from "@/lib/db";
|
||||||
|
import { SEO } from "@/components/SEO";
|
||||||
|
|
||||||
interface UserProfile {
|
interface UserProfile {
|
||||||
id: string;
|
id: string;
|
||||||
@ -227,6 +228,11 @@ const UserProfile = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background pt-14">
|
<div className="min-h-screen bg-background pt-14">
|
||||||
|
<SEO
|
||||||
|
title={userProfile.display_name || userProfile.username || 'User Profile'}
|
||||||
|
description={userProfile.bio || `Check out ${userProfile.display_name}'s profile on PolyMech`}
|
||||||
|
image={userProfile.avatar_url || undefined}
|
||||||
|
/>
|
||||||
<div className="container mx-auto px-0 md:px-4 py-8 max-w-4xl">
|
<div className="container mx-auto px-0 md:px-4 py-8 max-w-4xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
|||||||
@ -3,14 +3,13 @@
|
|||||||
* Based on deobfuscated TikTok video player implementation
|
* Based on deobfuscated TikTok video player implementation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useRef, useEffect } from 'react';
|
import React, { useRef, useEffect, lazy, Suspense } from 'react';
|
||||||
import { VideoItem } from '../types';
|
import { VideoItem } from '../types';
|
||||||
import { MediaPlayer, MediaProvider, type MediaPlayerInstance } from '@vidstack/react';
|
import type { MediaPlayerInstance } from '@vidstack/react';
|
||||||
import { defaultLayoutIcons, DefaultVideoLayout } from '@vidstack/react/player/layouts/default';
|
import { defaultLayoutIcons } from '@vidstack/react/player/layouts/default';
|
||||||
|
|
||||||
// Import Vidstack styles
|
// Lazy load Vidstack implementation
|
||||||
import '@vidstack/react/player/styles/default/theme.css';
|
const VidstackPlayer = lazy(() => import('./VidstackPlayerImpl').then(module => ({ default: module.VidstackPlayerImpl })));
|
||||||
import '@vidstack/react/player/styles/default/layouts/video.css';
|
|
||||||
|
|
||||||
interface VideoPlayerProps {
|
interface VideoPlayerProps {
|
||||||
video: VideoItem;
|
video: VideoItem;
|
||||||
@ -52,35 +51,36 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className={`w-full h-full bg-black flex justify-center items-center ${className}`}>
|
<div className={`w-full h-full bg-black flex justify-center items-center ${className}`}>
|
||||||
{/* Video Player - Using Vidstack for HLS support */}
|
{/* Video Player - Using Vidstack for HLS support */}
|
||||||
<MediaPlayer
|
{/* Video Player - Using Vidstack for HLS support */}
|
||||||
ref={player}
|
<Suspense fallback={<div className="w-full h-full bg-black animate-pulse" />}>
|
||||||
title={video.desc || 'Video'}
|
<VidstackPlayer
|
||||||
src={
|
ref={player}
|
||||||
video.video.playAddr.includes('/api/videos/')
|
title={video.desc || 'Video'}
|
||||||
? { src: video.video.playAddr, type: 'video/mp4' }
|
src={
|
||||||
: video.video.playAddr
|
video.video.playAddr.includes('/api/videos/')
|
||||||
}
|
? { src: video.video.playAddr, type: 'video/mp4' }
|
||||||
poster={video.video.cover}
|
: video.video.playAddr
|
||||||
playsInline
|
}
|
||||||
loop
|
poster={video.video.cover}
|
||||||
muted={false}
|
playsInline
|
||||||
autoPlay={true}
|
loop
|
||||||
load={isActive ? "eager" : "idle"}
|
muted={false}
|
||||||
posterLoad="eager"
|
autoPlay={true}
|
||||||
crossOrigin="anonymous"
|
load={isActive ? "eager" : "idle"}
|
||||||
className="w-full h-full"
|
posterLoad="eager"
|
||||||
style={{
|
crossOrigin="anonymous"
|
||||||
'--video-brand': '#ff0050',
|
className="w-full h-full"
|
||||||
'--media-object-fit': 'contain',
|
style={{
|
||||||
'--media-object-position': 'center'
|
'--video-brand': '#ff0050',
|
||||||
} as any}
|
'--media-object-fit': 'contain',
|
||||||
>
|
'--media-object-position': 'center'
|
||||||
<MediaProvider />
|
} as any}
|
||||||
<DefaultVideoLayout
|
layoutProps={{
|
||||||
icons={defaultLayoutIcons}
|
icons: defaultLayoutIcons,
|
||||||
noScrubGesture
|
noScrubGesture: true
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</MediaPlayer>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
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