diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 12ddcaf6..2d6e38e0 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -69,8 +69,8 @@ const AppWrapper = () => { const isFullScreenPage = location.pathname.startsWith('/video-feed'); const containerClassName = isFullScreenPage - ? "flex flex-col min-h-svh transition-colors duration-200 bg-background h-full" - : "mx-auto 2xl:max-w-7xl flex flex-col min-h-svh transition-colors duration-200 bg-background h-full"; + ? "flex flex-col min-h-svh transition-colors duration-200 h-full" + : "mx-auto 2xl:max-w-7xl flex flex-col min-h-svh transition-colors duration-200 h-full"; return (
@@ -109,7 +109,7 @@ const AppWrapper = () => { Loading...
}>} /> {/* Admin Routes */} - Loading...}>} /> + Loading...}>} /> {/* Organization-scoped routes */} } /> diff --git a/packages/ui/src/components/AITextGenerator.tsx b/packages/ui/src/components/AITextGenerator.tsx index 63393f74..dd198641 100644 --- a/packages/ui/src/components/AITextGenerator.tsx +++ b/packages/ui/src/components/AITextGenerator.tsx @@ -19,6 +19,7 @@ import { X, } from 'lucide-react'; import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog'; +import CollapsibleSection from '@/components/CollapsibleSection'; // Define Picture type locally if not imported (matches usages in other files) interface Picture { @@ -144,6 +145,7 @@ export const AITextGenerator: React.FC = ({ onNavigateHistory, }) => { const handleKeyDown = (e: React.KeyboardEvent) => { + console.log(e.key); // Ctrl+Enter to generate if ((e.key === 'Enter' && e.ctrlKey) && prompt.trim() && !isGenerating) { e.preventDefault(); @@ -151,11 +153,13 @@ export const AITextGenerator: React.FC = ({ } // Ctrl+Up to navigate to previous prompt in history else if (e.key === 'ArrowUp' && e.ctrlKey && onNavigateHistory) { + console.log('up'); e.preventDefault(); onNavigateHistory('up'); } // Ctrl+Down to navigate to next prompt in history else if (e.key === 'ArrowDown' && e.ctrlKey && onNavigateHistory) { + console.log('down'); e.preventDefault(); onNavigateHistory('down'); } @@ -180,20 +184,35 @@ export const AITextGenerator: React.FC = ({ {/* Single Column Layout for Side Panel */}
{/* Settings Area */} - -

- Settings -

+ {/* Settings Area */} + Settings} + initiallyOpen={true} + storageKey="ai-generator-settings-main" + asCard={true} + className="bg-muted/30" + contentClassName="p-4 space-y-3" + > {/* Provider & Model Selection */} - + {/* Provider & Model Selection */} + AI Provider} + initiallyOpen={true} + storageKey="ai-text-gen-provider-settings" + minimal={true} + className="border-none p-0" + contentClassName="pt-2" + > + + {/* Image Tools Toggle */}
@@ -360,7 +379,7 @@ export const AITextGenerator: React.FC = ({
-
+ {/* Prompt Input Area */}
diff --git a/packages/ui/src/components/Comments.tsx b/packages/ui/src/components/Comments.tsx index 0f9e1b74..697240d4 100644 --- a/packages/ui/src/components/Comments.tsx +++ b/packages/ui/src/components/Comments.tsx @@ -103,6 +103,7 @@ const Comments = ({ pictureId, initialComments }: CommentsProps) => { const missingUserIds = uniqueUserIds.filter(id => !userProfiles.has(id)); if (missingUserIds.length > 0) { + console.log('Fetching profiles for users:', missingUserIds); const { data: profilesData, error: profilesError } = await supabase .from('profiles') .select('user_id, avatar_url, display_name, username') diff --git a/packages/ui/src/components/ConfirmationDialog.tsx b/packages/ui/src/components/ConfirmationDialog.tsx new file mode 100644 index 00000000..c812c8ed --- /dev/null +++ b/packages/ui/src/components/ConfirmationDialog.tsx @@ -0,0 +1,110 @@ +import React, { useRef, useEffect } from "react"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogCancel, + AlertDialogAction, +} from "@/components/ui/alert-dialog"; +import { cn } from "@/lib/utils"; +import { T } from "@/i18n"; + +interface ConfirmationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: React.ReactNode; + onConfirm: () => void; + onCancel?: () => void; + confirmLabel?: string; + cancelLabel?: string; + variant?: "default" | "destructive"; +} + +export const ConfirmationDialog = ({ + open, + onOpenChange, + title, + description, + onConfirm, + onCancel, + confirmLabel = "Continue", + cancelLabel = "Cancel", + variant = "default", +}: ConfirmationDialogProps) => { + const cancelRef = useRef(null); + const confirmRef = useRef(null); + const lastFocusedRef = useRef(null); + + // Initial focus when opening and restore logic + useEffect(() => { + if (open) { + lastFocusedRef.current = document.activeElement as HTMLElement; + // Slight delay to ensure content is mounted and capable of receiving focus + // Radix UI handles initial focus too, but we might want to enforce "Cancel" as default for safety + setTimeout(() => { + cancelRef.current?.focus(); + }, 50); + } else { + // Restore focus when closed + if (lastFocusedRef.current) { + lastFocusedRef.current.focus(); + lastFocusedRef.current = null; + } + } + }, [open]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "ArrowRight") { + e.preventDefault(); + confirmRef.current?.focus(); + } else if (e.key === "ArrowLeft") { + e.preventDefault(); + cancelRef.current?.focus(); + } + }; + + const handleConfirm = (e: React.MouseEvent) => { + e.preventDefault(); + onConfirm(); + }; + + const handleCancel = (e: React.MouseEvent) => { + e.preventDefault(); + if (onCancel) onCancel(); + else onOpenChange(false); + }; + + return ( + + + + {title} + +
+ {typeof description === 'string' ? {description} : description} +
+
+
+ + + {cancelLabel} + + + {confirmLabel} + + +
+
+ ); +}; diff --git a/packages/ui/src/components/ImageLightbox.tsx b/packages/ui/src/components/ImageLightbox.tsx index 311541ac..2f7fd114 100644 --- a/packages/ui/src/components/ImageLightbox.tsx +++ b/packages/ui/src/components/ImageLightbox.tsx @@ -138,6 +138,7 @@ export default function ImageLightbox({ if (!isOpen) return; const handleKeyDown = (event: KeyboardEvent) => { + // Check if user is typing in the textarea or any input field const target = event.target as HTMLElement; const isTypingInInput = target instanceof HTMLTextAreaElement || diff --git a/packages/ui/src/components/ImageWizard/db.ts b/packages/ui/src/components/ImageWizard/db.ts index c0767eae..3522cb2a 100644 --- a/packages/ui/src/components/ImageWizard/db.ts +++ b/packages/ui/src/components/ImageWizard/db.ts @@ -344,6 +344,7 @@ export const getUserOpenAIKey = async (userId: string): Promise = * Get user secrets from user_secrets table (settings column) */ export const getUserSecrets = async (userId: string): Promise | null> => { + console.log('Fetching user secrets for user:', userId); try { const { data: secretData } = await supabase .from('user_secrets') diff --git a/packages/ui/src/components/ImageWizard/handlers/publishHandlers.ts b/packages/ui/src/components/ImageWizard/handlers/publishHandlers.ts index 0d5be0ad..e003cb55 100644 --- a/packages/ui/src/components/ImageWizard/handlers/publishHandlers.ts +++ b/packages/ui/src/components/ImageWizard/handlers/publishHandlers.ts @@ -1,6 +1,7 @@ import { ImageFile } from '../types'; import { uploadImage } from '@/lib/uploadUtils'; import { supabase } from '@/integrations/supabase/client'; +import { createPost, updatePostDetails } from '@/lib/db'; import { toast } from 'sonner'; import { translate } from '@/i18n'; @@ -65,35 +66,26 @@ export const publishImage = async ( if (editingPostId) { // Update existing post - const { error: updateError } = await supabase - .from('posts') - .update({ - title: title, - description: description, - settings: settings || undefined, - meta: meta || undefined, - updated_at: new Date().toISOString() - }) - .eq('id', editingPostId); - - if (updateError) throw updateError; + await updatePostDetails(editingPostId, { + title: title, + description: description, + settings: settings, + meta: meta + }); + // updated_at is handled by server or trigger usually, or we can add it to api if needed but usually standard fields are auto. + // API currently doesn't key `updated_at` from body, but `db-posts.ts` doesn't set it explicitly either. + // Usually supabase sets it via trigger or we should set it. + // The old code set it: `updated_at: new Date().toISOString()`. + // The API `handleUpdatePost` does `update({ ... })`. } else { // Create new post - const { data: postData, error: postError } = await supabase - .from('posts') - .insert({ - user_id: user.id, - title: title, - description: description, - settings: settings || { visibility: 'public' }, - meta: meta || {}, - }) - .select() - .single(); - - if (postError) throw postError; - if (!postData) throw new Error('Failed to create post'); - postId = postData.id; + const response = await createPost({ + title, + description, + settings: settings, + meta: meta, + }); + postId = response.post.id; } if (!postId) throw new Error('No post ID available'); @@ -307,18 +299,11 @@ export const quickPublishAsNew = async ( setIsPublishing(true); try { // 1. Create Post - const { data: postData, error: postError } = await supabase - .from('posts') - .insert({ - user_id: user.id, - title: imageTitle || 'Quick Publish', - description: prompt.trim(), // Use prompt as description - }) - .select() - .single(); - - if (postError) throw postError; - const postId = postData.id; + const response = await createPost({ + title: imageTitle || 'Quick Publish', + description: prompt.trim(), + }); + const postId = response.post.id; let file; diff --git a/packages/ui/src/components/ListLayout.tsx b/packages/ui/src/components/ListLayout.tsx index c2130600..d271fbdf 100644 --- a/packages/ui/src/components/ListLayout.tsx +++ b/packages/ui/src/components/ListLayout.tsx @@ -199,7 +199,7 @@ export const ListLayout = ({
{/* Right: Detail */} -
+
{selectedId ? ( (() => { const selectedPost = feedPosts.find((p: any) => p.id === selectedId); @@ -223,7 +223,7 @@ export const ListLayout = ({ key={selectedId} // Force remount on ID change postId={selectedId} embedded - className="h-full overflow-y-auto scrollbar-custom" + className="h-[inherit] overflow-y-auto scrollbar-custom" /> ); })() diff --git a/packages/ui/src/components/MarkdownRenderer.tsx b/packages/ui/src/components/MarkdownRenderer.tsx index 7660111f..5ad7c9f6 100644 --- a/packages/ui/src/components/MarkdownRenderer.tsx +++ b/packages/ui/src/components/MarkdownRenderer.tsx @@ -21,6 +21,7 @@ const SmartLightbox = React.lazy(() => import('../pages/Post/components/SmartLig interface MarkdownRendererProps { content: string; className?: string; + variables?: Record; } // Helper function to format URL display text (ported from previous implementation) @@ -61,10 +62,18 @@ const slugify = (text: string) => { .replace(/^-+|-+$/g, ''); }; -const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRendererProps) => { +import { substitute } from '@/lib/variables'; + +const MarkdownRenderer = React.memo(({ content, className = "", variables }: MarkdownRendererProps) => { const containerRef = React.useRef(null); const { user } = useAuth(); + // Substitute variables in content if provided + const finalContent = useMemo(() => { + if (!variables || Object.keys(variables).length === 0) return content; + return substitute(false, content, variables); + }, [content, variables]); + // Lightbox state const [lightboxOpen, setLightboxOpen] = useState(false); const [currentImageIndex, setCurrentImageIndex] = useState(0); @@ -76,34 +85,34 @@ const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRender let match; // We clone the regex to avoid stateful issues if reuse happens, though local var is fine const localRegex = new RegExp(regex); - while ((match = localRegex.exec(content)) !== null) { + while ((match = localRegex.exec(finalContent)) !== null) { images.push({ alt: match[1], src: match[2] }); } return images; - }, [content]); + }, [finalContent]); // Memoize content analysis (keep existing logic for simple hashtag views) const contentAnalysis = useMemo(() => { - const hasHashtags = /#[a-zA-Z0-9_]+/.test(content); - const hasMarkdownLinks = /\[.*?\]\(.*?\)/.test(content); - const hasMarkdownSyntax = /(\*\*|__|##?|###?|####?|#####?|######?|\*|\n\*|\n-|\n\d+\.)/.test(content); + const hasHashtags = /#[a-zA-Z0-9_]+/.test(finalContent); + const hasMarkdownLinks = /\[.*?\]\(.*?\)/.test(finalContent); + const hasMarkdownSyntax = /(\*\*|__|##?|###?|####?|#####?|######?|\*|\n\*|\n-|\n\d+\.)/.test(finalContent); return { hasHashtags, hasMarkdownLinks, hasMarkdownSyntax }; - }, [content]); + }, [finalContent]); // Apply syntax highlighting after render useEffect(() => { if (containerRef.current) { Prism.highlightAllUnder(containerRef.current); } - }, [content]); + }, [finalContent]); const handleImageClick = (src: string) => { const index = allImages.findIndex(img => img.src === src); @@ -145,7 +154,7 @@ const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRender if (contentAnalysis.hasHashtags && !contentAnalysis.hasMarkdownLinks && !contentAnalysis.hasMarkdownSyntax) { return (
- {content} + {finalContent}
); } @@ -238,7 +247,7 @@ const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRender }, }} > - {content} + {finalContent}
diff --git a/packages/ui/src/components/admin/AdminSidebar.tsx b/packages/ui/src/components/admin/AdminSidebar.tsx index ced90328..f83861e2 100644 --- a/packages/ui/src/components/admin/AdminSidebar.tsx +++ b/packages/ui/src/components/admin/AdminSidebar.tsx @@ -10,26 +10,27 @@ import { useSidebar } from "@/components/ui/sidebar"; import { T, translate } from "@/i18n"; -import { LayoutDashboard, Users, Server, Shield, AlertTriangle } from "lucide-react"; +import { LayoutDashboard, Users, Server, Shield, AlertTriangle, ChartBar } from "lucide-react"; -export type AdminActiveSection = 'dashboard' | 'users' | 'server' | 'bans' | 'violations'; +import { useLocation, useNavigate } from "react-router-dom"; -export const AdminSidebar = ({ - activeSection, - onSectionChange -}: { - activeSection: AdminActiveSection; - onSectionChange: (section: AdminActiveSection) => void; -}) => { +export type AdminActiveSection = 'dashboard' | 'users' | 'server' | 'bans' | 'violations' | 'analytics'; + +export const AdminSidebar = () => { const { state } = useSidebar(); + const location = useLocation(); + const navigate = useNavigate(); const isCollapsed = state === "collapsed"; + const currentSection = location.pathname.split('/').pop() as AdminActiveSection || 'users'; + const menuItems = [ { id: 'dashboard' as AdminActiveSection, label: translate('Dashboard'), icon: LayoutDashboard }, { id: 'users' as AdminActiveSection, label: translate('Users'), icon: Users }, { id: 'server' as AdminActiveSection, label: translate('Server'), icon: Server }, { id: 'bans' as AdminActiveSection, label: translate('Bans'), icon: Shield }, { id: 'violations' as AdminActiveSection, label: translate('Violations'), icon: AlertTriangle }, + { id: 'analytics' as AdminActiveSection, label: translate('Analytics'), icon: ChartBar }, ]; return ( @@ -39,17 +40,22 @@ export const AdminSidebar = ({ Admin - {menuItems.map((item) => ( - - onSectionChange(item.id)} - className={activeSection === item.id ? "bg-muted text-primary font-medium" : "hover:bg-muted/50"} - > - - {!isCollapsed && {item.label}} - - - ))} + {menuItems.map((item) => { + // Handle base /admin/ path matching 'users' if we redirect + const isActive = (item.id === 'users' && (location.pathname === '/admin' || location.pathname === '/admin/')) || location.pathname.includes(`/admin/${item.id}`); + + return ( + + navigate(`/admin/${item.id}`)} + className={isActive ? "bg-muted text-primary font-medium" : "hover:bg-muted/50"} + > + + {!isCollapsed && {item.label}} + + + ) + })} diff --git a/packages/ui/src/components/containers/ContainerPropertyPanel.tsx b/packages/ui/src/components/containers/ContainerPropertyPanel.tsx new file mode 100644 index 00000000..0b10c6a0 --- /dev/null +++ b/packages/ui/src/components/containers/ContainerPropertyPanel.tsx @@ -0,0 +1,205 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { TailwindClassPicker } from '@/components/widgets/TailwindClassPicker'; +import { useLayout } from '@/modules/layout/LayoutContext'; +import { LayoutContainer } from '@/modules/layout/LayoutManager'; +import { Grid3X3 } from 'lucide-react'; + +interface ContainerPropertyPanelProps { + pageId: string; + selectedContainerId: string; +} + +export const ContainerPropertyPanel: React.FC = ({ + pageId, + selectedContainerId, +}) => { + const { loadedPages, updatePageContainerSettings } = useLayout(); + const layout = loadedPages.get(pageId); + + // Find the container in layout + const findContainer = useCallback((containers: LayoutContainer[], id: string): LayoutContainer | null => { + for (const c of containers) { + if (c.id === id) return c; + const found = findContainer(c.children, id); + if (found) return found; + } + return null; + }, []); + + const container = layout ? findContainer(layout.containers, selectedContainerId) : null; + + const [settings, setSettings] = useState>({}); + + // Sync settings when container changes + useEffect(() => { + if (container?.settings) { + setSettings({ ...container.settings }); + } else { + setSettings({}); + } + }, [container?.id, container?.settings]); + + const updateSetting = useCallback(>( + key: K, + value: NonNullable[K] + ) => { + const newSettings = { ...settings, [key]: value }; + setSettings(newSettings); + updatePageContainerSettings(pageId, selectedContainerId, { [key]: value }); + }, [settings, pageId, selectedContainerId, updatePageContainerSettings]); + + if (!container) { + return ( +
+ Container not found +
+ ); + } + + return ( +
+
+ {/* Header */} +
+ +
+

Container Properties

+

{selectedContainerId}

+
+
+ + {/* General Properties */} +
+ + + {/* Enabled Toggle */} +
+
+ +

Turn off to hide this container.

+
+ updateSetting('enabled', checked)} + className="scale-90" + /> +
+ + {/* Columns Info */} +
+ +

{container.columns}

+
+ + {/* Gap Info */} +
+ +

{container.gap}px

+
+
+ + {/* CSS Class */} +
+ + updateSetting('customClassName', newValue)} + placeholder={`container-${selectedContainerId.split('-').pop()}`} + className="w-full" + /> +

+ Custom CSS class applied to this container. +

+
+ + {/* Title Settings */} +
+ + +
+
+ +

Display a title bar above the container.

+
+ updateSetting('showTitle', checked)} + className="scale-90" + /> +
+ + {settings.showTitle && ( +
+ + updateSetting('title', e.target.value)} + placeholder={`Container (${container.columns} col${container.columns !== 1 ? 's' : ''})`} + className="w-full h-8 text-sm" + /> +
+ )} +
+ + {/* Collapsible Settings */} +
+ + +
+
+ +

Allow users to collapse/expand.

+
+ { + updateSetting('collapsible', checked); + if (!checked) { + updateSetting('collapsed', false); + } + }} + className="scale-90" + /> +
+ + {settings.collapsible && ( +
+
+ +

Start with container collapsed.

+
+ updateSetting('collapsed', checked)} + className="scale-90" + /> +
+ )} +
+
+
+ ); +}; diff --git a/packages/ui/src/components/lazy-editors/AIGenerationPlugin.tsx b/packages/ui/src/components/lazy-editors/AIGenerationPlugin.tsx index 19821174..141a0438 100644 --- a/packages/ui/src/components/lazy-editors/AIGenerationPlugin.tsx +++ b/packages/ui/src/components/lazy-editors/AIGenerationPlugin.tsx @@ -32,6 +32,7 @@ const useProviderApiKey = () => { } try { + console.log('Fetching API key for user:', user.id, 'provider:', provider); const { data: userProvider, error } = await supabase .from('provider_configs') .select('settings') diff --git a/packages/ui/src/components/lazy-editors/AIImagePromptPopup.tsx b/packages/ui/src/components/lazy-editors/AIImagePromptPopup.tsx index b1aa5e45..db245d67 100644 --- a/packages/ui/src/components/lazy-editors/AIImagePromptPopup.tsx +++ b/packages/ui/src/components/lazy-editors/AIImagePromptPopup.tsx @@ -182,6 +182,7 @@ export const AIImagePromptPopup: React.FC = ({ }; const handleKeyDown = (e: React.KeyboardEvent) => { + console.log(e.key); if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleGenerate(); @@ -202,9 +203,6 @@ export const AIImagePromptPopup: React.FC = ({ } }; - console.log(model); - - // Helper to check if model supports advanced options // All Google models in our router support these parameters const isGoogleModel = model.startsWith('google/'); diff --git a/packages/ui/src/components/lazy-editors/MDXEditorInternal.tsx b/packages/ui/src/components/lazy-editors/MDXEditorInternal.tsx index e94defb4..8bf6cdd7 100644 --- a/packages/ui/src/components/lazy-editors/MDXEditorInternal.tsx +++ b/packages/ui/src/components/lazy-editors/MDXEditorInternal.tsx @@ -111,49 +111,43 @@ export default function MDXEditorInternal({ } }, [theme]); + const allPlugins = React.useMemo(() => [ + headingsPlugin(), + linkPlugin(), + linkDialogPlugin(), + imagePlugin(), + quotePlugin(), + thematicBreakPlugin(), + listsPlugin(), + tablePlugin(), + codeBlockPlugin({ defaultCodeBlockLanguage: 'js' }), + codeMirrorPlugin({ codeBlockLanguages: { js: 'JavaScript', css: 'CSS', 'json': 'JSON', '': 'Text' } }), + sandpackPlugin({ sandpackConfig: simpleSandpackConfig }), + slashCommandPlugin({ onRequestImage }), + aiGenerationPlugin(), + aiImageGenerationPlugin(), + toolbarPlugin({ + toolbarClassName: 'mdx-toolbar', + toolbarContents: () => ( + <> + + + + + + + + ) + }) + ], [onRequestImage]); + return ( ( - <> - - - - - - - - - - - - - - - ) - }) - ]} + plugins={allPlugins} placeholder={placeholder} contentEditableClassName="prose prose-sm max-w-none dark:prose-invert" /> diff --git a/packages/ui/src/components/lazy-editors/SlashCommandPlugin.tsx b/packages/ui/src/components/lazy-editors/SlashCommandPlugin.tsx index ae893631..db8096a7 100644 --- a/packages/ui/src/components/lazy-editors/SlashCommandPlugin.tsx +++ b/packages/ui/src/components/lazy-editors/SlashCommandPlugin.tsx @@ -198,7 +198,7 @@ function SlashCommandMenu({ } return anchorElementRef.current && ReactDOM.createPortal( -
+
{options.map((option, i) => { // Filter manually if the plugin doesn't do it for us (it usually does but we want custom fuzzy logic maybe) diff --git a/packages/ui/src/components/ui/card.tsx b/packages/ui/src/components/ui/card.tsx index 26cca74a..fd6f80a5 100644 --- a/packages/ui/src/components/ui/card.tsx +++ b/packages/ui/src/components/ui/card.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { cn } from "@/lib/utils"; const Card = React.forwardRef>(({ className, ...props }, ref) => ( -
+
)); Card.displayName = "Card"; diff --git a/packages/ui/src/components/variables/VariableBuilder.tsx b/packages/ui/src/components/variables/VariableBuilder.tsx index 8d7815a7..4ac1862f 100644 --- a/packages/ui/src/components/variables/VariableBuilder.tsx +++ b/packages/ui/src/components/variables/VariableBuilder.tsx @@ -4,7 +4,10 @@ import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; -import { Type as TypeIcon, Hash, ToggleLeft, Trash2, Lock, Plus } from 'lucide-react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Type as TypeIcon, Hash, ToggleLeft, Trash2, Lock, Plus, Import, Download, ChevronDown, Copy, FileJson } from 'lucide-react'; +import { toast } from "sonner"; export interface VariableElement { id: string; @@ -117,8 +120,8 @@ export const VariableBuilder = ({ onSave, isSaving }: { - initialData: Record, - onSave: (data: Record) => void, + initialData: Record, + onSave: (data: Record) => void, isSaving?: boolean }) => { const [elements, setElements] = useState([]); @@ -127,11 +130,20 @@ export const VariableBuilder = ({ // Load initial data useEffect(() => { - const initialElements: VariableElement[] = Object.entries(initialData).map(([key, value]) => { - // Infer type + const initialElements: VariableElement[] = Object.entries(initialData || {}).map(([key, rawValue]) => { + const value = String(rawValue); + // Infer type from raw value if possible, otherwise fallback to string string inference let type: VariableElement['type'] = 'string'; - if (value === 'true' || value === 'false') type = 'boolean'; - else if (!isNaN(Number(value)) && value.trim() !== '') type = 'number'; + + if (typeof rawValue === 'boolean') { + type = 'boolean'; + } else if (typeof rawValue === 'number') { + type = 'number'; + } else { + if (value === 'true' || value === 'false') type = 'boolean'; + else if (!isNaN(Number(value)) && value.trim() !== '') type = 'number'; + } + // Simple heuristic for secrets: key contains SECRET, KEY, TOKEN, PASSWORD if (/SECRET|KEY|TOKEN|PASSWORD|PASS/i.test(key)) type = 'secret'; @@ -165,10 +177,19 @@ export const VariableBuilder = ({ }; const handleSave = () => { - const result: Record = {}; + const result: Record = {}; elements.forEach(el => { if (el.key) { - result[el.key] = el.value; + let val: any = el.value; + if (el.type === 'boolean') { + val = el.value === 'true'; + } else if (el.type === 'number') { + const num = Number(el.value); + if (!isNaN(num) && el.value.trim() !== '') { + val = num; + } + } + result[el.key] = val; } }); onSave(result); @@ -231,34 +252,182 @@ const VariableBuilderContent = ({ updateSelectedElement: (u: Partial) => void, onAddVariable: (type: VariableElement['type']) => void }) => { + const [importJson, setImportJson] = useState(''); + const [isImportOpen, setIsImportOpen] = useState(false); + const filteredElements = elements.filter(e => e.key.toLowerCase().includes(searchTerm.toLowerCase()) || e.value.toLowerCase().includes(searchTerm.toLowerCase()) ); + const handleImport = () => { + try { + const parsed = JSON.parse(importJson); + if (typeof parsed !== 'object' || parsed === null) { + throw new Error("Invalid JSON: must be an object"); + } + + const newElements: VariableElement[] = []; + // Keep existing elements that are not overwritten + const existingKeys = new Set(Object.keys(parsed)); + + // Create new elements from JSON + Object.entries(parsed).forEach(([key, value]) => { + let type: VariableElement['type'] = 'string'; + + if (typeof value === 'boolean') { + type = 'boolean'; + } else if (typeof value === 'number') { + type = 'number'; + } else { + const s = String(value); + if (s === 'true' || s === 'false') type = 'boolean'; + else if (!isNaN(Number(s)) && s.trim() !== '') type = 'number'; + } + + if (/SECRET|KEY|TOKEN|PASSWORD|PASS/i.test(key)) type = 'secret'; + + const strValue = String(value); + + newElements.push({ + id: `var-${key}-${Date.now()}`, // Ensure unique ID + key, + value: strValue, + type + }); + }); + + // Merge: append new elements to existing ones, or replace if we want to be stricter? + // The prompt says "import", usually implies adding/merging. + // Let's append, but maybe warn about duplicates? + // Actually, let's just add them. The duplicate checker will handle conflicts visually. + + setElements(prev => [...prev, ...newElements]); + setImportJson(''); + setIsImportOpen(false); + toast.success(`Imported ${newElements.length} variables`); + } catch (error) { + console.error("Import failed", error); + toast.error("Invalid JSON format"); + } + }; + + const handleExportCopy = () => { + const data: Record = {}; + elements.forEach(el => { + if (el.key) { + let val: any = el.value; + if (el.type === 'boolean') val = el.value === 'true'; + else if (el.type === 'number') { + const num = Number(el.value); + if (!isNaN(num) && el.value.trim() !== '') val = num; + } + data[el.key] = val; + } + }); + navigator.clipboard.writeText(JSON.stringify(data, null, 2)); + toast.success("Copied to clipboard"); + }; + + const handleExportFile = () => { + const data: Record = {}; + elements.forEach(el => { + if (el.key) { + let val: any = el.value; + if (el.type === 'boolean') val = el.value === 'true'; + else if (el.type === 'number') { + const num = Number(el.value); + if (!isNaN(num) && el.value.trim() !== '') val = num; + } + data[el.key] = val; + } + }); + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'variables.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + toast.success("Download started"); + }; + return (
{/* Top Toolbar: Palette & Search & Actions */} -
-
+
+
+ Variables + +
+ setSearchTerm(e.target.value)} + /> + +
+ + + + + + + Import Variables (JSON) + + Paste a JSON object {`{"KEY": "VALUE"}`} to import variables. + + +