layouts | copy & paste | stuff like that :)

This commit is contained in:
lovebird 2026-02-16 02:50:17 +01:00
parent 64f0353f70
commit 2138792437
61 changed files with 3928 additions and 1743 deletions

View File

@ -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 (
<div className={containerClassName}>
@ -109,7 +109,7 @@ const AppWrapper = () => {
<Route path="/video-feed/:id" element={<React.Suspense fallback={<div>Loading...</div>}><VideoFeedPlayground /></React.Suspense>} />
{/* Admin Routes */}
<Route path="/admin/users" element={<React.Suspense fallback={<div>Loading...</div>}><AdminPage /></React.Suspense>} />
<Route path="/admin/*" element={<React.Suspense fallback={<div>Loading...</div>}><AdminPage /></React.Suspense>} />
{/* Organization-scoped routes */}
<Route path="/org/:orgSlug" element={<Index />} />

View File

@ -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<AITextGeneratorProps> = ({
onNavigateHistory,
}) => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
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<AITextGeneratorProps> = ({
}
// 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<AITextGeneratorProps> = ({
{/* Single Column Layout for Side Panel */}
<div className="flex flex-col gap-4">
{/* Settings Area */}
<Card className="p-4 space-y-3 bg-muted/30">
<h4 className="text-sm font-semibold">
<T>Settings</T>
</h4>
{/* Settings Area */}
<CollapsibleSection
title={<T>Settings</T>}
initiallyOpen={true}
storageKey="ai-generator-settings-main"
asCard={true}
className="bg-muted/30"
contentClassName="p-4 space-y-3"
>
{/* Provider & Model Selection */}
<ProviderSelector
provider={provider}
model={model}
onProviderChange={onProviderChange}
onModelChange={onModelChange}
disabled={disabled || isGenerating || isOptimizing}
showManagement={true}
/>
{/* Provider & Model Selection */}
<CollapsibleSection
title={<T>AI Provider</T>}
initiallyOpen={true}
storageKey="ai-text-gen-provider-settings"
minimal={true}
className="border-none p-0"
contentClassName="pt-2"
>
<ProviderSelector
provider={provider}
model={model}
onProviderChange={onProviderChange}
onModelChange={onModelChange}
disabled={disabled || isGenerating || isOptimizing}
showManagement={true}
/>
</CollapsibleSection>
{/* Image Tools Toggle */}
<div className="flex items-center justify-between p-3 bg-background rounded-lg border">
@ -360,7 +379,7 @@ export const AITextGenerator: React.FC<AITextGeneratorProps> = ({
</Button>
</div>
</Card>
</CollapsibleSection>
{/* Prompt Input Area */}
<div className="space-y-3">

View File

@ -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')

View File

@ -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<HTMLButtonElement>(null);
const confirmRef = useRef<HTMLButtonElement>(null);
const lastFocusedRef = useRef<HTMLElement | null>(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 (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent onKeyDown={handleKeyDown}>
<AlertDialogHeader>
<AlertDialogTitle><T>{title}</T></AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="text-muted-foreground">
{typeof description === 'string' ? <T>{description}</T> : description}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="mt-4">
<AlertDialogCancel
ref={cancelRef}
onClick={handleCancel}
>
<T>{cancelLabel}</T>
</AlertDialogCancel>
<AlertDialogAction
ref={confirmRef}
onClick={handleConfirm}
className={cn(variant === "destructive" && "bg-destructive text-destructive-foreground hover:bg-destructive/90")}
>
<T>{confirmLabel}</T>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@ -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 ||

View File

@ -344,6 +344,7 @@ export const getUserOpenAIKey = async (userId: string): Promise<string | null> =
* Get user secrets from user_secrets table (settings column)
*/
export const getUserSecrets = async (userId: string): Promise<Record<string, string> | null> => {
console.log('Fetching user secrets for user:', userId);
try {
const { data: secretData } = await supabase
.from('user_secrets')

View File

@ -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;

View File

@ -199,7 +199,7 @@ export const ListLayout = ({
</div>
{/* Right: Detail */}
<div className="flex-1 bg-background overflow-hidden relative h-full">
<div className="bg-background overflow-hidden relative flex flex-col h-[inherit]">
{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"
/>
);
})()

View File

@ -21,6 +21,7 @@ const SmartLightbox = React.lazy(() => import('../pages/Post/components/SmartLig
interface MarkdownRendererProps {
content: string;
className?: string;
variables?: Record<string, any>;
}
// 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<HTMLDivElement>(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 (
<div className={`prose prose-sm max-w-none dark:prose-invert ${className}`}>
<HashtagText>{content}</HashtagText>
<HashtagText>{finalContent}</HashtagText>
</div>
);
}
@ -238,7 +247,7 @@ const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRender
},
}}
>
{content}
{finalContent}
</ReactMarkdown>
</div>

View File

@ -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 = ({
<SidebarGroupLabel><T>Admin</T></SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{menuItems.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
onClick={() => onSectionChange(item.id)}
className={activeSection === item.id ? "bg-muted text-primary font-medium" : "hover:bg-muted/50"}
>
<item.icon className="h-4 w-4" />
{!isCollapsed && <span>{item.label}</span>}
</SidebarMenuButton>
</SidebarMenuItem>
))}
{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 (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
onClick={() => navigate(`/admin/${item.id}`)}
className={isActive ? "bg-muted text-primary font-medium" : "hover:bg-muted/50"}
>
<item.icon className="h-4 w-4" />
{!isCollapsed && <span>{item.label}</span>}
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>

View File

@ -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<ContainerPropertyPanelProps> = ({
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<NonNullable<LayoutContainer['settings']>>({});
// Sync settings when container changes
useEffect(() => {
if (container?.settings) {
setSettings({ ...container.settings });
} else {
setSettings({});
}
}, [container?.id, container?.settings]);
const updateSetting = useCallback(<K extends keyof NonNullable<LayoutContainer['settings']>>(
key: K,
value: NonNullable<LayoutContainer['settings']>[K]
) => {
const newSettings = { ...settings, [key]: value };
setSettings(newSettings);
updatePageContainerSettings(pageId, selectedContainerId, { [key]: value });
}, [settings, pageId, selectedContainerId, updatePageContainerSettings]);
if (!container) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
Container not found
</div>
);
}
return (
<div className="h-full overflow-y-auto scrollbar-custom">
<div className="p-4 space-y-4">
{/* Header */}
<div className="flex items-center gap-2 pb-3 border-b border-border">
<Grid3X3 className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-semibold">Container Properties</h3>
<p className="text-[10px] text-muted-foreground font-mono">{selectedContainerId}</p>
</div>
</div>
{/* General Properties */}
<div className="space-y-4 pb-4 border-b border-border">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
General
</Label>
{/* Enabled Toggle */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="container-enabled" className="text-xs font-medium">Enabled</Label>
<p className="text-[10px] text-muted-foreground">Turn off to hide this container.</p>
</div>
<Switch
id="container-enabled"
checked={settings.enabled !== false}
onCheckedChange={(checked) => updateSetting('enabled', checked)}
className="scale-90"
/>
</div>
{/* Columns Info */}
<div className="space-y-1">
<Label className="text-xs font-medium text-slate-500 dark:text-slate-400">
Columns
</Label>
<p className="text-sm text-foreground">{container.columns}</p>
</div>
{/* Gap Info */}
<div className="space-y-1">
<Label className="text-xs font-medium text-slate-500 dark:text-slate-400">
Gap
</Label>
<p className="text-sm text-foreground">{container.gap}px</p>
</div>
</div>
{/* CSS Class */}
<div className="space-y-1 pb-4 border-b border-border">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
CSS Class
</Label>
<TailwindClassPicker
value={settings.customClassName || ''}
onChange={(newValue) => updateSetting('customClassName', newValue)}
placeholder={`container-${selectedContainerId.split('-').pop()}`}
className="w-full"
/>
<p className="text-[10px] text-muted-foreground">
Custom CSS class applied to this container.
</p>
</div>
{/* Title Settings */}
<div className="space-y-4 pb-4 border-b border-border">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Title
</Label>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="container-show-title" className="text-xs font-medium">Show Title</Label>
<p className="text-[10px] text-muted-foreground">Display a title bar above the container.</p>
</div>
<Switch
id="container-show-title"
checked={settings.showTitle || false}
onCheckedChange={(checked) => updateSetting('showTitle', checked)}
className="scale-90"
/>
</div>
{settings.showTitle && (
<div className="space-y-2">
<Label htmlFor="container-title" className="text-xs font-medium text-slate-500 dark:text-slate-400">
Title Text
</Label>
<Input
id="container-title"
type="text"
value={settings.title || ''}
onChange={(e) => updateSetting('title', e.target.value)}
placeholder={`Container (${container.columns} col${container.columns !== 1 ? 's' : ''})`}
className="w-full h-8 text-sm"
/>
</div>
)}
</div>
{/* Collapsible Settings */}
<div className="space-y-4 pb-4 border-b border-border">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Behavior
</Label>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="container-collapsible" className="text-xs font-medium">Collapsible</Label>
<p className="text-[10px] text-muted-foreground">Allow users to collapse/expand.</p>
</div>
<Switch
id="container-collapsible"
checked={settings.collapsible || false}
onCheckedChange={(checked) => {
updateSetting('collapsible', checked);
if (!checked) {
updateSetting('collapsed', false);
}
}}
className="scale-90"
/>
</div>
{settings.collapsible && (
<div className="flex items-center justify-between pl-4 border-l-2 border-slate-200 dark:border-slate-700">
<div className="space-y-0.5">
<Label htmlFor="container-collapsed" className="text-xs font-medium">Initially Collapsed</Label>
<p className="text-[10px] text-muted-foreground">Start with container collapsed.</p>
</div>
<Switch
id="container-collapsed"
checked={settings.collapsed || false}
onCheckedChange={(checked) => updateSetting('collapsed', checked)}
className="scale-90"
/>
</div>
)}
</div>
</div>
</div>
);
};

View File

@ -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')

View File

@ -182,6 +182,7 @@ export const AIImagePromptPopup: React.FC<AIImagePromptPopupProps> = ({
};
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<AIImagePromptPopupProps> = ({
}
};
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/');

View File

@ -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: () => (
<>
<UndoRedo />
<ListsToggle />
<BoldItalicUnderlineToggles />
<CreateLink />
<InsertThematicBreak />
<InsertTable />
</>
)
})
], [onRequestImage]);
return (
<MDXEditor
className={isDarkMode ? 'dark-theme dark-editor' : ''}
ref={editorRef}
markdown={value || ''}
onChange={onChange}
plugins={[
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: () => (
<>
<UndoRedo />
<ListsToggle />
<BoldItalicUnderlineToggles />
<BlockTypeSelect />
<CreateLink />
<InsertThematicBreak />
<InsertTable />
<DiffSourceToggleWrapper>
<InsertCustomImage onRequestImage={onRequestImage} />
<MicrophoneTranscribe onTranscribed={onTextInsert} />
<FullscreenToggle isFullscreen={isFullscreen} onToggle={onToggleFullscreen} />
<SaveButton onSave={onSave} />
</DiffSourceToggleWrapper>
</>
)
})
]}
plugins={allPlugins}
placeholder={placeholder}
contentEditableClassName="prose prose-sm max-w-none dark:prose-invert"
/>

View File

@ -198,7 +198,7 @@ function SlashCommandMenu({
}
return anchorElementRef.current && ReactDOM.createPortal(
<div className="z-50 min-w-[200px] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2">
<div className="z-[100] min-w-[200px] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2">
<div className="flex flex-col gap-0.5">
{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)

View File

@ -3,7 +3,7 @@ import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-lg bg-card text-card-foreground shadow-sm", className)} {...props} />
<div ref={ref} className={cn("rounded-lg dark:bg-slate-800/50 text-card-foreground shadow-sm", className)} {...props} />
));
Card.displayName = "Card";

View File

@ -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<string, string>,
onSave: (data: Record<string, string>) => void,
initialData: Record<string, any>,
onSave: (data: Record<string, any>) => void,
isSaving?: boolean
}) => {
const [elements, setElements] = useState<VariableElement[]>([]);
@ -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<string, string> = {};
const result: Record<string, any> = {};
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<VariableElement>) => 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<string, any> = {};
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<string, any> = {};
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 (
<div className="flex flex-col h-full border rounded-lg overflow-hidden bg-background">
{/* Top Toolbar: Palette & Search & Actions */}
<div className="flex items-center justify-between p-3 border-b bg-muted/20 gap-4">
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar">
<div className="flex flex-col border-b bg-muted/20">
<div className="flex items-start justify-between p-3 border-b border-border/50 gap-4">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider pt-1.5">Variables</span>
<div className="flex items-center gap-2 shrink-0">
<Input
placeholder="Filter..."
className="h-7 w-40 text-xs bg-background"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
<div className="flex items-center gap-1 border-l pl-2 ml-1">
<Dialog open={isImportOpen} onOpenChange={setIsImportOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" title="Import JSON">
<Import className="h-4 w-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Import Variables (JSON)</DialogTitle>
<DialogDescription>
Paste a JSON object {`{"KEY": "VALUE"}`} to import variables.
</DialogDescription>
</DialogHeader>
<Textarea
value={importJson}
onChange={e => setImportJson(e.target.value)}
className="min-h-[300px] font-mono text-xs"
placeholder={`{\n "API_KEY": "12345",\n "ENABLE_FEATURE": "true"\n}`}
/>
<DialogFooter>
<Button variant="outline" onClick={() => setIsImportOpen(false)}>Cancel</Button>
<Button onClick={handleImport}>Import Variables</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" title="Export JSON">
<Download className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleExportCopy}>
<Copy className="mr-2 h-4 w-4" /> Copy to Clipboard
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportFile}>
<FileJson className="mr-2 h-4 w-4" /> Download JSON
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="w-[1px] h-4 bg-border mx-1" />
<Button size="sm" onClick={handleSave} disabled={isSaving || duplicateKeys.size > 0} className="h-7 text-xs px-3">
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
<div className="flex items-center gap-2 p-2 px-3 overflow-x-auto no-scrollbar bg-background/50">
<span className="text-xs font-semibold text-muted-foreground mr-2 uppercase tracking-wider">Add:</span>
<PaletteItem type="string" label="String" onClick={() => onAddVariable('string')} />
<PaletteItem type="number" label="Number" onClick={() => onAddVariable('number')} />
<PaletteItem type="boolean" label="Boolean" onClick={() => onAddVariable('boolean')} />
<PaletteItem type="secret" label="Secret" onClick={() => onAddVariable('secret')} />
</div>
<div className="flex items-center gap-2 shrink-0">
<Input
placeholder="Filter..."
className="h-8 w-40 text-xs bg-background"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
<Button size="sm" onClick={handleSave} disabled={isSaving || duplicateKeys.size > 0} className="h-8 text-xs">
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
<div className="flex flex-1 min-h-0">

View File

@ -1,18 +1,18 @@
import React, { useEffect, useState, useCallback } from 'react';
import { getUserSecrets, updateUserSecrets } from '@/lib/db';
import { VariableBuilder } from './VariableBuilder';
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
export interface VariablesEditorProps {
onLoad: () => Promise<Record<string, string>>;
onSave: (data: Record<string, string>) => Promise<void>;
onLoad: () => Promise<Record<string, any>>;
onSave: (data: Record<string, any>) => Promise<void>;
}
export const VariablesEditor: React.FC<VariablesEditorProps> = ({
onLoad,
onSave
}) => {
const [variables, setVariables] = useState<Record<string, string>>({});
const [variables, setVariables] = useState<Record<string, any>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@ -33,7 +33,7 @@ export const VariablesEditor: React.FC<VariablesEditorProps> = ({
loadVariables();
}, [loadVariables]);
const handleSave = async (newVariables: Record<string, string>) => {
const handleSave = async (newVariables: Record<string, any>) => {
setSaving(true);
try {
await onSave(newVariables);

View File

@ -486,7 +486,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
{editingCategory && (
<VariablesEditor
onLoad={async () => {
return (editingCategory.meta?.variables as Record<string, string>) || {};
return (editingCategory.meta?.variables as Record<string, any>) || {};
}}
onSave={async (newVars) => {
setEditingCategory({

View File

@ -262,15 +262,15 @@ export const HtmlWidget: React.FC<HtmlWidgetProps> = ({
if (loading) {
return <Skeleton className="w-full h-full min-h-[50px] rounded bg-slate-100 dark:bg-slate-800" />;
return <Skeleton className="w-full h-full min-h-[50px] rounded dark:bg-slate-800/50" />;
}
if (error) {
return <div className="p-2 text-xs text-red-500 border border-red-200 rounded bg-red-50">{error}</div>;
return <div className="text-xs text-red-500 border border-red-200 rounded bg-red-50">{error}</div>;
}
if (!processedContent) {
return <div className="p-2 text-xs text-slate-400 border border-dashed border-slate-300 rounded">No content</div>;
return <div className="text-xs text-slate-400 border border-dashed border-slate-300 rounded">No content</div>;
}
return (

View File

@ -1,5 +1,7 @@
import React, { useEffect, useMemo } from 'react';
import { GenericCanvas } from '@/modules/layout/GenericCanvas';
import { useLayout } from '@/modules/layout/LayoutContext';
import { PageLayout } from '@/modules/layout/LayoutManager';
interface LayoutContainerWidgetProps {
widgetInstanceId: string;
@ -9,6 +11,12 @@ interface LayoutContainerWidgetProps {
nestedPageId?: string;
nestedPageName?: string;
showControls?: boolean;
nestedLayoutData?: PageLayout;
selectedWidgetId?: string | null;
onSelectWidget?: (id: string, pageId?: string) => void;
editingWidgetId?: string | null;
onEditWidget?: (id: string | null) => void;
contextVariables?: Record<string, any>;
}
const LayoutContainerWidget: React.FC<LayoutContainerWidgetProps> = ({
@ -18,7 +26,15 @@ const LayoutContainerWidget: React.FC<LayoutContainerWidgetProps> = ({
nestedPageId,
nestedPageName = 'Nested Canvas',
showControls = false,
nestedLayoutData,
selectedWidgetId,
onSelectWidget,
editingWidgetId,
onEditWidget,
contextVariables,
}) => {
const { loadedPages } = useLayout();
// Generate a unique pageId for the nested canvas if it doesn't exist.
const uniqueNestedPageId = useMemo(() => {
if (nestedPageId) {
@ -37,6 +53,23 @@ const LayoutContainerWidget: React.FC<LayoutContainerWidgetProps> = ({
}
}, [nestedPageId, uniqueNestedPageId, onPropsChange]);
// Sync Layout Data back to props (Persist nested layout changes)
useEffect(() => {
if (uniqueNestedPageId && isEditMode) {
const currentLayout = loadedPages.get(uniqueNestedPageId);
if (currentLayout) {
const propTimestamp = nestedLayoutData?.updatedAt || 0;
if (currentLayout.updatedAt > propTimestamp) {
const layoutChanged = JSON.stringify(currentLayout) !== JSON.stringify(nestedLayoutData);
if (layoutChanged) {
onPropsChange({ nestedLayoutData: currentLayout });
}
}
}
}
}, [uniqueNestedPageId, loadedPages, isEditMode, onPropsChange, nestedLayoutData]);
if (!nestedPageId) {
return <div className="p-4 text-center text-slate-500">Initializing nested layout...</div>;
}
@ -48,6 +81,12 @@ const LayoutContainerWidget: React.FC<LayoutContainerWidgetProps> = ({
isEditMode={isEditMode}
showControls={showControls}
className="p-0"
initialLayout={nestedLayoutData}
selectedWidgetId={selectedWidgetId}
onSelectWidget={onSelectWidget}
editingWidgetId={editingWidgetId}
onEditWidget={onEditWidget}
contextVariables={contextVariables}
/>
);
};

View File

@ -0,0 +1,764 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { T, translate } from '@/i18n';
import {
FileText,
Wand2,
Minimize,
Maximize,
PanelLeftClose,
PanelLeftOpen
} from 'lucide-react';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import MarkdownEditor from '@/components/MarkdownEditorEx';
import { Button } from '@/components/ui/button';
import { generateText, transcribeAudio, runTools } from '@/lib/openai';
import OpenAI from 'openai';
import { useAuth } from '@/hooks/useAuth';
import { createMarkdownToolPreset } from '@/lib/markdownImageTools';
import { toast } from 'sonner';
import { Card, CardContent } from '@/components/ui/card';
import AITextGenerator from '@/components/AITextGenerator';
import { getUserSecrets } from '@/components/ImageWizard/db';
import * as db from '@/lib/db';
import CollapsibleSection from '@/components/CollapsibleSection';
interface MarkdownTextWidgetEditProps {
content?: string;
placeholder?: string;
templates?: Array<{ name: string; template: string }>;
onPropsChange?: (props: Record<string, any>) => void;
contextVariables?: Record<string, any>;
onClose: () => void;
}
const MarkdownTextWidgetEdit: React.FC<MarkdownTextWidgetEditProps> = ({
content: propContent = '',
placeholder: propPlaceholder = 'Enter your text here...',
templates: propTemplates = [],
onPropsChange,
contextVariables,
onClose
}) => {
const { user } = useAuth();
const [content, setContent] = useState<string>(propContent);
const [templates, setTemplates] = useState(propTemplates);
// Internal state
const [showFilters, setShowFilters] = useState(false);
const [isLeftPaneCollapsed, setIsLeftPaneCollapsed] = useState(false);
const [activeLeftTab, setActiveLeftTab] = useState('ai');
const [isFullscreen, setIsFullscreen] = useState(false);
const [prompt, setPrompt] = useState('');
const [promptHistory, setPromptHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [isGenerating, setIsGenerating] = useState(false);
const [isOptimizing, setIsOptimizing] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [isTranscribing, setIsTranscribing] = useState(false);
const [selectedText, setSelectedText] = useState<string>('');
const [selectedProvider, setSelectedProvider] = useState('openai');
const [selectedModel, setSelectedModel] = useState('gpt-4o-mini');
const [imageToolsEnabled, setImageToolsEnabled] = useState(false);
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
const [contextMode, setContextMode] = useState<'clear' | 'selection' | 'all'>('all');
const [applicationMode, setApplicationMode] = useState<'replace' | 'insert' | 'append'>('append');
const [settingsLoaded, setSettingsLoaded] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const abortControllerRef = useRef<AbortController | null>(null);
const editorRef = useRef<any>(null);
const insertTransactionRef = useRef<{ text: string, onComplete: () => void } | null>(null);
const lastSavedContentRef = useRef<string>(propContent);
const isInternalUpdate = useRef(false);
const lastSelectionRef = useRef<{ isAtStart: boolean } | null>(null);
// Load AI Text Generator settings
useEffect(() => {
const loadSettings = async () => {
if (!user) {
setSettingsLoaded(true);
return;
}
try {
const settings = await db.getUserSettings(user.id);
const aiTextSettings = settings?.aiTextGenerator;
if (aiTextSettings) {
if (aiTextSettings.provider) setSelectedProvider(aiTextSettings.provider);
if (aiTextSettings.model) setSelectedModel(aiTextSettings.model);
if (typeof aiTextSettings.imageToolsEnabled === 'boolean') {
setImageToolsEnabled(aiTextSettings.imageToolsEnabled);
}
if (typeof aiTextSettings.webSearchEnabled === 'boolean') {
setWebSearchEnabled(aiTextSettings.webSearchEnabled);
}
if (aiTextSettings.contextMode) setContextMode(aiTextSettings.contextMode);
if (aiTextSettings.applicationMode) setApplicationMode(aiTextSettings.applicationMode);
}
} catch (error) {
console.error('Error loading AITextGenerator settings:', error);
} finally {
setSettingsLoaded(true);
}
};
loadSettings();
}, [user]);
// Save AI Text Generator settings
useEffect(() => {
if (!user || !settingsLoaded) return;
const saveSettings = async () => {
try {
const currentSettings = await db.getUserSettings(user.id);
const updatedSettings = {
...currentSettings,
aiTextGenerator: {
provider: selectedProvider,
model: selectedModel,
imageToolsEnabled,
webSearchEnabled,
contextMode,
applicationMode,
}
};
await db.updateUserSettings(user.id, updatedSettings);
} catch (error) {
console.error('Error saving AITextGenerator settings:', error);
}
};
const timeoutId = setTimeout(saveSettings, 500);
return () => clearTimeout(timeoutId);
}, [user, selectedProvider, selectedModel, imageToolsEnabled, webSearchEnabled, contextMode, applicationMode, settingsLoaded]);
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSubmittedContentRef = useRef<string>(propContent);
const prevPropContentRef = useRef<string>(propContent);
// Sync content from props, but ignore "echoes" of our own submissions
useEffect(() => {
// If prop hasn't changed, ignore
if (propContent === prevPropContentRef.current) return;
prevPropContentRef.current = propContent;
// If the incoming prop matches what we last submitted, it's just an echo.
// Ignore it to preserve any newer local edits.
if (propContent === lastSubmittedContentRef.current) {
return;
}
// Otherwise, it's a real external change (e.g. Undo/Redo, or initial load)
setContent(propContent);
}, [propContent]);
// Cleanup debounce on unmount
useEffect(() => {
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, []);
// Sync templates from props
useEffect(() => {
if (JSON.stringify(propTemplates) !== JSON.stringify(templates)) {
setTemplates(propTemplates || []);
}
}, [propTemplates, templates]);
const handleContentChange = useCallback((newContent: string) => {
if (insertTransactionRef.current && newContent.includes(insertTransactionRef.current.text)) {
insertTransactionRef.current.onComplete();
insertTransactionRef.current = null;
}
setContent(newContent);
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
debounceTimeoutRef.current = setTimeout(() => {
// Only submit if meaningful change?
// Actually, we should submit whatever the user typed.
lastSubmittedContentRef.current = newContent;
onPropsChange?.({
content: newContent,
placeholder: propPlaceholder,
templates
});
}, 1000);
}, [onPropsChange, propPlaceholder, templates]);
const handleSave = useCallback(() => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
lastSubmittedContentRef.current = content;
onPropsChange?.({
content,
placeholder: propPlaceholder,
templates
});
toast.success(translate('Saved'));
}, [content, onPropsChange, propPlaceholder, templates]);
const addToHistory = (text: string) => {
if (text.trim()) {
setPromptHistory(prev => [text, ...prev.slice(0, 49)]);
setHistoryIndex(-1);
}
};
const getProviderApiKey = async (provider: string): Promise<string | null> => {
if (!user) return null;
if (provider === 'openai') {
try {
const secrets = await getUserSecrets(user.id);
if (secrets && secrets.openai_api_key) return secrets.openai_api_key;
} catch (error) {
console.error('Error fetching user secrets:', error);
}
}
try {
const userProvider = await db.getProviderConfig(user.id, provider);
if (!userProvider) {
if (provider !== 'openai') console.warn(`No provider configuration found for ${provider}`);
return null;
}
const settings = userProvider.settings as any;
return settings?.apiKey || null;
} catch (error) {
console.error(`Error fetching API key for ${provider}:`, error);
return null;
}
};
const generateWithProvider = async (
prompt: string,
provider: string,
model: string,
signal?: AbortSignal,
webSearch?: boolean
): Promise<string | null> => {
try {
switch (provider) {
case 'openai':
const openaiKey = await getProviderApiKey('openai');
return await generateText(prompt, model, openaiKey || undefined, signal, undefined, webSearch);
case 'openrouter':
const openrouterKey = await getProviderApiKey('openrouter');
if (!openrouterKey) throw new Error('No OpenRouter API key found in provider configuration');
const openrouterClient = new OpenAI({
apiKey: openrouterKey,
baseURL: 'https://openrouter.ai/api/v1',
dangerouslyAllowBrowser: true,
defaultHeaders: { 'HTTP-Referer': window.location.origin, 'X-Title': document.title || 'PM-Pics' }
});
const response = await openrouterClient.chat.completions.create({
model: model,
messages: [{ role: "user", content: prompt }],
temperature: 0.7,
}, { signal });
return response.choices[0]?.message?.content || null;
default:
throw new Error(`Provider ${provider} not supported yet`);
}
} catch (error: any) {
if (error.name === 'AbortError' || error.message?.includes('aborted')) return null;
console.error(`Error generating with ${provider}:`, error.message);
throw error;
}
};
const navigateHistory = (direction: 'up' | 'down') => {
if (direction === 'up' && historyIndex < promptHistory.length - 1) {
const newIndex = historyIndex + 1;
setHistoryIndex(newIndex);
setPrompt(promptHistory[newIndex]);
} else if (direction === 'down') {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);
setPrompt(promptHistory[newIndex]);
} else {
setHistoryIndex(-1);
setPrompt('');
}
}
};
const handleCancelGeneration = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
setIsGenerating(false);
toast.info(translate('Generation cancelled'));
}
};
const handleGenerateText = async (options?: { referenceImages?: string[] }) => {
if (!prompt.trim()) { toast.error(translate('Please enter a prompt')); return; }
if (!selectedProvider || !selectedModel) { toast.error(translate('Please select a provider and model')); return; }
if (!user) { toast.error(translate('Please sign in to generate content')); return; }
abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal;
setIsGenerating(true);
addToHistory(prompt);
try {
let generatedText: string | null = null;
let contextPrompt = '';
switch (contextMode) {
case 'clear': contextPrompt = `USER REQUEST: ${prompt}\n\n`; break;
case 'selection':
contextPrompt = (selectedText && selectedText.trim())
? `CONTEXT - Selected Text:\n\`\`\`\n${selectedText}\n\`\`\`\n\nUSER REQUEST: ${prompt}\n\n`
: `USER REQUEST: ${prompt}\n\n`;
break;
case 'all':
contextPrompt = (content && content.trim())
? `CONTEXT - Existing Content:\n\`\`\`\n${content}\n\`\`\`\n\nUSER REQUEST: ${prompt}\n\n`
: `USER REQUEST: ${prompt}\n\n`;
break;
default: contextPrompt = `USER REQUEST: ${prompt}\n\n`;
}
if (imageToolsEnabled) {
if (selectedProvider !== 'openai') toast.warning(translate('Image Tools require OpenAI provider. Switching to OpenAI for this generation.'));
const enhancedPrompt = `${contextPrompt}IMPORTANT: Generate engaging markdown content. If the content would benefit from visual illustrations, use the available image generation tools to create relevant images and embed them in the markdown. Return ONLY raw markdown content with embedded images. Do NOT wrap in code blocks.`;
const openaiKey = await getProviderApiKey('openai');
const toolPreset = createMarkdownToolPreset(user.id, selectedModel);
const result = await runTools({
prompt: enhancedPrompt,
preset: toolPreset,
model: selectedProvider === 'openai' ? selectedModel : 'gpt-4o-mini',
apiKey: openaiKey || undefined,
images: options?.referenceImages,
});
if (result.success && result.content) generatedText = result.content;
else throw new Error(result.error || 'Tool execution failed');
} else {
const enhancedPrompt = `${contextPrompt}IMPORTANT: Return ONLY raw markdown content. Do NOT wrap your response in code blocks or markdown fences (no \`\`\`markdown). Just return the plain markdown text directly.`;
generatedText = await generateWithProvider(enhancedPrompt, selectedProvider, selectedModel, signal, webSearchEnabled);
}
if (signal.aborted) return;
if (generatedText) {
generatedText = generatedText.replace(/^```markdown\s*\n/i, '').replace(/^```\s*\n/, '').replace(/\n```\s*$/i, '').trim();
switch (applicationMode) {
case 'replace':
if (selectedText && selectedText.trim()) {
const newContent = content.replace(selectedText, generatedText);
handleContentChange(newContent);
toast.success(translate('Selected text replaced!'));
setSelectedText('');
} else {
toast.warning('Select text to replace.');
}
break;
case 'append':
const newContentAppend = content ? `${content}\n\n---\n\n${generatedText}` : generatedText;
handleContentChange(newContentAppend);
toast.success('Content appended!');
break;
case 'insert':
if (editorRef.current?.insertMarkdown) {
insertTransactionRef.current = {
text: generatedText,
onComplete: () => toast.success(translate('Content inserted!'))
};
editorRef.current.insertMarkdown(`\n${generatedText}\n`);
setPrompt('');
return;
}
const newContentInsert = content && content.trim() ? `${content}\n${generatedText}` : generatedText;
handleContentChange(newContentInsert);
toast.success(translate('Content added!'));
break;
}
setPrompt('');
} else {
toast.error(translate('Failed to generate content'));
}
} catch (error: any) {
if (error.name === 'AbortError' || error.message?.includes('aborted')) return;
console.error('Error generating content:', error.message);
toast.error(translate('Error generating content: ' + error.message));
} finally {
abortControllerRef.current = null;
setIsGenerating(false);
}
};
const handleOptimizePrompt = async () => {
if (!prompt.trim()) { toast.error(translate('Please enter a prompt to optimize')); return; }
if (!selectedProvider || !selectedModel) { toast.error(translate('Please select a provider and model')); return; }
setIsOptimizing(true);
try {
const optimizationPrompt = `You are an expert at optimizing prompts for better AI text generation.
Please optimize the following prompt to be clearer, more specific, and likely to produce better results:
"${prompt}"
Return ONLY the optimized prompt, no explanations or additional text.`;
const optimized = await generateWithProvider(optimizationPrompt, selectedProvider, selectedModel);
if (optimized) {
setPrompt(optimized.trim());
toast.success(translate('Prompt optimized with ' + selectedProvider + '!'));
} else {
toast.error(translate('Failed to optimize prompt'));
}
} catch (error: any) {
console.error('Error optimizing prompt:', error);
toast.error(translate('Error optimizing prompt: ' + error.message));
} finally {
setIsOptimizing(false);
}
};
const handleMicrophoneToggle = async () => {
if (isRecording) {
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop();
}
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) audioChunksRef.current.push(event.data);
};
mediaRecorder.onstop = async () => {
setIsRecording(false);
setIsTranscribing(true);
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
const audioFile = new File([audioBlob], 'recording.webm', { type: 'audio/webm' });
try {
const transcription = await transcribeAudio(audioFile, 'whisper-1');
if (transcription) {
setPrompt(prev => prev ? `${prev} ${transcription}` : transcription);
toast.success(translate('Audio transcribed successfully!'));
} else {
toast.error(translate('Failed to transcribe audio'));
}
} catch (error: any) {
console.error('Error transcribing audio:', error);
toast.error(translate('Error transcribing audio'));
} finally {
setIsTranscribing(false);
stream.getTracks().forEach(track => track.stop());
}
};
mediaRecorder.start();
setIsRecording(true);
toast.info(translate('Recording... Click again to stop'));
} catch (error: any) {
console.error('Error accessing microphone:', error);
toast.error(translate('Could not access microphone'));
setIsRecording(false);
}
};
const handleSaveTemplate = () => {
if (!prompt.trim()) { toast.error(translate('Please enter a prompt to save')); return; }
const name = window.prompt(translate('Enter template name:'));
if (name) {
const newTemplates = [...templates, { name, template: prompt }];
setTemplates(newTemplates);
isInternalUpdate.current = true;
lastSavedContentRef.current = content;
onPropsChange?.({
content,
placeholder: propPlaceholder,
templates: newTemplates
});
toast.success(translate('Template saved!'));
}
};
const handleDeleteTemplate = useCallback((index: number) => {
const newTemplates = templates.filter((_, i) => i !== index);
setTemplates(newTemplates);
isInternalUpdate.current = true;
lastSavedContentRef.current = content;
onPropsChange?.({
content,
placeholder: propPlaceholder,
templates: newTemplates
});
toast.success(translate('Template deleted'));
}, [templates, content, onPropsChange, propPlaceholder]);
const handleApplyTemplate = useCallback((template: string) => {
setPrompt(template);
toast.success(translate('Template applied'));
}, []);
const handleSelectionChange = useCallback((newSelectedText: string) => {
setSelectedText(newSelectedText);
if (newSelectedText && newSelectedText.trim()) {
if (applicationMode !== 'replace') setApplicationMode('replace');
}
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
if (range.collapsed && range.startOffset === 0) {
if (newSelectedText.trim() === '') {
lastSelectionRef.current = { isAtStart: true };
return;
}
}
}
lastSelectionRef.current = { isAtStart: false };
if (newSelectedText && !showFilters && !isFullscreen) {
toast.info(translate('Text selected! Use AI tools to work with selection.'));
}
}, [showFilters, isFullscreen, applicationMode]);
const handleFullscreenToggle = useCallback(() => {
setIsFullscreen(prev => {
const newValue = !prev;
if (newValue) {
document.body.style.setProperty('overflow', 'hidden', 'important');
} else {
document.body.style.removeProperty('overflow');
}
return newValue;
});
}, []);
useEffect(() => {
return () => {
if (isFullscreen) document.body.style.removeProperty('overflow');
};
}, [isFullscreen]);
// Render Fullscreen
if (isFullscreen) {
return (
<div className="fixed inset-0 z-50 bg-background flex flex-col">
<div className="flex items-center justify-between p-4 border-b bg-background flex-shrink-0">
<div className="flex items-center gap-2 text-lg font-medium">
<FileText className="h-5 w-5" />
<span><T>Fullscreen Editor</T></span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={!isLeftPaneCollapsed ? "default" : "outline"}
onClick={() => setIsLeftPaneCollapsed(!isLeftPaneCollapsed)}
title={isLeftPaneCollapsed ? "Show Sidebar" : "Hide Sidebar"}
>
{isLeftPaneCollapsed ? <PanelLeftOpen className="h-4 w-4 mr-2" /> : <PanelLeftClose className="h-4 w-4 mr-2" />}
<T>{isLeftPaneCollapsed ? "Show Sidebar" : "Sidebar"}</T>
</Button>
<Button size="sm" variant="outline" onClick={handleFullscreenToggle}>
<Minimize className="h-4 w-4 mr-2" />
<T>Exit Fullscreen</T>
</Button>
<Button size="sm" variant="default" onClick={onClose} >
<T>Done</T>
</Button>
</div>
</div>
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
<ResizablePanel
defaultSize={25}
minSize={20}
maxSize={40}
collapsible={true}
collapsedSize={0}
onCollapse={() => setIsLeftPaneCollapsed(true)}
onExpand={() => setIsLeftPaneCollapsed(false)}
className={isLeftPaneCollapsed ? "hidden" : ""}
>
<div className="h-full flex flex-col bg-muted/10 border-r">
<Tabs value={activeLeftTab} onValueChange={setActiveLeftTab} className="flex-1 flex flex-col min-h-0">
<div className="p-2 border-b bg-muted/30">
<TabsList className="w-full grid grid-cols-2">
<TabsTrigger value="ai" className="flex items-center gap-2">
<Wand2 className="h-3 w-3" />
<T>AI Generator</T>
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="ai" className="flex-1 min-h-0 m-0 p-0">
<ScrollArea className="h-full">
<div className="p-4 pb-20">
<AITextGenerator
prompt={prompt}
onPromptChange={(newPrompt) => {
setPrompt(newPrompt);
if (historyIndex !== -1) setHistoryIndex(-1);
}}
provider={selectedProvider}
model={selectedModel}
onProviderChange={setSelectedProvider}
onModelChange={setSelectedModel}
imageToolsEnabled={imageToolsEnabled}
onImageToolsChange={setImageToolsEnabled}
webSearchEnabled={webSearchEnabled}
onWebSearchChange={setWebSearchEnabled}
contextMode={contextMode}
onContextModeChange={setContextMode}
hasSelection={!!selectedText && selectedText.trim().length > 0}
selectionLength={selectedText ? selectedText.length : 0}
hasContent={!!content && content.trim().length > 0}
contentLength={content ? content.length : 0}
applicationMode={applicationMode}
onApplicationModeChange={setApplicationMode}
templates={templates}
onApplyTemplate={handleApplyTemplate}
onSaveTemplate={handleSaveTemplate}
onDeleteTemplate={handleDeleteTemplate}
onGenerate={handleGenerateText}
onOptimize={handleOptimizePrompt}
onCancel={handleCancelGeneration}
isGenerating={isGenerating}
isOptimizing={isOptimizing}
onMicrophoneToggle={handleMicrophoneToggle}
isRecording={isRecording}
isTranscribing={isTranscribing}
promptHistory={promptHistory}
historyIndex={historyIndex}
onNavigateHistory={navigateHistory}
/>
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={75}>
<div className="h-full flex flex-col bg-background">
<MarkdownEditor
value={content}
onChange={handleContentChange}
placeholder={propPlaceholder}
className="h-full w-full border-0"
onSelectionChange={handleSelectionChange}
onSave={handleSave}
editorRef={editorRef}
/>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
// Normal Card layout
return (
<Card>
<CardContent className="p-4 space-y-4">
<div className="flex items-center justify-between flex-nowrap">
<div className="flex items-center gap-2 flex-nowrap shrink-0">
<Button
size="sm"
variant={isFullscreen ? "default" : "outline"}
onClick={handleFullscreenToggle}
>
{isFullscreen ? (
<><Minimize className="h-3 w-3 mr-2" /><T>Exit Fullscreen</T></>
) : (
<><Maximize className="h-3 w-3 mr-2" /><T>Fullscreen</T></>
)}
</Button>
<Button
size="sm"
variant="default"
onClick={onClose}
disabled={isFullscreen}
>
<T>Done</T>
</Button>
</div>
</div>
<MarkdownEditor
value={content}
onChange={handleContentChange}
placeholder={propPlaceholder}
className="min-h-[200px]"
onSelectionChange={handleSelectionChange}
onSave={handleSave}
editorRef={editorRef}
/>
<div className="pt-3 border-t">
<CollapsibleSection
title={<div className="flex items-center gap-2"><Wand2 className="h-4 w-4" /><T>AI Assistant</T></div>}
initiallyOpen={true}
storageKey="markdown-widget-ai-assistant"
minimal={true}
className="border-none p-0"
headerClassName="flex justify-between items-center py-2 cursor-pointer hover:bg-muted/50 rounded-md px-2"
contentClassName="pt-4"
>
<AITextGenerator
prompt={prompt}
onPromptChange={(newPrompt) => {
setPrompt(newPrompt);
if (historyIndex !== -1) setHistoryIndex(-1);
}}
provider={selectedProvider}
model={selectedModel}
onProviderChange={setSelectedProvider}
onModelChange={setSelectedModel}
imageToolsEnabled={imageToolsEnabled}
onImageToolsChange={setImageToolsEnabled}
webSearchEnabled={webSearchEnabled}
onWebSearchChange={setWebSearchEnabled}
contextMode={contextMode}
onContextModeChange={setContextMode}
hasSelection={!!selectedText && selectedText.trim().length > 0}
hasContent={!!content && content.trim().length > 0}
applicationMode={applicationMode}
onApplicationModeChange={setApplicationMode}
templates={templates}
onApplyTemplate={handleApplyTemplate}
onSaveTemplate={handleSaveTemplate}
onDeleteTemplate={handleDeleteTemplate}
onGenerate={handleGenerateText}
onOptimize={handleOptimizePrompt}
onCancel={handleCancelGeneration}
isGenerating={isGenerating}
isOptimizing={isOptimizing}
onMicrophoneToggle={handleMicrophoneToggle}
isRecording={isRecording}
isTranscribing={isTranscribing}
promptHistory={promptHistory}
historyIndex={historyIndex}
onNavigateHistory={navigateHistory}
/>
</CollapsibleSection>
</div>
</CardContent>
</Card>
);
};
export default MarkdownTextWidgetEdit;

File diff suppressed because it is too large Load Diff

View File

@ -117,6 +117,7 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
if (pictureError) throw pictureError;
setPicture(pictureData);
console.log('Fetching profile for user:', pictureData.user_id);
// Fetch user profile
const { data: profileData } = await supabase
.from('profiles')

View File

@ -30,6 +30,7 @@ interface TabsWidgetProps {
onSelectContainer?: (containerId: string | null, pageId?: string) => void;
editingWidgetId?: string | null;
onEditWidget?: (id: string | null) => void;
contextVariables?: Record<string, any>;
}
const TabsWidget: React.FC<TabsWidgetProps> = ({
@ -48,6 +49,7 @@ const TabsWidget: React.FC<TabsWidgetProps> = ({
onSelectContainer,
editingWidgetId,
onEditWidget,
contextVariables,
}) => {
const [currentTabId, setCurrentTabId] = useState<string | undefined>(activeTabId);
const { loadedPages, addPageContainer } = useLayout();
@ -194,7 +196,7 @@ const TabsWidget: React.FC<TabsWidgetProps> = ({
</div>
{/* Content Area */}
<div className={cn("flex-1 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-b-md relative overflow-hidden", contentClassName)}>
<div className={cn("flex-1 dark:bg-slate-900/50 border border-slate-200 dark:border-slate-800 rounded-b-md relative overflow-hidden", contentClassName)}>
{currentTab ? (
<GenericCanvas
key={currentTab.layoutId} // Important: force remount so GenericCanvas loads new pageId
@ -209,6 +211,7 @@ const TabsWidget: React.FC<TabsWidgetProps> = ({
onSelectContainer={onSelectContainer}
editingWidgetId={editingWidgetId}
onEditWidget={onEditWidget}
contextVariables={contextVariables}
/>
) : (
<div className="flex items-center justify-center h-full text-slate-400">

View File

@ -20,12 +20,12 @@ export const WidgetMovementControls: React.FC<WidgetMovementControlsProps> = ({
className = ''
}) => {
return (
<div className={`relative w-16 h-16 ${className}`}>
<div className={`relative w-12 h-12 ${className}`}>
{/* Cross/Plus pattern with connecting lines */}
{/* Vertical line */}
<div className="absolute top-2 bottom-2 left-1/2 w-0.5 bg-slate-300 dark:bg-slate-600 transform -translate-x-1/2 opacity-30"></div>
{/* Horizontal line */}
<div className="absolute left-2 right-2 top-1/2 h-0.5 bg-slate-300 dark:bg-slate-600 transform -translate-y-1/2 opacity-30"></div>

View File

@ -70,7 +70,7 @@ export const WidgetPropertyPanel: React.FC<WidgetPropertyPanelProps> = ({
};
return (
<div className={`flex flex-col h-full bg-white dark:bg-slate-900 border-l border-slate-200 dark:border-slate-800 ${className}`}>
<div className={`flex flex-col h-full dark:bg-slate-900/50 border-l border-slate-200 dark:border-slate-800 ${className}`}>
<div className="p-4 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
<h3 className="font-semibold text-sm truncate" title={widgetDefinition.metadata.name}>
{widgetDefinition.metadata.name}

View File

@ -1,4 +1,4 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { createContext, useContext, useEffect, useState, useRef } from 'react';
import { User, Session } from '@supabase/supabase-js';
import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
@ -23,49 +23,116 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [session, setSession] = useState<Session | null>(null);
const [roles, setRoles] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const loadingRef = useRef(true); // Track loading state for timeouts/callbacks without closure staleness
const { toast } = useToast();
useEffect(() => {
setLoading(true);
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
setLoading(false);
});
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setSession(session);
setUser(session?.user ?? null);
}
);
return () => subscription.unsubscribe();
}, []);
loadingRef.current = loading;
}, [loading]);
useEffect(() => {
let mounted = true;
let cleanup: (() => void) | undefined;
const loadRoles = async () => {
if (user) {
try {
const roles = await db.fetchUserRoles(user.id);
if (mounted) setRoles(roles);
} catch (error) {
console.error('Error fetching user roles:', error);
if (mounted) setRoles([]);
// Simplified and robust auth initialization
const initAuth = async () => {
const startTime = Date.now();
const MIN_LOADING_TIME = 500;
// 1. Setup listener *before* checking session to catch any immediate events
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
if (mounted) {
setSession(session);
setUser(session?.user ?? null);
if (session?.user) {
// OPTIMIZATION: Check for cached roles to unblock UI immediately
const cachedRoles = localStorage.getItem(`polymech-roles-${session.user.id}`);
if (cachedRoles) {
try {
const parsedRoles = JSON.parse(cachedRoles);
if (mounted) setRoles(parsedRoles);
// If we have cached roles, we can stop loading immediately (after min time)
// We will still fetch fresh roles in background
if (loadingRef.current) {
const elapsed = Date.now() - startTime;
if (elapsed < MIN_LOADING_TIME) {
await new Promise(r => setTimeout(r, MIN_LOADING_TIME - elapsed));
}
if (mounted) setLoading(false);
}
} catch (e) {
console.warn('Failed to parse cached roles', e);
}
}
// Fetch fresh roles independently
db.fetchUserRoles(session.user.id).then(roles => {
if (!mounted) return;
// Update cache
localStorage.setItem(`polymech-roles-${session.user.id}`, JSON.stringify(roles));
// Compare with current roles to avoid unnecessary re-renders if possible?
// For now just set them. React handles strict equality checks on state setters usually,
// but arrays are new references.
// Let's just set it.
if (mounted) setRoles(roles);
// If we were still loading (no cache), stop loading now
if (loadingRef.current) {
if (mounted) setLoading(false);
}
}).catch(err => {
console.error('Error fetching user roles:', err);
// If we were still loading and failed, we should stop loading (roles=[], guest?)
if (loadingRef.current && !cachedRoles) {
if (mounted) setLoading(false);
}
});
} else {
if (mounted) {
setRoles([]);
if (loadingRef.current) setLoading(false);
}
}
}
}
} else {
if (mounted) setRoles([]);
);
// 2. Trigger initial check, but don't block narrowly on it
// The onAuthStateChange often fires INITIAL_SESSION immediately.
const safetyTimeout = setTimeout(() => {
if (mounted && loadingRef.current) {
console.warn('Auth initialization safety timeout (5s) - forcing app load');
setLoading(false);
}
}, 5000);
try {
const { error } = await supabase.auth.getSession();
if (error) throw error;
} catch (err) {
console.error("getSession failed or timed out", err);
// If this fails, the safety timeout or event listener (with null session) should handle it
}
return () => {
clearTimeout(safetyTimeout);
subscription.unsubscribe();
};
};
loadRoles();
initAuth().then(c => { cleanup = c; });
return () => {
mounted = false;
if (cleanup) cleanup();
};
}, [user]);
}, []);
const signUp = async (email: string, password: string, username: string, displayName: string) => {
const redirectUrl = `${window.location.origin}/`;

View File

@ -0,0 +1,71 @@
import { useState, useCallback } from 'react';
import { toast } from 'sonner';
import { translate } from '@/i18n';
interface UseImageDropProps {
onFilesDrop: (files: File[]) => void;
isEditMode: boolean;
enabled?: boolean;
}
export const useImageDrop = ({ onFilesDrop, isEditMode, enabled = true }: UseImageDropProps) => {
const [isDragging, setIsDragging] = useState(false);
const handleDragEnter = useCallback((e: React.DragEvent) => {
if (!isEditMode || !enabled) return;
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes('Files')) {
setIsDragging(true);
}
}, [isEditMode, enabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
if (!isEditMode || !enabled) return;
e.preventDefault();
e.stopPropagation();
// Check if we're actually leaving the element
if (e.currentTarget.contains(e.relatedTarget as Node)) {
return;
}
setIsDragging(false);
}, [isEditMode, enabled]);
const handleDragOver = useCallback((e: React.DragEvent) => {
if (!isEditMode || !enabled) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
}, [isEditMode, enabled]);
const handleDrop = useCallback((e: React.DragEvent) => {
if (!isEditMode || !enabled) 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 an image file'));
}
return;
}
onFilesDrop(imageFiles);
}, [isEditMode, enabled, onFilesDrop]);
return {
isDragging,
handlers: {
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: handleDragOver,
onDrop: handleDrop
}
};
};

View File

@ -5,7 +5,7 @@ import { T, translate } from '@/i18n';
import { useAuth } from '@/hooks/useAuth';
import { transcribeAudio, runTools } from '@/lib/openai';
import { useLog } from '@/contexts/LogContext';
import { createPageTool, createPageInDb } from '@/lib/pageTools';
import { createPageInDb } from '@/lib/pageTools';
// Define the states for the page generation process
type GenerationStatus = 'idle' | 'transcribing' | 'generating' | 'creating' | 'success' | 'error';

View File

@ -56,6 +56,15 @@
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
/* Body Background Variables - Light Mode Defaults */
--body-bg: hsl(var(--background));
--body-bg-gradient: linear-gradient(135deg, hsl(0 0% 100%) 0%, hsl(262 83% 58% / 0.05) 100%);
--body-bg-cover: transparent;
--body-font-weight: 400;
--body-letter-spacing: normal;
--body-bg-image: none;
--body-bg-image-size: cover;
}
.dark {
@ -137,6 +146,16 @@
--sidebar-accent-foreground: 0 0% 95%;
--sidebar-border: 0 0% 15%;
--sidebar-ring: 270 91% 75%;
/* Body Background Variables */
--body-bg: hsl(var(--background));
--body-bg-gradient: var(--gradient-hero);
--body-bg-cover: url('/cover-dark.jpg');
--body-font-weight: 400;
--body-letter-spacing: normal;
--body-bg-image: url('/pattern-dark.png');
--body-bg-image-size: 140px;
/* Adjust as needed for pattern density */
}
}
@ -145,8 +164,73 @@
@apply border-border;
}
html {
height: 100%;
background-color: var(--body-bg);
background-image: var(--body-bg-gradient);
&:before {
content: '';
position: fixed;
top: 0;
inset-inline-start: 0;
inset-inline-end: 0;
height: 100%;
z-index: -5;
background: var(--body-bg-gradient);
pointer-events: none;
}
&:after {
content: '';
position: fixed;
top: 0;
height: 100%;
inset-inline-start: 0;
inset-inline-end: 0;
z-index: -10;
background-color: var(--body-bg);
background-image: var(--body-bg-gradient), var(--body-bg-cover);
background-repeat: no-repeat;
background-position: center;
background-attachment: initial;
transition: background .2s linear;
background-size: cover;
pointer-events: none;
}
}
body {
@apply bg-background text-foreground;
/* @apply bg-background text-foreground; - Removing bg-background as it's handled by html/body layers now */
@apply text-foreground;
background: none;
font-weight: var(--body-font-weight);
letter-spacing: var(--body-letter-spacing);
position: relative;
min-height: 100vh;
z-index: 1;
/* Changed from 9999 to avoid stacking issues with modals */
&:before {
content: "";
position: fixed;
top: 0;
inset-inline-start: 0;
inset-inline-end: 0;
height: 100%;
background-attachment: initial;
background-color: transparent;
background-image: var(--body-bg-image);
background-size: var(--body-bg-image-size);
background-repeat: repeat;
z-index: -5;
pointer-events: none;
}
&:not(.app-init) * {
transition: none !important;
animation: none !important;
}
}
}
@ -181,6 +265,11 @@
.scrollbar-custom::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.5);
}
/* Force background transparency in dark mode to show global pattern */
.dark .bg-background {
background-color: rgba(0, 0, 0, 0.5) !important;
}
}
@layer components {

View File

@ -145,6 +145,15 @@ export const fetchMediaItemsByIds = async (
return adaptSupabasePicturesToMediaItems(data);
};
/**
* Filters out pictures that belong to private collections the user doesn't have access to
* (Placeholder implementation - currently returns all pictures)
*/
async function filterPrivateCollectionPictures(pictures: any[]): Promise<any[]> {
// TODO: Implement actual privacy filtering logic based on collections
return pictures;
}
/**
* Fetches and merges pictures and videos from the database
* Returns a unified array of media items sorted by created_at
@ -201,7 +210,8 @@ export async function fetchMediaItems(options: FetchMediaOptions = {}): Promise<
// Convert to unified MediaItem format
// Videos have type='mux-video' or type='video', everything else is a picture
const allMedia: MediaItem[] = picturesWithComments.map(p => {
// Cast to any to bypass strict type checking for legacy MediaItem shape mismatch
const allMedia: MediaItem[] = (picturesWithComments.map(p => {
const isLegacyVideo = p.type === 'video-intern';
let url = p.image_url; // Default for pictures
if (isLegacyVideo) {
@ -210,7 +220,7 @@ export async function fetchMediaItems(options: FetchMediaOptions = {}): Promise<
return {
id: p.id,
type: isLegacyVideo ? 'video' as const : 'picture' as const,
type: isLegacyVideo ? 'video' : 'picture',
title: p.title,
description: p.description,
url: url, // Use the constructed URL
@ -221,7 +231,7 @@ export async function fetchMediaItems(options: FetchMediaOptions = {}): Promise<
comments_count: p.comments_count,
meta: p.meta,
};
}).sort(
}) as any[]).sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
@ -282,6 +292,7 @@ export const fetchFullPost = async (postId: string, client?: SupabaseClient) =>
export const fetchAuthorProfile = async (userId: string, client?: SupabaseClient): Promise<UserProfile | null> => {
const supabase = client || defaultSupabase;
return fetchWithDeduplication(`profile-${userId}`, async () => {
console.log('Fetching profile for user:', userId);
const { data, error } = await supabase
.from('profiles')
.select('user_id, avatar_url, display_name, username')
@ -422,19 +433,71 @@ export const deletePicture = async (id: string, client?: SupabaseClient) => {
return await response.json();
};
export const updatePostDetails = async (postId: string, updates: { title: string, description: string }, client?: SupabaseClient) => {
const supabase = client || defaultSupabase;
const { error } = await supabase
.from('posts')
.update(updates)
.eq('id', postId);
if (error) throw error;
export const deletePost = async (id: string, client?: SupabaseClient) => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
// Cache invalidation handled by React Query
// Invalidate Server Cache
await invalidateServerCache(['posts']);
if (!token) throw new Error('No active session');
const response = await fetch(`/api/posts/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`Failed to delete post: ${response.statusText}`);
}
return await response.json();
};
export const createPost = async (postData: { title: string, description?: string, settings?: any, meta?: any }) => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
if (!token) throw new Error('No active session');
const response = await fetch(`/api/posts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(postData)
});
if (!response.ok) {
throw new Error(`Failed to create post: ${response.statusText}`);
}
return await response.json();
};
export const updatePostDetails = async (postId: string, updates: { title?: string, description?: string, settings?: any, meta?: any }, client?: SupabaseClient) => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
if (!token) throw new Error('No active session');
const response = await fetch(`/api/posts/${postId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(updates)
});
if (!response.ok) {
throw new Error(`Failed to update post details: ${response.statusText}`);
}
return await response.json();
};
export const unlinkPictures = async (ids: string[], client?: SupabaseClient) => {
const supabase = client || defaultSupabase;
const { error } = await supabase
@ -508,6 +571,7 @@ export const getUserGoogleApiKey = async (userId: string, client?: SupabaseClien
export const getUserSecrets = async (userId: string, client?: SupabaseClient) => {
const supabase = client || defaultSupabase;
return fetchWithDeduplication(`user-secrets-${userId}`, async () => {
console.log('Fetching user secrets for user:', userId);
const { data, error } = await supabase
.from('user_secrets')
.select('settings')
@ -526,6 +590,7 @@ export const getUserSecrets = async (userId: string, client?: SupabaseClient) =>
export const getProviderConfig = async (userId: string, provider: string, client?: SupabaseClient) => {
const supabase = client || defaultSupabase;
return fetchWithDeduplication(`provider-${userId}-${provider}`, async () => {
console.log('Fetching provider config for user:', userId, 'provider:', provider);
const { data, error } = await supabase
.from('provider_configs')
.select('settings')
@ -792,30 +857,87 @@ export const deleteCategory = async (id: string) => {
return await res.json();
};
export const updatePostMeta = async (postId: string, metaUpdates: any) => {
// Fetch current post to merge meta
const { data: post, error: fetchError } = await defaultSupabase
.from('posts')
.select('meta')
.eq('id', postId)
.single();
export const updatePostMeta = async (postId: string, meta: any, client?: SupabaseClient) => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
if (fetchError) throw fetchError;
if (!token) throw new Error('No active session');
const currentMeta = (post?.meta as any) || {};
const newMeta = { ...currentMeta, ...metaUpdates };
const response = await fetch(`/api/posts/${postId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ meta })
});
const { data, error } = await defaultSupabase
.from('posts')
.update({ meta: newMeta, updated_at: new Date().toISOString() })
.eq('id', postId)
.select()
.single();
if (!response.ok) {
throw new Error(`Failed to update post meta: ${response.statusText}`);
}
if (error) throw error;
return data;
return await response.json();
};
export const fetchAnalytics = async (options: { limit?: number, startDate?: string, endDate?: string } = {}) => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
if (!token) throw new Error('No active session');
const headers: HeadersInit = {};
headers['Authorization'] = `Bearer ${token}`;
const params = new URLSearchParams();
if (options.limit) params.append('limit', String(options.limit));
if (options.startDate) params.append('startDate', options.startDate);
if (options.endDate) params.append('endDate', options.endDate);
// Server URL logic from fetchMediaItemsByIds
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || 'http://localhost:3333';
const res = await fetch(`${serverUrl}/api/analytics?${params.toString()}`, { headers });
if (!res.ok) {
if (res.status === 403 || res.status === 401) {
throw new Error('Unauthorized');
}
throw new Error(`Failed to fetch analytics: ${res.statusText}`);
}
return await res.json();
};
export const clearAnalytics = async () => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
if (!token) throw new Error('No active session');
const headers: HeadersInit = {};
headers['Authorization'] = `Bearer ${token}`;
// Server URL logic from fetchMediaItemsByIds
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || 'http://localhost:3333';
const res = await fetch(`${serverUrl}/api/analytics`, {
method: 'DELETE',
headers
});
if (!res.ok) {
if (res.status === 403 || res.status === 401) {
throw new Error('Unauthorized');
}
throw new Error(`Failed to clear analytics: ${res.statusText}`);
}
return await res.json();
};
// --- i18n ---
@ -1082,7 +1204,8 @@ export const updateUserSecrets = async (userId: string, secrets: Record<string,
/**
* Get user variables from user_secrets table (settings.variables)
*/
export const getUserVariables = async (userId: string): Promise<Record<string, string> | null> => {
export const getUserVariables = async (userId: string): Promise<Record<string, any> | null> => {
console.log('getUserVariables Fetching user variables for user:', userId);
try {
const { data: secretData } = await defaultSupabase
.from('user_secrets')
@ -1093,7 +1216,7 @@ export const getUserVariables = async (userId: string): Promise<Record<string, s
if (!secretData?.settings) return null;
const settings = secretData.settings as Record<string, any>;
return (settings.variables as Record<string, string>) || {};
return (settings.variables as Record<string, any>) || {};
} catch (error) {
console.error('Error fetching user variables:', error);
return null;
@ -1103,7 +1226,8 @@ export const getUserVariables = async (userId: string): Promise<Record<string, s
/**
* Update user variables in user_secrets table (settings.variables)
*/
export const updateUserVariables = async (userId: string, variables: Record<string, string>): Promise<void> => {
export const updateUserVariables = async (userId: string, variables: Record<string, any>): Promise<void> => {
console.log('Updating user variables for user:', userId);
try {
// Check if record exists
const { data: existing } = await defaultSupabase

View File

@ -0,0 +1,48 @@
export const normalizeValue = (val: any): string => {
if (val === null || val === undefined) return '';
if (typeof val === 'string') return val;
return String(val);
};
export const mergePageVariables = (page: any, userVariables: Record<string, string> = {}): Record<string, string> => {
const globalVariables: Record<string, string> = {};
// 0. User Variables (Base)
Object.entries(userVariables).forEach(([k, v]) => {
globalVariables[k] = normalizeValue(v);
});
// Category Variables
if (page.category_paths && Array.isArray(page.category_paths)) {
// Flatten all categories across all paths
const allCategories = page.category_paths.flat();
for (const cat of allCategories) {
if (cat.meta?.variables) {
Object.entries(cat.meta.variables).forEach(([k, v]) => {
globalVariables[k] = normalizeValue(v);
});
}
}
}
// Type Values
if (page.meta?.typeValues) {
Object.values(page.meta.typeValues).forEach((typeVal: any) => {
if (typeVal && typeof typeVal === 'object') {
Object.entries(typeVal).forEach(([k, v]) => {
globalVariables[k] = normalizeValue(v);
});
}
});
}
// Page Variables (Highest Precedence)
if (page.meta?.variables) {
Object.entries(page.meta.variables).forEach(([k, v]) => {
globalVariables[k] = normalizeValue(v);
});
}
return globalVariables;
};

View File

@ -53,3 +53,32 @@ export const uploadImage = async (file: File, userId: string): Promise<{ publicU
return { publicUrl };
}
};
/**
* Creates a picture record in the database.
*/
export const createPictureRecord = async (userId: string, file: File, publicUrl: string, meta?: any) => {
const { data, error } = await supabase
.from('pictures')
.insert({
user_id: userId,
title: file.name.split('.')[0] || 'Uploaded Image',
description: null,
image_url: publicUrl,
type: 'supabase-image',
meta: meta || {},
})
.select()
.single();
if (error) throw error;
return data;
};
/**
* Uploads an image and creates a picture record in one go.
*/
export const uploadAndCreatePicture = async (file: File, userId: string) => {
const { publicUrl, meta } = await uploadImage(file, userId);
return await createPictureRecord(userId, file, publicUrl, meta);
};

View File

@ -5,7 +5,58 @@ import { Download, Upload, Grid3X3 } from 'lucide-react';
import { useLayout } from '@/modules/layout/LayoutContext';
import { LayoutContainer } from './LayoutContainer';
import { WidgetPalette } from './WidgetPalette';
import { useImageDrop } from '@/hooks/useImageDrop';
import { uploadAndCreatePicture } from '@/lib/uploadUtils';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { translate } from '@/i18n';
/* {isEditMode && (
<div className="flex items-center gap-2 flex-wrap justify-end">
{onSave && (
<Button
onClick={handleSaveToApi}
size="sm"
disabled={isSaving}
className={`glass-button ${saveStatus === 'success'
? 'bg-green-500 text-white'
: saveStatus === 'error'
? 'bg-red-500 text-white'
: 'bg-blue-500 text-white'
}`}
title="Save layout to server"
>
{isSaving ? (
<>
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
<T>Saving...</T>
</>
) : saveStatus === 'success' ? (
<>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<T>Saved!</T>
</>
) : saveStatus === 'error' ? (
<>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<T>Failed</T>
</>
) : (
<>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3-3m0 0l-3 3m3-3v12" />
</svg>
<T>Save</T>
</>
)}
</Button>
)}
</div>
)}*/
interface GenericCanvasProps {
pageId: string;
@ -14,6 +65,7 @@ interface GenericCanvasProps {
showControls?: boolean;
className?: string;
selectedWidgetId?: string | null;
selectedWidgetIds?: Set<string>;
onSelectWidget?: (widgetId: string, pageId?: string) => void;
selectedContainerId?: string | null;
onSelectContainer?: (containerId: string | null, pageId?: string) => void;
@ -32,6 +84,7 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
showControls = true,
className = '',
selectedWidgetId,
selectedWidgetIds,
onSelectWidget,
selectedContainerId: propSelectedContainerId,
onSelectContainer: propOnSelectContainer,
@ -191,6 +244,37 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
}
};
// Drag and Drop Handler for Image Files
const handleFilesDrop = async (files: File[], targetContainerId?: string, targetColumn?: number) => {
if (!targetContainerId) return;
const toastId = toast.loading(translate('Uploading images...'));
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
toast.error(translate('You must be logged in to upload images'), { id: toastId });
return;
}
for (const file of files) {
// Upload and create record
const picture = await uploadAndCreatePicture(file, user.id);
if (picture) {
// Create widget with initial props
await addWidgetToPage(pageId, targetContainerId, 'photo-card', targetColumn, {
pictureId: picture.id
});
}
}
toast.success(translate('Images added to page'), { id: toastId });
} catch (error) {
console.error('Failed to process dropped files:', error);
toast.error(translate('Failed to upload images'), { id: toastId });
}
};
const totalWidgets = layout.containers.reduce((total, container) => {
const getContainerWidgetCount = (cont: any): number => {
let count = cont.widgets.length;
@ -211,78 +295,16 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
{/* Header with Controls */}
{showControls && (
<div className="glass-card p-4">
<div className="">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold glass-text">
{layout.name}
</h2>
</div>
{/* Edit Mode Controls */}
{isEditMode && (
<div className="flex items-center gap-2 flex-wrap justify-end">
<Button
onClick={async () => {
try {
await addPageContainer(pageId);
} catch (error) {
console.error('Failed to add container:', error);
}
}}
size="sm"
className="glass-button status-gradient-connected text-white border-0"
title="Add container"
>
<Grid3X3 className="h-4 w-4 mr-2" />
<T>Add Container</T>
</Button>
{onSave && (
<Button
onClick={handleSaveToApi}
size="sm"
disabled={isSaving}
className={`glass-button ${saveStatus === 'success'
? 'bg-green-500 text-white'
: saveStatus === 'error'
? 'bg-red-500 text-white'
: 'bg-blue-500 text-white'
}`}
title="Save layout to server"
>
{isSaving ? (
<>
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
<T>Saving...</T>
</>
) : saveStatus === 'success' ? (
<>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<T>Saved!</T>
</>
) : saveStatus === 'error' ? (
<>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<T>Failed</T>
</>
) : (
<>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3-3m0 0l-3 3m3-3v12" />
</svg>
<T>Save</T>
</>
)}
</Button>
)}
</div>
)}
</div>
{/* Layout Info */}
@ -312,6 +334,7 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
onAddWidget={handleAddWidget}
isCompactMode={className.includes('p-0')}
selectedWidgetId={selectedWidgetId}
selectedWidgetIds={selectedWidgetIds}
onSelectWidget={onSelectWidget}
editingWidgetId={editingWidgetId}
onEditWidget={onEditWidget}
@ -368,6 +391,7 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
console.error('Failed to remove container:', error);
}
}}
onFilesDrop={(files, targetColumn) => handleFilesDrop(files, container.id, targetColumn)}
/>
))}

View File

@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { cn } from '@/lib/utils';
import { useImageDrop } from '@/hooks/useImageDrop';
import { Button } from '@/components/ui/button';
import { Plus, Minus, Grid3X3, Trash2, Settings, ArrowUp, ArrowDown } from 'lucide-react';
import { LayoutContainer as LayoutContainerType, WidgetInstance } from '@/modules/layout/LayoutManager';
@ -27,6 +28,7 @@ interface LayoutContainerProps {
canMoveContainerUp?: boolean;
canMoveContainerDown?: boolean;
selectedWidgetId?: string | null;
selectedWidgetIds?: Set<string>;
onSelectWidget?: (widgetId: string, pageId?: string) => void;
depth?: number;
isCompactMode?: boolean;
@ -34,6 +36,7 @@ interface LayoutContainerProps {
onEditWidget?: (widgetId: string | null) => void;
newlyAddedWidgetId?: string | null;
contextVariables?: Record<string, any>;
onFilesDrop?: (files: File[], targetColumn?: number) => void;
}
const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
@ -53,6 +56,7 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
canMoveContainerUp,
canMoveContainerDown,
selectedWidgetId,
selectedWidgetIds,
onSelectWidget,
depth = 0,
isCompactMode = false,
@ -60,7 +64,12 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
onEditWidget,
newlyAddedWidgetId,
contextVariables,
onFilesDrop,
}) => {
const { isDragging, handlers } = useImageDrop({
onFilesDrop: (files) => onFilesDrop?.(files),
isEditMode: isEditMode && !!onFilesDrop,
});
const maxDepth = 3; // Limit nesting depth
const canNest = depth < maxDepth;
const isSelected = selectedContainerId === container.id;
@ -117,7 +126,7 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
widget={widget}
isEditMode={isEditMode}
pageId={pageId}
isSelected={selectedWidgetId === widget.id}
isSelected={selectedWidgetIds ? selectedWidgetIds.has(widget.id) : selectedWidgetId === widget.id}
onSelect={() => onSelectWidget?.(widget.id, pageId)}
canMoveUp={index > 0}
canMoveDown={index < container.widgets.length - 1}
@ -130,6 +139,8 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
onSelectWidget={onSelectWidget}
editingWidgetId={editingWidgetId}
contextVariables={contextVariables}
selectedContainerId={selectedContainerId}
onSelectContainer={onSelect}
/>
))}
@ -182,6 +193,7 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
canMoveContainerUp={canMoveContainerUp}
canMoveContainerDown={canMoveContainerDown}
selectedWidgetId={selectedWidgetId}
selectedWidgetIds={selectedWidgetIds}
onSelectWidget={onSelectWidget}
depth={depth + 1}
isCompactMode={isCompactMode}
@ -221,14 +233,32 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
</>
);
// Handle Enabled State
const isContainerEnabled = container.settings?.enabled !== false; // Default to true
if (!isContainerEnabled && !isEditMode) {
return null;
}
return (
<div className="space-y-0">
<div className={cn(
"space-y-0",
container.settings?.customClassName,
!isContainerEnabled && "opacity-50 grayscale transition-all hover:grayscale-0"
)}>
{/* Edit Mode Controls */}
{isEditMode && (
<div className={cn(
"text-white px-2 sm:px-3 py-1 rounded-t-lg text-xs overflow-hidden",
"text-white px-2 sm:px-3 py-1 rounded-t-lg text-xs overflow-hidden cursor-pointer",
isSelected ? "bg-blue-500" : "bg-slate-500"
)}>
)}
onClick={(e) => {
e.stopPropagation();
if (isEditMode) {
onSelect?.(container.id, pageId);
}
}}
>
{/* Responsive layout: title and buttons wrap on small screens */}
<div className="flex items-center justify-between gap-x-2 gap-y-1 min-w-0 flex-wrap">
<div className="flex items-center gap-1 min-w-0 flex-grow">
@ -371,13 +401,15 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
{/* Container Content */}
<div
{...handlers}
className={cn(
"relative transition-all duration-200 min-w-0 overflow-hidden",
isEditMode ? "rounded-b-lg" : "rounded-lg",
isEditMode && "hover:border-blue-300 cursor-pointer",
isSelected && isEditMode && "border-blue-500 bg-blue-50/20 dark:bg-blue-900/20",
!isSelected && isEditMode && "border-slate-300/50 dark:border-white/20",
!isEditMode && "border-transparent"
!isEditMode && "border-transparent",
isDragging && "ring-4 ring-blue-400 ring-opacity-50 bg-blue-50/50 dark:bg-blue-900/50"
)}
onClick={(e) => {
e.stopPropagation();
@ -436,6 +468,14 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
</div>
)}
</div>
{/* Drop Overlay */}
{isDragging && (
<div className="absolute inset-0 z-50 pointer-events-none flex items-center justify-center rounded-lg border-2 border-dashed border-blue-500 bg-blue-100/20 backdrop-blur-[1px]">
<div className="bg-background/90 px-4 py-2 rounded-full shadow-lg text-blue-600 font-medium animate-bounce">
Drop to add Image
</div>
</div>
)}
{/* Container Settings Dialog */}
{showContainerSettings && (
@ -474,6 +514,8 @@ interface WidgetItemProps {
onSelectWidget?: (widgetId: string, pageId?: string) => void;
editingWidgetId?: string | null;
contextVariables?: Record<string, any>;
selectedContainerId?: string | null;
onSelectContainer?: (containerId: string, pageId?: string) => void;
}
const WidgetItem: React.FC<WidgetItemProps> = ({
@ -493,12 +535,22 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
onSelectWidget,
editingWidgetId,
contextVariables,
selectedContainerId,
onSelectContainer,
}) => {
const widgetDefinition = widgetRegistry.get(widget.widgetId);
const { updateWidgetProps, renameWidget } = useLayout();
// Internal state removed in favor of controlled state
// const [showSettingsModal, setShowSettingsModal] = useState(false);
const handlePropsChange = React.useCallback(async (newProps: Record<string, any>) => {
try {
await updateWidgetProps(pageId, widget.id, newProps);
} catch (error) {
console.error('Failed to update widget props:', error);
}
}, [pageId, widget.id, updateWidgetProps]);
// pageId is now passed as a prop from the parent component
if (!widgetDefinition) {
@ -607,7 +659,7 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
{/* Move Controls - Cross Pattern (Only show on hover or selection) */}
<div className={cn(
"absolute top-8 left-2 z-10 transition-opacity duration-200",
"absolute bottom-4 right-2 z-10 transition-opacity duration-200",
isSelected ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}>
<WidgetMovementControls
@ -623,7 +675,7 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
{/* Widget Content - With selection wrapper */}
<div
className={cn(
"w-full bg-white dark:bg-slate-800 overflow-hidden rounded-lg transition-all duration-200",
"w-full dark:bg-slate-800/50 overflow-hidden rounded-lg transition-all duration-200",
widget.props?.customClassName || `widget-${widget.id.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
`widget-type-${widget.widgetId}`,
// Selection Visuals & Margins
@ -647,19 +699,14 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
widgetInstanceId={widget.id}
widgetDefId={widget.widgetId}
isEditMode={isEditMode}
onPropsChange={async (newProps: Record<string, any>) => {
try {
await updateWidgetProps(pageId, widget.id, newProps);
} catch (error) {
console.error('Failed to update widget props:', error);
}
}}
onPropsChange={handlePropsChange}
selectedWidgetId={selectedWidgetId}
onSelectWidget={onSelectWidget}
editingWidgetId={editingWidgetId}
onEditWidget={onEditWidget}
contextVariables={contextVariables}
/>
selectedContainerId={selectedContainerId}
onSelectContainer={onSelect} />
</div>
{/* Generic Settings Modal */}

View File

@ -16,6 +16,7 @@ import {
ReplaceLayoutCommand
} from '@/modules/layout/commands';
interface LayoutContextType {
// Generic page management
loadPageLayout: (pageId: string, defaultName?: string) => Promise<void>;
@ -23,7 +24,7 @@ interface LayoutContextType {
clearPageLayout: (pageId: string) => Promise<void>;
// Generic page actions
addWidgetToPage: (pageId: string, containerId: string, widgetId: string, targetColumn?: number) => Promise<WidgetInstance>;
addWidgetToPage: (pageId: string, containerId: string, widgetId: string, targetColumn?: number, initialProps?: Record<string, any>) => Promise<WidgetInstance>;
removeWidgetFromPage: (pageId: string, widgetInstanceId: string) => Promise<void>;
moveWidgetInPage: (pageId: string, widgetInstanceId: string, direction: 'up' | 'down' | 'left' | 'right') => Promise<void>;
updatePageContainerColumns: (pageId: string, containerId: string, columns: number) => Promise<void>;
@ -134,11 +135,14 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
}, []);
// Widget Actions
const addWidgetToPage = useCallback(async (pageId: string, containerId: string, widgetId: string, targetColumn?: number) => {
const addWidgetToPage = useCallback(async (pageId: string, containerId: string, widgetId: string, targetColumn?: number, initialProps?: Record<string, any>) => {
const layout = loadedPages.get(pageId);
if (!layout) throw new Error("Layout not loaded");
const widgetInstance = UnifiedLayoutManager.createWidgetInstance(widgetId);
if (initialProps) {
widgetInstance.props = { ...widgetInstance.props, ...initialProps };
}
// Use Command for Undo/Redo
const command = new AddWidgetCommand(pageId, containerId, widgetInstance, -1); // Index handling might need improvement if strict positioning required

View File

@ -22,6 +22,8 @@ export interface LayoutContainer {
collapsed?: boolean;
title?: string;
showTitle?: boolean;
customClassName?: string;
enabled?: boolean;
};
}

View File

@ -0,0 +1,109 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { WidgetInstance, LayoutContainer } from '@/modules/layout/LayoutManager';
export interface ClipboardData {
widgets: WidgetInstance[];
containers: LayoutContainer[];
}
interface SelectionContextType {
selectedWidgetIds: Set<string>;
selectedContainerId: string | null;
selectWidget: (id: string, multi?: boolean) => void;
selectContainer: (id: string | null) => void;
clearSelection: () => void;
toggleWidgetSelection: (id: string) => void;
clipboard: ClipboardData | null;
hasClipboard: boolean;
copyToClipboard: (data: ClipboardData) => void;
clearClipboard: () => void;
}
const SelectionContext = createContext<SelectionContextType | undefined>(undefined);
export const SelectionProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [selectedWidgetIds, setSelectedWidgetIds] = useState<Set<string>>(new Set());
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(null);
const [clipboard, setClipboard] = useState<ClipboardData | null>(null);
const selectWidget = useCallback((id: string, multi: boolean = false) => {
console.log('SelectionContext: selectWidget', { id, multi });
setSelectedWidgetIds(prev => {
if (multi) {
const newSet = new Set(prev);
newSet.add(id);
return newSet;
} else {
return new Set([id]);
}
});
// Selecting a widget usually implicitly interacts with container selection logic in specific ways vs just clearing it
// For now, let's clear container selection when selecting a widget to avoid confusion, or keep it independent?
// User didn't specify, but usually they are mutually exclusive in property panels.
setSelectedContainerId(null);
}, []);
const toggleWidgetSelection = useCallback((id: string) => {
console.log('SelectionContext: toggleWidgetSelection', { id });
setSelectedWidgetIds(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
setSelectedContainerId(null);
}, []);
const selectContainer = useCallback((id: string | null) => {
console.log('SelectionContext: selectContainer', { id });
setSelectedContainerId(id);
if (id) {
setSelectedWidgetIds(new Set()); // Clear widget selection when selecting container
}
}, []);
const clearSelection = useCallback(() => {
console.log('SelectionContext: clearSelection');
setSelectedWidgetIds(new Set());
setSelectedContainerId(null);
}, []);
const copyToClipboard = useCallback((data: ClipboardData) => {
console.log('SelectionContext: copyToClipboard', { widgets: data.widgets.length, containers: data.containers.length });
setClipboard(JSON.parse(JSON.stringify(data))); // Deep clone
}, []);
const clearClipboard = useCallback(() => {
setClipboard(null);
}, []);
const hasClipboard = clipboard !== null && (clipboard.widgets.length > 0 || clipboard.containers.length > 0);
return (
<SelectionContext.Provider value={{
selectedWidgetIds,
selectedContainerId,
selectWidget,
selectContainer,
clearSelection,
toggleWidgetSelection,
clipboard,
hasClipboard,
copyToClipboard,
clearClipboard
}}>
{children}
</SelectionContext.Provider>
);
};
export const useSelection = () => {
const context = useContext(SelectionContext);
if (!context) {
throw new Error('useSelection must be used within a SelectionProvider');
}
return context;
};

View File

@ -70,12 +70,12 @@ export const WidgetPalette: React.FC<WidgetPaletteProps> = ({
const modalContent = (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-[99999]"
className="fixed inset-0 dark:bg-black/50 flex items-center justify-center z-[99999]"
onClick={onClose}
style={{ zIndex: 99999 }}
>
<Card
className="glass-card w-96 max-w-[90vw] max-h-[80vh] overflow-hidden shadow-2xl"
className="glass-card dark:bg-slate-800/80 bg-white w-96 max-w-[90vw] max-h-[80vh] overflow-hidden shadow-2xl"
onClick={(e) => e.stopPropagation()}
style={{ zIndex: 100000 }}
>
@ -91,7 +91,7 @@ export const WidgetPalette: React.FC<WidgetPaletteProps> = ({
</Button>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className=" space-y-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-slate-500" />

View File

@ -102,3 +102,25 @@ export const updateLayout = async (layoutId: string, layoutData: any, client?: S
return await res.json();
};
export const deleteLayout = async (layoutId: string, client?: SupabaseClient) => {
const supabase = client || defaultSupabase;
const { data: sessionData } = await supabase.auth.getSession();
const token = sessionData.session?.access_token;
const headers: HeadersInit = {
'Content-Type': 'application/json'
};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/api/layouts/${layoutId}`, {
method: 'DELETE',
headers
});
if (!res.ok) {
throw new Error(`Failed to delete layout: ${res.statusText}`);
}
return true;
};

View File

@ -755,3 +755,63 @@ export class ReplaceLayoutCommand implements Command {
}
}
}
// --- Paste Widgets Command ---
export class PasteWidgetsCommand implements Command {
id: string;
type = 'PASTE_WIDGETS';
timestamp: number;
private pageId: string;
private containerId: string;
private widgets: WidgetInstance[];
private pastedIds: string[] = [];
constructor(pageId: string, containerId: string, widgets: WidgetInstance[]) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.containerId = containerId;
// Deep clone and assign new IDs
this.widgets = widgets.map(w => {
const suffix = crypto.randomUUID().slice(0, 6);
const newId = `${w.id.replace(/-copy-[a-f0-9]+$/, '')}-copy-${suffix}`;
return { ...JSON.parse(JSON.stringify(w)), id: newId };
});
this.pastedIds = this.widgets.map(w => w.id);
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const container = findContainer(newLayout.containers, this.containerId);
if (!container) throw new Error(`Container not found: ${this.containerId}`);
for (const widget of this.widgets) {
container.widgets.push(JSON.parse(JSON.stringify(widget)));
}
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
async undo(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
for (const pastedId of this.pastedIds) {
const location = findWidgetLocation(newLayout.containers, pastedId);
if (location) {
location.container.widgets.splice(location.index, 1);
}
}
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}

View File

@ -29,7 +29,7 @@ const NewPage = () => {
const navigate = useNavigate();
const { user } = useAuth();
const { orgSlug } = useParams<{ orgSlug?: string }>();
const [title, setTitle] = useState("");
const [slug, setSlug] = useState("");
const [type, setType] = useState<string>("page");
@ -113,12 +113,12 @@ const NewPage = () => {
if (error) throw error;
toast.success(translate('Page created successfully!'));
// Navigate to the new page
const pageUrl = orgSlug
const pageUrl = orgSlug
? `/org/${orgSlug}/user/${user.id}/pages/${newPage.slug}`
: `/user/${user.id}/pages/${newPage.slug}`;
navigate(pageUrl);
} catch (error) {
console.error('Error creating page:', error);
@ -129,7 +129,7 @@ const NewPage = () => {
};
const handleCancel = () => {
const backUrl = orgSlug
const backUrl = orgSlug
? `/org/${orgSlug}/user/${user?.id}`
: `/user/${user?.id}`;
navigate(backUrl);
@ -242,7 +242,7 @@ const NewPage = () => {
{/* Visibility Settings */}
<div className="space-y-4 pt-4 border-t">
<h4 className="font-medium text-sm"><T>Visibility Settings</T></h4>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="public">

View File

@ -15,6 +15,7 @@ import {
DropdownMenuGroup
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { updatePage, updatePageMeta } from "./client-pages";
const CategoryManager = React.lazy(() => import("@/components/widgets/CategoryManager").then(module => ({ default: module.CategoryManager })));
@ -71,7 +72,6 @@ export const PageActions = ({
onPageUpdate({ ...page, meta: newMeta });
// Persist to database
try {
const { updatePageMeta } = await import('@/lib/db');
await updatePageMeta(page.id, newMeta);
// Trigger parent refresh to get updated category_paths
if (onMetaUpdated) {
@ -89,7 +89,6 @@ export const PageActions = ({
setLoading(true);
try {
const { updatePage } = await import('@/lib/db');
await updatePage(page.id, { visible: !page.visible });
onPageUpdate({ ...page, visible: !page.visible });
@ -109,7 +108,6 @@ export const PageActions = ({
setLoading(true);
try {
const { updatePage } = await import('@/lib/db');
await updatePage(page.id, { is_public: !page.is_public });
onPageUpdate({ ...page, is_public: !page.is_public });
toast.success(translate(page.is_public ? 'Page made private' : 'Page made public'));

View File

@ -70,7 +70,7 @@ const PageCard: React.FC<PageCardProps> = ({
if (variant === 'feed') {
return (
<div
className="group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full border rounded-lg mb-4"
className="group relative overflow-hidden transition-all duration-300 cursor-pointer w-full border rounded-lg mb-4"
onClick={handleCardClick}
>
{showHeader && (

View File

@ -1,4 +1,4 @@
import { useState, useEffect, Suspense, lazy } from "react";
import { useState, useEffect, useMemo, Suspense, lazy } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
@ -7,6 +7,7 @@ import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { T, translate } from "@/i18n";
import { fetchUserPage } from "@/lib/db";
import { mergePageVariables } from "@/lib/page-variables";
import { GenericCanvas } from "@/modules/layout/GenericCanvas";
import { useLayout, LayoutProvider } from "@/modules/layout/LayoutContext";
@ -142,11 +143,21 @@ const UserPageContent = ({ userId: propUserId, slug: propSlug, embedded = false,
}
};
// Reactive Heading Extraction
// This ensures we extract headings whenever the page loads OR when specific layouts are loaded into context
const { loadedPages } = useLayout();
// Merge variables using client-side utility to match server logic
const contextVariables = useMemo(() => {
if (!page) return {};
// We attempt to read userVariables from page or extra props if available.
// Ideally we need userVariables from the server response.
const userVars = (page as any).userVariables || {};
const merged = mergePageVariables(page, userVars);
console.log('contextVariables', merged);
return merged;
}, [page]);
useEffect(() => {
if (!page) return;
@ -243,6 +254,7 @@ const UserPageContent = ({ userId: propUserId, slug: propSlug, embedded = false,
childPages={childPages}
onExitEditMode={() => setIsEditMode(false)}
onPageUpdate={handlePageUpdate}
contextVariables={contextVariables}
/>
</Suspense>
);
@ -348,7 +360,7 @@ const UserPageContent = ({ userId: propUserId, slug: propSlug, embedded = false,
<div>
{page.content && typeof page.content === 'string' ? (
<div className="prose prose-lg dark:prose-invert max-w-none pb-12">
<MarkdownRenderer content={page.content} />
<MarkdownRenderer content={page.content} variables={contextVariables} />
</div>
) : (
<GenericCanvas
@ -359,12 +371,7 @@ const UserPageContent = ({ userId: propUserId, slug: propSlug, embedded = false,
initialLayout={page.content}
selectedWidgetId={null}
onSelectWidget={() => { }}
contextVariables={(() => {
const typeValues = page.meta?.typeValues || {};
// Flatten all type values into a single object
// Later types override earlier ones if keys collide, but order isn't guaranteed in object
return Object.values(typeValues).reduce((acc: any, val: any) => ({ ...acc, ...val }), {});
})()}
contextVariables={contextVariables}
/>
)}
</div>
@ -397,7 +404,8 @@ const UserPageContent = ({ userId: propUserId, slug: propSlug, embedded = false,
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div >);
</div>
);
};
const UserPage = (props: UserPageProps) => (

View File

@ -71,6 +71,7 @@ interface UserPageDetailsProps {
onWidgetRename: (id: string | null) => void;
templates?: Layout[];
onLoadTemplate?: (template: Layout) => void;
showActions?: boolean;
}
export const UserPageDetails: React.FC<UserPageDetailsProps> = ({
@ -85,6 +86,7 @@ export const UserPageDetails: React.FC<UserPageDetailsProps> = ({
onWidgetRename,
templates,
onLoadTemplate,
showActions = true,
}) => {
const navigate = useNavigate();
@ -227,8 +229,8 @@ export const UserPageDetails: React.FC<UserPageDetailsProps> = ({
};
return (
<div className="mb-8">
<div className="flex items-start gap-4 mb-4">
<div className="">
<div className="flex items-start gap-4">
<div className="flex-1">
{/* Parent Page Eyebrow */}
{page.parent_page && (
@ -333,7 +335,7 @@ export const UserPageDetails: React.FC<UserPageDetailsProps> = ({
</div>
</div>
<Separator className="my-6" />
<Separator className="my-2" />
{/* Tags and Type */}
<div className="space-y-3 mb-8">
@ -351,7 +353,7 @@ export const UserPageDetails: React.FC<UserPageDetailsProps> = ({
)}
{/* PageActions - Only visible in View Mode (Edit Mode uses PageRibbonBar) */}
{!isEditMode && (
{!isEditMode && showActions && (
<React.Suspense fallback={<div className="h-9 w-24 bg-muted animate-pulse rounded" />}>
<PageActions
page={page}
@ -369,9 +371,6 @@ export const UserPageDetails: React.FC<UserPageDetailsProps> = ({
</React.Suspense>
)}
</div>
<Separator className="my-6" />
{/* Editable Tags */}
{editingTags && isOwner && isEditMode ? (
<div className="flex items-start gap-2">

View File

@ -1,5 +1,5 @@
import { useState, useEffect, lazy, Suspense, useRef } from "react";
import { useState, useEffect, lazy, Suspense, useRef, useCallback } from "react";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@ -11,6 +11,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
// Editor components lazy loaded
const WidgetPropertyPanel = lazy(() => import("@/components/widgets/WidgetPropertyPanel").then(module => ({ default: module.WidgetPropertyPanel })));
const ContainerPropertyPanel = lazy(() => import("@/components/containers/ContainerPropertyPanel").then(module => ({ default: module.ContainerPropertyPanel })));
const HierarchyTree = lazy(() => import("@/components/sidebar/HierarchyTree").then(module => ({ default: module.HierarchyTree })));
const UserPageTypeFields = lazy(() => import("./UserPageTypeFields").then(module => ({ default: module.UserPageTypeFields })));
@ -23,12 +24,16 @@ import { MobileTOC } from "@/components/sidebar/MobileTOC";
import { MarkdownHeading } from "@/lib/toc";
import { useLayout } from "@/modules/layout/LayoutContext";
import { useLayouts } from "@/modules/layout/useLayouts";
import { SelectionProvider, useSelection } from "@/modules/layout/SelectionContext";
import { PasteWidgetsCommand, AddContainerCommand } from "@/modules/layout/commands";
import { WidgetInstance, LayoutContainer as LayoutContainerType } from "@/modules/layout/LayoutManager";
import { Database } from "@/integrations/supabase/types";
import { UserPageDetails } from "./UserPageDetails";
import PageRibbonBar from "./ribbons/PageRibbonBar";
import { ConfirmationDialog } from "@/components/ConfirmationDialog";
import { deletePage, updatePage } from "../client-pages";
import { createLayout, updateLayout } from "@/modules/layout/client-layouts";
import { createLayout, updateLayout, deleteLayout } from "@/modules/layout/client-layouts";
type Layout = Database['public']['Tables']['layouts']['Row'];
@ -79,9 +84,10 @@ interface UserPageEditProps {
childPages: { id: string; title: string; slug: string }[];
onExitEditMode: () => void;
onPageUpdate: (updatedPage: Page) => void;
contextVariables?: Record<string, any>;
}
const UserPageEdit = ({
const UserPageEditInner = ({
page,
userProfile,
isOwner,
@ -91,13 +97,39 @@ const UserPageEdit = ({
childPages,
onExitEditMode,
onPageUpdate,
contextVariables,
}: UserPageEditProps) => {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [selectedWidgetId, setSelectedWidgetId] = useState<string | null>(null);
const { selectedWidgetIds, selectedContainerId, selectWidget, selectContainer, clearSelection, toggleWidgetSelection, clipboard, copyToClipboard, hasClipboard } = useSelection();
// Derived single-widget ID for backward compatibility (first selected widget, if any)
const selectedWidgetId = selectedWidgetIds.size > 0 ? Array.from(selectedWidgetIds)[0] : null;
// Track modifier key state for multi-select
const modifierKeyRef = useRef(false);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { if (e.ctrlKey || e.metaKey) modifierKeyRef.current = true; };
const onKeyUp = (e: KeyboardEvent) => { if (!e.ctrlKey && !e.metaKey) modifierKeyRef.current = false; };
const onBlur = () => { modifierKeyRef.current = false; };
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
return () => { window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp); window.removeEventListener('blur', onBlur); };
}, []);
// Wrapper setters for backward compatibility with existing code
const setSelectedWidgetId = useCallback((id: string | null) => {
if (!id) { clearSelection(); return; }
if (modifierKeyRef.current) { toggleWidgetSelection(id); }
else if (selectedWidgetIds.size === 1 && selectedWidgetIds.has(id)) { clearSelection(); }
else { selectWidget(id); }
}, [selectWidget, toggleWidgetSelection, clearSelection, selectedWidgetIds]);
const setSelectedContainerId = useCallback((id: string | null) => {
if (id && selectedContainerId === id) { selectContainer(null); }
else { selectContainer(id); }
}, [selectContainer, selectedContainerId]);
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
const [showHierarchy, setShowHierarchy] = useState(false);
const [showEmailPreview, setShowEmailPreview] = useState(false);
const [isPreview, setIsPreview] = useState(false);
const [previewMode, setPreviewMode] = useState<'desktop' | 'mobile'>('desktop');
const [showSendEmailDialog, setShowSendEmailDialog] = useState(false);
const [emailRecipient, setEmailRecipient] = useState('cgoflyn@gmail.com');
@ -105,6 +137,8 @@ const UserPageEdit = ({
const [authToken, setAuthToken] = useState<string | null>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
useEffect(() => {
// Dynamic import to avoid circular deps if any, or just standard import usage
import("@/integrations/supabase/client").then(({ supabase }) => {
@ -114,6 +148,20 @@ const UserPageEdit = ({
});
}, []);
const [confirmationState, setConfirmationState] = useState<{
open: boolean;
title: string;
description: string;
onConfirm: () => void;
confirmLabel?: string;
variant?: "default" | "destructive";
}>({
open: false,
title: "",
description: "",
onConfirm: () => { },
});
const handleSendEmail = async () => {
if (!emailRecipient) {
toast.error(translate("Email is required"));
@ -193,7 +241,8 @@ const UserPageEdit = ({
getLoadedPageLayout,
loadedPages
} = useLayout();
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(null);
const { executeCommand } = useLayout();
// selectedContainerId is now from useSelection (see above)
const [editingWidgetId, setEditingWidgetId] = useState<string | null>(null);
const [newlyAddedWidgetId, setNewlyAddedWidgetId] = useState<string | null>(null);
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(null);
@ -204,6 +253,9 @@ const UserPageEdit = ({
const [settingsWidgetId, setSettingsWidgetId] = useState<string | null>(null);
const [settingsLayoutId, setSettingsLayoutId] = useState<string | null>(null);
// console.log('UserPageEdit render:', { selectedWidgetId, selectedContainerId });
const handleOpenSettings = (id: string, type: 'widget' | 'container', layoutId: string) => {
if (type === 'widget') {
setSettingsWidgetId(id);
@ -300,6 +352,105 @@ const UserPageEdit = ({
}
};
// --- Copy / Paste ---
const handleCopy = useCallback(() => {
if (!page) return;
const pageId = selectedPageId || `page-${page.id}`;
const layout = getLoadedPageLayout(pageId);
if (!layout) return;
// Helper to find widgets by IDs in nested containers
const findWidgets = (containers: LayoutContainerType[], ids: Set<string>): WidgetInstance[] => {
const result: WidgetInstance[] = [];
for (const c of containers) {
for (const w of c.widgets) {
if (ids.has(w.id)) result.push(w);
}
if (c.children) result.push(...findWidgets(c.children, ids));
}
return result;
};
// Helper to find a container by ID
const findContainer = (containers: LayoutContainerType[], id: string): LayoutContainerType | null => {
for (const c of containers) {
if (c.id === id) return c;
if (c.children) {
const found = findContainer(c.children, id);
if (found) return found;
}
}
return null;
};
if (selectedWidgetIds.size > 0) {
// Copy selected widgets
const widgets = findWidgets(layout.containers, selectedWidgetIds);
if (widgets.length === 0) {
toast.error(translate('Nothing selected to copy'));
return;
}
copyToClipboard({ widgets, containers: [] });
toast.success(translate(`Copied ${widgets.length} widget(s)`));
} else if (selectedContainerId) {
// Copy the entire container (with its widgets and children)
const container = findContainer(layout.containers, selectedContainerId);
if (!container) {
toast.error(translate('Container not found'));
return;
}
copyToClipboard({ widgets: [], containers: [container] });
toast.success(translate(`Copied container with ${container.widgets.length} widget(s)`));
} else {
toast.error(translate('Nothing selected to copy'));
}
}, [page, selectedPageId, selectedWidgetIds, selectedContainerId, getLoadedPageLayout, copyToClipboard]);
const handlePaste = useCallback(async () => {
if (!page || !clipboard) return;
const pageId = selectedPageId || `page-${page.id}`;
const layout = getLoadedPageLayout(pageId);
if (!layout) return;
try {
if (clipboard.containers.length > 0) {
// Paste containers — deep clone with new IDs
for (const container of clipboard.containers) {
const suffix = crypto.randomUUID().slice(0, 6);
const cloneContainer = (c: LayoutContainerType): LayoutContainerType => ({
...JSON.parse(JSON.stringify(c)),
id: `${c.id.replace(/-copy-[a-f0-9]+$/, '')}-copy-${suffix}`,
widgets: c.widgets.map(w => ({
...JSON.parse(JSON.stringify(w)),
id: `${w.id.replace(/-copy-[a-f0-9]+$/, '')}-copy-${crypto.randomUUID().slice(0, 6)}`
})),
children: c.children ? c.children.map(cloneContainer) : []
});
const newContainer = cloneContainer(container);
const cmd = new AddContainerCommand(pageId, newContainer);
await executeCommand(cmd);
}
toast.success(translate(`Pasted ${clipboard.containers.length} container(s)`));
} else if (clipboard.widgets.length > 0) {
// Paste widgets into target container
let targetContainerId = selectedContainerId;
if (!targetContainerId && layout.containers.length > 0) {
targetContainerId = layout.containers[0].id;
}
if (!targetContainerId) {
toast.error(translate('No container to paste into'));
return;
}
const cmd = new PasteWidgetsCommand(pageId, targetContainerId, clipboard.widgets);
await executeCommand(cmd);
toast.success(translate(`Pasted ${clipboard.widgets.length} widget(s)`));
}
} catch (e) {
console.error('Paste failed', e);
toast.error(translate('Failed to paste'));
}
}, [page, selectedPageId, clipboard, selectedContainerId, getLoadedPageLayout, executeCommand]);
const handleLoadTemplate = async (template: Layout) => {
if (!page) return;
try {
@ -362,55 +513,67 @@ const UserPageEdit = ({
};
const handleNewLayout = async () => {
if (!page) return;
if (!confirm(translate("Are you sure you want to clear the layout?"))) return;
try {
const pageId = `page-${page.id}`;
// Load empty layout
const emptyLayout = {
id: pageId,
containers: [],
name: 'Empty Layout',
createdAt: Date.now(),
updatedAt: Date.now()
};
const jsonString = JSON.stringify(emptyLayout);
await importPageLayout(pageId, jsonString); // This uses ReplaceLayoutCommand so it's undoable!
setActiveTemplateId(null);
toast.success("Layout cleared");
} catch (e) {
console.error("Failed to clear layout", e);
toast.error("Failed to clear layout");
}
const handleDeleteTemplate = async (template: Layout) => {
setConfirmationState({
open: true,
title: translate("Delete Template"),
description: translate("Are you sure you want to delete the template") + ` "${template.name}"?`,
confirmLabel: translate("Delete"),
variant: "destructive",
onConfirm: async () => {
try {
await deleteLayout(template.id);
toast.success(translate("Template deleted"));
if (activeTemplateId === template.id) {
setActiveTemplateId(null);
}
loadTemplates();
} catch (e) {
console.error("Failed to delete template", e);
toast.error(translate("Failed to delete template"));
}
setConfirmationState(prev => ({ ...prev, open: false }));
}
});
};
// Keyboard Shortcuts for Undo/Redo
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if input/textarea is focused
if (['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName)) {
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
e.preventDefault();
if (e.shiftKey) {
if (canRedo) redo();
} else {
if (canUndo) undo();
const handleNewLayout = async () => {
if (!page) return;
setConfirmationState({
open: true,
title: translate("Clear Layout"),
description: translate("Are you sure you want to clear the layout?"),
confirmLabel: translate("Clear"),
variant: "destructive",
onConfirm: async () => {
try {
const pageId = `page-${page.id}`;
// Load empty layout
const emptyLayout = {
id: pageId,
containers: [],
name: 'Empty Layout',
createdAt: Date.now(),
updatedAt: Date.now()
};
const jsonString = JSON.stringify(emptyLayout);
await importPageLayout(pageId, jsonString); // This uses ReplaceLayoutCommand so it's undoable!
setActiveTemplateId(null);
toast.success("Layout cleared");
} catch (e) {
console.error("Failed to clear layout", e);
toast.error("Failed to clear layout");
}
setConfirmationState(prev => ({ ...prev, open: false }));
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') {
e.preventDefault();
if (canRedo) redo();
}
};
});
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [undo, redo, canUndo, canRedo]);
// Export Layout Handler
const handleExportLayout = async () => {
@ -463,22 +626,33 @@ const UserPageEdit = ({
};
// Delete Page Handler
// Delete Page Handler
const handleDeletePage = async () => {
if (!page) return;
if (!confirm(translate("Are you sure you want to delete this page?"))) return;
try {
await deletePage(page.id);
toast.success(translate("Page deleted"));
setConfirmationState({
open: true,
title: translate("Delete Page"),
description: translate("Are you sure you want to delete this page?"),
confirmLabel: translate("Delete"),
variant: "destructive",
onConfirm: async () => {
try {
await deletePage(page.id);
toast.success(translate("Page deleted"));
// Navigate away
const redirectUrl = orgSlug ? `/org/${orgSlug}/user/${userId}` : `/user/${userId}`;
window.location.href = redirectUrl; // Force reload to clear cache? Or just navigate.
} catch (e) {
console.error("Failed to delete page", e);
toast.error(translate("Failed to delete page"));
}
// Navigate away
const redirectUrl = orgSlug ? `/org/${orgSlug}/user/${userId}` : `/user/${userId}`;
window.location.href = redirectUrl; // Force reload to clear cache? Or just navigate.
} catch (e) {
console.error("Failed to delete page", e);
toast.error(translate("Failed to delete page"));
}
setConfirmationState(prev => ({ ...prev, open: false }));
}
});
};
// Compute active widgets for ribbon state
@ -524,12 +698,40 @@ const UserPageEdit = ({
}
};
const contextVariables = (() => {
const typeValues = page.meta?.typeValues || {};
return Object.values(typeValues).reduce((acc: any, val: any) => ({ ...acc, ...val }), {});
})();
// Keyboard Shortcuts for Undo/Redo
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if input/textarea is focused
if (['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName)) {
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
e.preventDefault();
if (e.shiftKey) {
if (canRedo) redo();
} else {
if (canUndo) undo();
}
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') {
e.preventDefault();
if (canRedo) redo();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [undo, redo, canUndo, canRedo]);
// contextVariables passed from parent
// console.log('page-edit contextVariables', contextVariables);
const handleSave = async () => {
const toastId = toast.loading(translate("Saving..."));
try {
const { upsertLayout } = await import('@/lib/db');
@ -569,12 +771,52 @@ const UserPageEdit = ({
});
await Promise.all(promises);
toast.success(translate("Saved"), { id: toastId });
} catch (error) {
console.error("Failed to save all layouts", error);
throw error;
toast.error(translate("Failed to save page"), { id: toastId });
}
};
// Keyboard Shortcuts for Save/Preview/Copy/Paste
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if input/textarea is focused for most things, but maybe not Save?
// Standard behavior usually allows Ctrl+S even in inputs.
const isInput = ['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName);
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
handleSave();
}
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'v') {
// Ctrl+Shift+V = Toggle preview
if (!isInput) {
e.preventDefault();
setIsPreview(prev => !prev);
}
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
// Ctrl+V = Paste widgets (only when not in input to preserve native paste)
if (!isInput) {
e.preventDefault();
handlePaste();
}
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
// Copy selected widgets (only when not in input to preserve native copy)
if (!isInput && selectedWidgetIds.size > 0) {
e.preventDefault();
handleCopy();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleSave, handleCopy, handlePaste, selectedWidgetIds]);
return (
<>
@ -592,6 +834,7 @@ const UserPageEdit = ({
onMetaUpdated={() => { }}
templates={templates}
onLoadTemplate={handleLoadTemplate}
onDeleteTemplate={handleDeleteTemplate}
onToggleWidget={handleWidgetClick}
onAddContainer={handleAddContainer}
@ -617,13 +860,21 @@ const UserPageEdit = ({
showEmailPreview={showEmailPreview}
onToggleEmailPreview={() => setShowEmailPreview(!showEmailPreview)}
onSendEmail={() => setShowSendEmailDialog(true)}
isPreview={isPreview}
onTogglePreview={() => setIsPreview(!isPreview)}
selectedWidgetId={selectedWidgetId}
selectedWidgetIds={selectedWidgetIds}
selectedContainerId={selectedContainerId}
onCopy={handleCopy}
onPaste={handlePaste}
hasClipboard={hasClipboard}
/>
<div className="flex-1 flex overflow-hidden min-h-0">
<div className="flex-1 flex min-h-0">
{/* Sidebar Left */}
{!showEmailPreview && (headings.length > 0 || childPages.length > 0 || showHierarchy) && (
<Sidebar className={`${isSidebarCollapsed ? 'w-12' : 'w-[300px]'} border-r bg-background/50 h-full hidden lg:flex flex-col shrink-0 transition-all duration-300`}>
<div className={`flex items-center ${isSidebarCollapsed ? 'justify-center' : 'justify-end'} p-2 sticky top-0 bg-background/50 z-10`}>
<Sidebar className={`${isSidebarCollapsed ? 'w-12' : 'w-[300px]'} border-r h-full hidden lg:flex flex-col shrink-0 transition-all duration-300`}>
<div className={`flex items-center ${isSidebarCollapsed ? 'justify-center' : 'justify-end'} p-2 sticky top-0 z-10`}>
<Button
variant="ghost"
size="icon"
@ -667,13 +918,17 @@ const UserPageEdit = ({
selectedWidgetId={selectedWidgetId}
selectedContainerId={selectedContainerId}
onSelectWidget={(id) => {
if (isPreview) return;
setSelectedWidgetId(id);
setSelectedPageId(`page-${page.id}`);
// Ensure editor is open/focused if needed
const widget = currentLayout.containers.flatMap((c: any) => [c, ...c.children]).flatMap((c: any) => c.widgets).find((w: any) => w.id === id);
if (widget) setEditingWidgetId(id);
}}
onSelectContainer={setSelectedContainerId}
onSelectContainer={(id) => {
if (isPreview) return;
setSelectedContainerId(id);
}}
onSettingsClick={handleOpenSettings}
layoutId={currentLayout.id}
/>
@ -701,7 +956,7 @@ const UserPageEdit = ({
page={page}
userProfile={userProfile}
isOwner={isOwner}
isEditMode={true}
isEditMode={!isPreview}
userId={userId || ''}
orgSlug={orgSlug}
onPageUpdate={onPageUpdate}
@ -709,6 +964,7 @@ const UserPageEdit = ({
onWidgetRename={setSelectedWidgetId}
templates={templates}
onLoadTemplate={handleLoadTemplate}
showActions={false}
/>
)}
@ -774,16 +1030,19 @@ const UserPageEdit = ({
<GenericCanvas
pageId={`page-${page.id}`}
pageName={page.title}
isEditMode={true}
showControls={true}
isEditMode={!isPreview}
showControls={!isPreview}
initialLayout={page.content}
selectedWidgetId={selectedWidgetId}
selectedWidgetId={isPreview ? null : selectedWidgetId}
selectedWidgetIds={isPreview ? undefined : selectedWidgetIds}
onSelectWidget={(id, pageId) => {
if (isPreview) return;
setSelectedWidgetId(id);
setSelectedPageId(pageId || `page-${page.id}`);
}}
selectedContainerId={selectedContainerId}
selectedContainerId={isPreview ? null : selectedContainerId}
onSelectContainer={(id, pageId) => {
if (isPreview) return;
setSelectedContainerId(id);
if (id) {
setSelectedPageId(pageId || `page-${page.id}`);
@ -791,75 +1050,86 @@ const UserPageEdit = ({
// If deselecting, we might want to reset pageId to main page or keep last context?
// Keeping last context is fine.
}}
editingWidgetId={editingWidgetId}
editingWidgetId={isPreview ? null : editingWidgetId}
onEditWidget={handleEditWidget}
newlyAddedWidgetId={newlyAddedWidgetId}
newlyAddedWidgetId={isPreview ? null : newlyAddedWidgetId}
contextVariables={contextVariables}
onSave={handleSave}
/>
)}
</div>
{/* Footer */}
<div className="mt-8 pt-8 border-t text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<div>
<T>Last updated:</T> {new Date(page.updated_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
{/* Footer in Preview Mode */}
{isPreview && !showEmailPreview && (
<div className="mt-8 pt-8 border-t text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<div>
<T>Last updated:</T> {new Date(page.updated_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</div>
{page.parent && (
<Link
to={`/pages/${page.parent}`}
className="text-primary hover:underline"
>
<T>View parent page</T>
</Link>
)}
</div>
</div>
{page.parent && (
<Link
to={`/pages/${page.parent}`}
className="text-primary hover:underline"
>
<T>View parent page</T>
</Link>
)}
</div>
)}
</div>
</div>
</div>
</ResizablePanel>
{/* Right Sidebar - Property Panel OR Type Fields */}
{(selectedWidgetId || showTypeFields) && (
<>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={25} minSize={20} maxSize={50} order={2} id="user-page-props">
<div className="h-full flex flex-col shrink-0 transition-all duration-300 overflow-hidden bg-background">
{selectedWidgetId ? (
<Suspense fallback={<div className="h-full flex items-center justify-center text-muted-foreground">Loading settings...</div>}>
<WidgetPropertyPanel
pageId={selectedPageId || `page-${page.id}`}
selectedWidgetId={selectedWidgetId}
onWidgetRenamed={setSelectedWidgetId}
contextVariables={contextVariables}
/>
</Suspense>
) : showTypeFields ? (
<div className="h-full overflow-y-auto p-4">
<Suspense fallback={<div className="h-full flex items-center justify-center text-muted-foreground">Loading types...</div>}>
<UserPageTypeFields
pageId={page.id}
pageMeta={page.meta}
assignedTypes={assignedTypes}
isEditMode={true}
onMetaUpdate={(newMeta) => onPageUpdate({ ...page, meta: newMeta })}
{
(selectedWidgetId || selectedContainerId || showTypeFields) && (
<>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={25} minSize={20} maxSize={50} order={2} id="user-page-props">
<div className="h-full flex flex-col shrink-0 transition-all duration-300 overflow-hidden bg-background">
{selectedWidgetId ? (
<Suspense fallback={<div className="h-full flex items-center justify-center text-muted-foreground">Loading settings...</div>}>
<WidgetPropertyPanel
pageId={selectedPageId || `page-${page.id}`}
selectedWidgetId={selectedWidgetId}
onWidgetRenamed={setSelectedWidgetId}
contextVariables={contextVariables}
/>
</Suspense>
</div>
) : null}
</div>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</div>
) : selectedContainerId ? (
<Suspense fallback={<div className="h-full flex items-center justify-center text-muted-foreground">Loading settings...</div>}>
<ContainerPropertyPanel
pageId={selectedPageId || `page-${page.id}`}
selectedContainerId={selectedContainerId}
/>
</Suspense>
) : showTypeFields ? (
<div className="h-full overflow-y-auto p-4">
<Suspense fallback={<div className="h-full flex items-center justify-center text-muted-foreground">Loading types...</div>}>
<UserPageTypeFields
pageId={page.id}
pageMeta={page.meta}
assignedTypes={assignedTypes}
isEditMode={true}
onMetaUpdate={(newMeta) => onPageUpdate({ ...page, meta: newMeta })}
/>
</Suspense>
</div>
) : null}
</div>
</ResizablePanel>
</>
)
}
</ResizablePanelGroup >
</div >
<Suspense fallback={null}>
<SaveTemplateDialog
@ -918,8 +1188,24 @@ const UserPageEdit = ({
</div>
</DialogContent>
</Dialog>
<ConfirmationDialog
open={confirmationState.open}
onOpenChange={(open) => setConfirmationState(prev => ({ ...prev, open }))}
title={confirmationState.title}
description={confirmationState.description}
onConfirm={confirmationState.onConfirm}
confirmLabel={confirmationState.confirmLabel}
variant={confirmationState.variant}
/>
</>
);
};
const UserPageEdit = (props: UserPageEditProps) => (
<SelectionProvider>
<UserPageEditInner {...props} />
</SelectionProvider>
);
export default UserPageEdit;

View File

@ -20,6 +20,8 @@ import {
MousePointer2,
Save,
Undo2,
Copy,
ClipboardPaste,
Redo2,
FileJson,
Download,
@ -28,7 +30,9 @@ import {
ListTree,
Database,
Mail,
Send
Send,
MoreHorizontal,
Plus
} from "lucide-react";
import {
AlertDialog,
@ -40,6 +44,12 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { T, translate } from "@/i18n";
import { Database as DatabaseType } from '@/integrations/supabase/types';
@ -53,6 +63,7 @@ import * as PageCommands from "@/modules/layout/commands";
import { ActionProvider } from "@/actions/ActionProvider";
import { useActions } from "@/actions/useActions";
import { UNDO_ACTION_ID, REDO_ACTION_ID, FINISH_ACTION_ID, CANCEL_ACTION_ID } from "@/actions/default-actions";
import { updatePage } from '../../client-pages';
const { UpdatePageParentCommand, UpdatePageMetaCommand } = PageCommands;
type Layout = DatabaseType['public']['Tables']['layouts']['Row'];
@ -116,6 +127,15 @@ interface PageRibbonBarProps {
showEmailPreview?: boolean;
onToggleEmailPreview?: () => void;
onSendEmail?: () => void;
isPreview?: boolean;
onTogglePreview?: () => void;
onDeleteTemplate?: (template: Layout) => void;
selectedWidgetId?: string | null;
selectedWidgetIds?: Set<string>;
selectedContainerId?: string | null;
onCopy?: () => void;
onPaste?: () => void;
hasClipboard?: boolean;
}
// Ribbon UI Components
@ -125,7 +145,7 @@ const RibbonTab = ({ active, onClick, children }: { active: boolean, onClick: ()
className={cn(
"px-4 py-1 text-sm font-medium transition-all duration-200 border-t-2 border-transparent select-none",
active
? "bg-background text-primary border-t-blue-500 shadow-[0_4px_12px_-4px_rgba(0,0,0,0.1)]"
? "dark:bg-slate-800/50 text-primary border-t-blue-500 shadow-[0_4px_12px_-4px_rgba(0,0,0,0.1)]"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
)}
>
@ -144,38 +164,7 @@ const RibbonGroup = ({ label, children }: { label: string, children: React.React
</div>
);
const RibbonItemLarge = ({
icon: Icon,
label,
onClick,
active,
iconColor = "text-foreground",
disabled = false
}: {
icon: any,
label: string,
onClick?: () => void,
active?: boolean,
iconColor?: string,
disabled?: boolean
}) => (
<button
onClick={onClick}
disabled={disabled}
className={cn(
"flex flex-col items-center justify-center h-16 min-w-[4rem] px-2 gap-1 rounded-md transition-all duration-200 group/btn",
!disabled && "hover:bg-accent/80 hover:shadow-sm hover:-translate-y-0.5 active:translate-y-0 active:shadow-inner",
active && "bg-blue-100/50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 ring-1 ring-blue-200 dark:ring-blue-800",
disabled && "opacity-40 cursor-not-allowed grayscale"
)}
title={label}
>
<div className={cn("transition-transform duration-200 group-hover/btn:scale-110 drop-shadow-sm", iconColor)}>
<Icon className="h-7 w-7" strokeWidth={1.5} />
</div>
<span className="text-[10px] font-medium leading-tight text-center whitespace-nowrap"><T>{label}</T></span>
</button>
);
const RibbonItemSmall = ({
icon: Icon,
@ -184,6 +173,7 @@ const RibbonItemSmall = ({
active,
iconColor = "text-foreground",
disabled = false,
onDelete,
...props
}: {
icon: any,
@ -192,24 +182,123 @@ const RibbonItemSmall = ({
active?: boolean,
iconColor?: string,
disabled?: boolean,
onDelete?: () => void,
[key: string]: any
}) => (
<button
onClick={onClick}
disabled={disabled}
{...props}
className={cn(
"flex items-center gap-2 px-2 py-0.5 h-7 w-full text-left rounded-sm transition-colors text-xs font-medium group/btn",
!disabled && "hover:bg-accent/60",
active && "bg-blue-100/40 dark:bg-blue-900/10 text-blue-700 dark:text-blue-300",
disabled && "opacity-50 cursor-not-allowed"
<div className="relative group/btn w-full">
<button
onClick={onClick}
disabled={disabled}
{...props}
className={cn(
"flex items-center gap-2 px-2 py-0.5 h-7 w-full text-left rounded-sm transition-colors text-xs font-medium",
!disabled && "hover:bg-accent/60",
active && "bg-blue-100/40 dark:bg-blue-900/10 text-blue-700 dark:text-blue-300",
disabled && "opacity-50 cursor-not-allowed"
)}
>
<Icon className={cn("h-4 w-4 transition-colors", iconColor)} />
<span className="truncate pr-4"><T>{label}</T></span>
</button>
{onDelete && !disabled && (
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 rounded-sm opacity-0 group-hover/btn:opacity-100 hover:bg-red-100 text-muted-foreground hover:text-red-500 transition-all"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
>
<Icon className={cn("h-4 w-4 transition-colors", iconColor)} />
<span className="truncate"><T>{label}</T></span>
</button>
</div>
);
interface CompactAction {
id?: string;
icon: any;
label: string;
onClick?: () => void;
active?: boolean;
disabled?: boolean;
iconColor?: string;
onDelete?: () => void;
}
const CompactFlowGroup = ({
actions,
maxColumns = 3
}: {
actions: CompactAction[],
maxColumns?: number
}) => {
// Determine how many items to show based on maxColumns * 3 rows
// If exact fit or less, show all. If more, show (maxColumns*3 - 1) and a More button
const maxItems = maxColumns * 3;
const shouldOverflow = actions.length > maxItems;
const visibleCount = shouldOverflow ? maxItems - 1 : actions.length;
const visibleActions = actions.slice(0, visibleCount);
const overflowActions = actions.slice(visibleCount);
return (
<div className={cn("grid grid-rows-3 grid-flow-col gap-1")}>
{visibleActions.map((action, i) => (
<RibbonItemSmall
key={action.id || i}
icon={action.icon}
label={action.label}
onClick={action.onClick}
active={action.active}
disabled={action.disabled}
iconColor={action.iconColor}
onDelete={action.onDelete}
/>
))}
{shouldOverflow && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
"flex items-center gap-2 px-2 py-0.5 h-7 w-full text-left rounded-sm transition-colors text-xs font-medium group/btn hover:bg-accent/60"
)}
>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
<span className="truncate"><T>More</T></span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{overflowActions.map((action, i) => (
<DropdownMenuItem
key={action.id || i}
onClick={action.onClick}
disabled={action.disabled}
>
<action.icon className={cn("mr-2 h-4 w-4", action.iconColor)} />
<span><T>{action.label}</T></span>
{action.onDelete && (
<button
onClick={(e) => {
e.stopPropagation();
action.onDelete?.();
}}
className="ml-auto p-1 hover:text-red-500"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
};
export const PageRibbonBar = ({
page,
isOwner,
@ -241,7 +330,16 @@ export const PageRibbonBar = ({
onSave,
showEmailPreview,
onToggleEmailPreview,
onSendEmail
onSendEmail,
isPreview,
onTogglePreview,
selectedWidgetId,
selectedWidgetIds,
selectedContainerId,
onDeleteTemplate,
onCopy,
onPaste,
hasClipboard = false
}: PageRibbonBarProps) => {
const { executeCommand, loadPageLayout, clearHistory } = useLayout();
const navigate = useNavigate();
@ -273,6 +371,12 @@ export const PageRibbonBar = ({
// Logic duplicated from PageActions
const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
useEffect(() => {
if (activeTab === 'advanced') {
console.log('PageRibbonBar Debug:', { selectedContainerId, selectedWidgetId, selectedWidgetCount: selectedWidgetIds?.size ?? 0 });
}
}, [activeTab, selectedContainerId, selectedWidgetId]);
useEffect(() => {
if (activeTab === 'advanced') {
const fetchSize = async () => {
@ -301,32 +405,6 @@ export const PageRibbonBar = ({
}
}, [activeTab, page.owner, page.slug, baseUrl, orgSlug]);
const invalidatePageCache = React.useCallback(async () => {
try {
const session = await supabase.auth.getSession();
const token = session.data.session?.access_token;
if (!token) return;
const apiPath = `/api/user-page/${page.owner}/${page.slug}`;
const htmlPath = `/user/${page.owner}/pages/${page.slug}`;
await fetch(`${baseUrl}/api/cache/invalidate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
paths: [apiPath, htmlPath],
types: ['pages']
})
});
console.log('Cache invalidated for:', page.slug);
} catch (e) {
console.error('Failed to invalidate cache:', e);
}
}, [page.owner, page.slug, baseUrl]);
const handleParentUpdate = React.useCallback(async (newParentPage: any | null) => {
if (loading) return;
setLoading(true);
@ -424,7 +502,6 @@ export const PageRibbonBar = ({
setLoading(true);
try {
const { updatePage } = await import('@/lib/db');
await updatePage(page.id, { visible: !page.visible });
onPageUpdate({ ...page, visible: !page.visible });
toast.success(translate(page.visible ? 'Page hidden' : 'Page made visible'));
@ -442,7 +519,6 @@ export const PageRibbonBar = ({
setLoading(true);
try {
const { updatePage } = await import('@/lib/db');
await updatePage(page.id, { is_public: !page.is_public });
onPageUpdate({ ...page, is_public: !page.is_public });
@ -482,9 +558,9 @@ export const PageRibbonBar = ({
return (
<ActionProvider>
<div className={cn("flex flex-col w-full bg-background border-b shadow-sm relative z-40", className)}>
<div className={cn("flex flex-col w-full border-b shadow-sm sticky top-0 z-50", className)}>
{/* Context & Tabs Row */}
<div className="flex items-center border-b bg-muted/30">
<div className="flex items-center border-b bg-muted/90 backdrop-blur-sm">
<div className="px-4 py-1.5 bg-gradient-to-r from-blue-600 to-blue-500 text-white text-xs font-bold tracking-widest shadow-sm">
<T>DESIGN</T>
</div>
@ -498,50 +574,114 @@ export const PageRibbonBar = ({
</div>
{/* Ribbon Toolbar Area */}
<div className="h-24 flex items-center bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 shadow-inner px-0">
<div className="h-32 flex items-center backdrop-blur supports-[backdrop-filter]:bg-background/60 shadow-inner px-0">
{/* Fixed 'Finish' Section - Always Visible */}
<div className="flex-none flex h-full items-center border-r border-border/40 bg-background/50 z-10 px-1">
<RibbonGroup label="Exit">
{exitActions.map(action => (
<RibbonItemLarge
key={action.id}
icon={action.icon}
label={action.label}
onClick={() => action.handler && action.handler()}
active={action.id === FINISH_ACTION_ID} // Finish is active in original
iconColor={action.id === FINISH_ACTION_ID ? "text-green-600 dark:text-green-400" : "text-red-500 dark:text-red-400"}
<RibbonGroup label="Main">
<div className="grid grid-rows-3 grid-flow-col gap-1">
{exitActions.map(action => (
<RibbonItemSmall
key={action.id}
icon={action.icon}
label={action.label}
onClick={() => action.handler && action.handler()}
active={action.id === FINISH_ACTION_ID} // Finish is active in original
iconColor={action.id === FINISH_ACTION_ID ? "text-green-600 dark:text-green-400" : "text-red-500 dark:text-red-400"}
/>
))}
<RibbonItemSmall
icon={Save}
label="Update"
onClick={async () => {
setLoading(true);
try {
if (onSave) {
await onSave();
toast.success(translate('Page saved'));
}
} catch (e) {
console.error(e);
toast.error(translate('Failed to save'));
} finally {
setLoading(false);
}
}}
iconColor="text-blue-600 dark:text-blue-400"
/>
))}
<RibbonItemSmall
icon={page.visible ? Eye : EyeOff}
label={isPreview ? "Edit" : "Preview"}
onClick={onTogglePreview}
active={isPreview}
iconColor={isPreview ? "text-green-500" : "text-blue-500"}
/>
<RibbonItemSmall
icon={ListTree}
label="Hierarchy"
onClick={onToggleHierarchy}
active={showHierarchy}
iconColor="text-indigo-500"
/>
</div>
</RibbonGroup>
<RibbonGroup label="History">
{historyActions.map(action => (
<RibbonItemLarge
key={action.id}
icon={action.icon}
label={action.label}
onClick={() => action.handler && action.handler()}
disabled={action.disabled}
iconColor="text-purple-600 dark:text-purple-400"
/>
))}
<CompactFlowGroup
maxColumns={2}
actions={historyActions.map(action => ({
id: action.id,
icon: action.icon,
label: action.label,
onClick: () => action.handler && action.handler(),
disabled: action.disabled,
iconColor: "text-purple-600 dark:text-purple-400"
}))}
/>
</RibbonGroup>
<RibbonGroup label="Clipboard">
<CompactFlowGroup
maxColumns={2}
actions={[
{
icon: Copy,
label: 'Copy',
onClick: onCopy,
disabled: !selectedWidgetId && !selectedContainerId,
active: !!selectedWidgetId || !!selectedContainerId,
iconColor: 'text-blue-600 dark:text-blue-400'
},
{
icon: ClipboardPaste,
label: 'Paste',
onClick: onPaste,
disabled: !hasClipboard,
iconColor: 'text-green-600 dark:text-green-400'
}
]}
/>
</RibbonGroup>
{/* Fields Toggle */}
<RibbonGroup label="Data">
<RibbonItemLarge
icon={Database}
label="Fields"
onClick={onToggleTypeFields}
active={showTypeFields}
disabled={!hasTypeFields}
iconColor="text-teal-600 dark:text-teal-400"
/>
<RibbonItemLarge
icon={Database}
label="Variables"
onClick={() => setShowVariablesDialog(true)}
iconColor="text-indigo-600 dark:text-indigo-400"
<CompactFlowGroup
maxColumns={2}
actions={[
{
icon: Database,
label: "Fields",
onClick: onToggleTypeFields,
active: showTypeFields,
disabled: !hasTypeFields,
iconColor: "text-teal-600 dark:text-teal-400"
},
{
icon: Database,
label: "Variables",
onClick: () => setShowVariablesDialog(true),
iconColor: "text-indigo-600 dark:text-indigo-400"
}
]}
/>
</RibbonGroup>
</div>
@ -560,92 +700,92 @@ export const PageRibbonBar = ({
{activeTab === 'page' && (
<>
<RibbonGroup label="Manage">
<div className="flex flex-col gap-0.5 justify-center">
<RibbonItemSmall
icon={page.visible ? Eye : EyeOff}
label={page.visible ? "Visible" : "Hidden"}
active={!page.visible}
onClick={handleToggleVisibility}
iconColor={page.visible ? "text-emerald-500" : "text-gray-400"}
data-testid="page-visibility-toggle"
/>
<RibbonItemSmall
icon={page.is_public ? GitMerge : Settings}
label={page.is_public ? "Public" : "Private"}
active={!page.is_public}
onClick={handleTogglePublic}
iconColor={page.is_public ? "text-amber-500" : "text-gray-400"}
data-testid="page-public-toggle"
/>
</div>
<RibbonItemLarge
icon={FolderTree}
label="Categories"
onClick={() => setShowCategoryManager(true)}
iconColor="text-yellow-600 dark:text-yellow-400"
/>
<RibbonItemLarge
icon={GitMerge}
label="Parent"
onClick={() => setShowPagePicker(true)}
iconColor="text-orange-500 dark:text-orange-400"
/>
<RibbonItemLarge
icon={FilePlus}
label="Add Child"
onClick={() => setShowCreationWizard(true)}
iconColor="text-green-600 dark:text-green-500"
<CompactFlowGroup
maxColumns={3}
actions={[
{
icon: page.visible ? Eye : EyeOff,
label: page.visible ? "Visible" : "Hidden",
active: !page.visible,
onClick: () => handleToggleVisibility(),
iconColor: page.visible ? "text-emerald-500" : "text-gray-400"
},
{
icon: page.is_public ? GitMerge : Settings,
label: page.is_public ? "Public" : "Private",
active: !page.is_public,
onClick: () => handleTogglePublic(),
iconColor: page.is_public ? "text-amber-500" : "text-gray-400"
},
{
icon: FolderTree,
label: "Categories",
onClick: () => setShowCategoryManager(true),
iconColor: "text-yellow-600 dark:text-yellow-400"
},
{
icon: GitMerge,
label: "Parent",
onClick: () => setShowPagePicker(true),
iconColor: "text-orange-500 dark:text-orange-400"
},
{
icon: FilePlus,
label: "Add Child",
onClick: () => setShowCreationWizard(true),
iconColor: "text-green-600 dark:text-green-500"
}
]}
/>
</RibbonGroup>
<RibbonGroup label="Actions">
<RibbonItemLarge
icon={Save}
label="Update"
onClick={async () => {
setLoading(true);
try {
if (onSave) {
await onSave();
toast.success(translate('Page saved'));
}
} catch (e) {
console.error(e);
toast.error(translate('Failed to save'));
} finally {
setLoading(false);
}
}}
iconColor="text-blue-600 dark:text-blue-400"
<CompactFlowGroup
maxColumns={2}
actions={[
{
icon: Plus,
label: "New",
onClick: () => {
if (orgSlug) {
navigate(`/org/${orgSlug}/user/${page.owner}/pages/new`);
} else {
navigate(`/user/${page.owner}/pages/new`);
}
},
iconColor: "text-green-600 dark:text-green-400"
},
...(onDelete ? [{
icon: Trash2,
label: "Delete",
onClick: onDelete,
active: true,
iconColor: "text-red-500 hover:text-red-600"
}] : [])
]}
/>
{onDelete && (
<RibbonItemLarge
icon={Trash2}
label="Delete"
onClick={onDelete}
active
iconColor="text-red-500 hover:text-red-600"
/>
)}
</RibbonGroup>
<RibbonGroup label="File">
<RibbonItemLarge
icon={Upload}
label="Import"
onClick={onImportLayout}
iconColor="text-blue-500"
<CompactFlowGroup
maxColumns={2}
actions={[
{
icon: Upload,
label: "Import",
onClick: onImportLayout,
iconColor: "text-blue-500"
},
{
icon: Download,
label: "Export",
onClick: onExportLayout,
iconColor: "text-blue-500"
}
]}
/>
<RibbonItemLarge
icon={Download}
label="Export"
onClick={onExportLayout}
iconColor="text-blue-500"
/>
</RibbonGroup>
</>
)}
@ -680,67 +820,72 @@ export const PageRibbonBar = ({
return (
<>
<RibbonGroup label="Structure">
{/* Add Container Action */}
<RibbonItemLarge
icon={Grid}
label="Container"
onClick={onAddContainer}
iconColor="text-cyan-500"
<CompactFlowGroup
maxColumns={4}
actions={[
{
icon: Grid,
label: "Container",
onClick: onAddContainer,
iconColor: "text-cyan-500"
},
...structureWidgets.map(widget => ({
id: widget.metadata.id,
icon: widget.metadata.icon,
label: widget.metadata.name,
onClick: () => onToggleWidget?.(widget.metadata.id),
active: activeWidgets.has(widget.metadata.id),
iconColor: activeWidgets.has(widget.metadata.id) ? "text-green-600 dark:text-green-400" : "text-blue-600 dark:text-blue-400"
}))
]}
/>
{structureWidgets.map(widget => (
<RibbonItemLarge
key={widget.metadata.id}
icon={widget.metadata.icon}
label={widget.metadata.name}
onClick={() => onToggleWidget?.(widget.metadata.id)}
active={activeWidgets.has(widget.metadata.id)}
iconColor={activeWidgets.has(widget.metadata.id) ? "text-green-600 dark:text-green-400" : "text-blue-600 dark:text-blue-400"}
/>
))}
</RibbonGroup>
{mediaWidgets.length > 0 && (
<RibbonGroup label="Media">
{mediaWidgets.map(widget => (
<RibbonItemLarge
key={widget.metadata.id}
icon={widget.metadata.icon}
label={widget.metadata.name}
onClick={() => onToggleWidget?.(widget.metadata.id)}
active={activeWidgets.has(widget.metadata.id)}
iconColor={activeWidgets.has(widget.metadata.id) ? "text-green-600 dark:text-green-400" : "text-blue-600 dark:text-blue-400"}
/>
))}
<CompactFlowGroup
maxColumns={4}
actions={mediaWidgets.map(widget => ({
id: widget.metadata.id,
icon: widget.metadata.icon,
label: widget.metadata.name,
onClick: () => onToggleWidget?.(widget.metadata.id),
active: activeWidgets.has(widget.metadata.id),
iconColor: activeWidgets.has(widget.metadata.id) ? "text-green-600 dark:text-green-400" : "text-blue-600 dark:text-blue-400"
}))}
/>
</RibbonGroup>
)}
{contentWidgets.length > 0 && (
<RibbonGroup label="Content">
{contentWidgets.map(widget => (
<RibbonItemLarge
key={widget.metadata.id}
icon={widget.metadata.icon}
label={widget.metadata.name}
onClick={() => onToggleWidget?.(widget.metadata.id)}
active={activeWidgets.has(widget.metadata.id)}
iconColor={activeWidgets.has(widget.metadata.id) ? "text-green-600 dark:text-green-400" : "text-blue-600 dark:text-blue-400"}
/>
))}
<CompactFlowGroup
maxColumns={4}
actions={contentWidgets.map(widget => ({
id: widget.metadata.id,
icon: widget.metadata.icon,
label: widget.metadata.name,
onClick: () => onToggleWidget?.(widget.metadata.id),
active: activeWidgets.has(widget.metadata.id),
iconColor: activeWidgets.has(widget.metadata.id) ? "text-green-600 dark:text-green-400" : "text-blue-600 dark:text-blue-400"
}))}
/>
</RibbonGroup>
)}
{advancedWidgets.length > 0 && (
<RibbonGroup label="Advanced">
{advancedWidgets.map(widget => (
<RibbonItemLarge
key={widget.metadata.id}
icon={widget.metadata.icon}
label={widget.metadata.name}
onClick={() => onToggleWidget?.(widget.metadata.id)}
active={activeWidgets.has(widget.metadata.id)}
iconColor={activeWidgets.has(widget.metadata.id) ? "text-green-600 dark:text-green-400" : "text-blue-600 dark:text-blue-400"}
/>
))}
<CompactFlowGroup
maxColumns={4}
actions={advancedWidgets.map(widget => ({
id: widget.metadata.id,
icon: widget.metadata.icon,
label: widget.metadata.name,
onClick: () => onToggleWidget?.(widget.metadata.id),
active: activeWidgets.has(widget.metadata.id),
iconColor: activeWidgets.has(widget.metadata.id) ? "text-green-600 dark:text-green-400" : "text-blue-600 dark:text-blue-400"
}))}
/>
</RibbonGroup>
)}
</>
@ -751,41 +896,44 @@ export const PageRibbonBar = ({
{activeTab === 'layouts' && (
<>
<RibbonGroup label="Actions">
<RibbonItemLarge
icon={FilePlus}
label="New Layout"
onClick={onNewLayout}
iconColor="text-green-500"
<CompactFlowGroup
maxColumns={2}
actions={[
{
icon: FilePlus,
label: "New Layout",
onClick: onNewLayout,
iconColor: "text-green-500"
},
...(onSaveAsNewTemplate ? [{
icon: Save,
label: "Save as New",
onClick: onSaveAsNewTemplate,
iconColor: "text-blue-500"
}] : []),
...(activeTemplateId && onSaveToTemplate ? [{
icon: Save,
label: "Update Layout",
onClick: onSaveToTemplate,
iconColor: "text-pink-600 dark:text-pink-400"
}] : [])
]}
/>
{onSaveAsNewTemplate && (
<RibbonItemLarge
icon={Save}
label="Save as New"
onClick={onSaveAsNewTemplate}
iconColor="text-blue-500"
/>
)}
{activeTemplateId && onSaveToTemplate && (
<RibbonItemLarge
icon={Save}
label="Update Layout"
onClick={onSaveToTemplate}
iconColor="text-pink-600 dark:text-pink-400"
/>
)}
</RibbonGroup>
<RibbonGroup label="Templates">
{templates?.map(t => (
<RibbonItemLarge
key={t.id}
icon={LayoutTemplate}
label={t.name}
onClick={() => onLoadTemplate?.(t)}
active={t.id === activeTemplateId}
iconColor="text-indigo-500 dark:text-indigo-400"
/>
))}
<CompactFlowGroup
maxColumns={4}
actions={templates?.map(t => ({
id: t.id,
icon: LayoutTemplate,
label: t.name,
onClick: () => onLoadTemplate?.(t),
active: activeTemplateId === t.id,
iconColor: activeTemplateId === t.id ? "text-green-600" : "text-gray-500",
onDelete: onDeleteTemplate ? () => onDeleteTemplate(t) : undefined
})) || []}
/>
{(!templates || templates.length === 0) && (
<div className="text-xs text-muted-foreground px-2 italic"><T>No Templates</T></div>
)}
@ -797,13 +945,19 @@ export const PageRibbonBar = ({
{activeTab === 'view' && (
<>
<RibbonGroup label="Panels">
<RibbonItemLarge
icon={ListTree}
label="Hierarchy"
onClick={onToggleHierarchy}
active={showHierarchy}
iconColor="text-indigo-500"
<CompactFlowGroup
maxColumns={1}
actions={[
{
icon: ListTree,
label: "Hierarchy",
onClick: onToggleHierarchy,
active: showHierarchy,
iconColor: "text-indigo-500"
}
]}
/>
</RibbonGroup>
</>
)}
@ -812,17 +966,22 @@ export const PageRibbonBar = ({
{activeTab === 'advanced' && (
<>
<RibbonGroup label="Developer">
<RibbonItemLarge
icon={Type}
label="Types"
onClick={handleOpenTypes}
iconColor="text-indigo-600 dark:text-indigo-400"
/>
<RibbonItemLarge
icon={FileJson}
label="Dump JSON"
onClick={handleDumpJson}
iconColor="text-orange-600 dark:text-orange-400"
<CompactFlowGroup
maxColumns={2}
actions={[
{
icon: Type,
label: "Types",
onClick: handleOpenTypes,
iconColor: "text-indigo-600 dark:text-indigo-400"
},
{
icon: FileJson,
label: "Dump JSON",
onClick: handleDumpJson,
iconColor: "text-orange-600 dark:text-orange-400"
}
]}
/>
</RibbonGroup>
@ -837,20 +996,44 @@ export const PageRibbonBar = ({
</div>
<div className="text-[9px] text-muted-foreground/60">Limit: 102KB</div>
</div>
<RibbonItemLarge
icon={Mail}
label="Preview"
onClick={onToggleEmailPreview}
active={showEmailPreview}
iconColor="text-purple-600 dark:text-purple-400"
/>
<RibbonItemLarge
icon={Send}
label="Send"
onClick={onSendEmail}
iconColor="text-teal-600 dark:text-teal-400"
<CompactFlowGroup
maxColumns={2}
actions={[
{
icon: Mail,
label: "Preview",
onClick: onToggleEmailPreview,
active: showEmailPreview,
iconColor: "text-purple-600 dark:text-purple-400"
},
{
icon: Send,
label: "Send",
onClick: onSendEmail,
iconColor: "text-teal-600 dark:text-teal-400"
}
]}
/>
</RibbonGroup>
<RibbonGroup label="Selection">
<div className="flex flex-col justify-center px-3 gap-0.5 h-full min-w-[6rem]">
<div className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
Selected
</div>
<div className="text-xs font-mono text-foreground/80">
{selectedContainerId ? (
<div title={selectedContainerId}>C: {selectedContainerId.slice(0, 8)}...</div>
) : <div className="text-muted-foreground/50">No Container</div>}
{selectedWidgetId ? (
<div title={selectedWidgetId}>W: {selectedWidgetId.slice(0, 8)}...{(selectedWidgetIds?.size ?? 0) > 1 ? ` +${(selectedWidgetIds?.size ?? 1) - 1}` : ''}</div>
) : <div className="text-muted-foreground/50">No Widget</div>}
</div>
<div className="text-[9px] text-muted-foreground/60">
Count: {(selectedContainerId ? 1 : 0) + (selectedWidgetIds?.size ?? (selectedWidgetId ? 1 : 0))}
</div>
</div>
</RibbonGroup>
</>
)}
</div>
@ -983,7 +1166,7 @@ export const PageRibbonBar = ({
</AlertDialogContent>
</AlertDialog>
</div>
</ActionProvider>
</ActionProvider >
);
};

View File

@ -1,19 +1,22 @@
import { useState } from "react";
import UserManager from "@/components/admin/UserManager";
import { useAuth } from "@/hooks/useAuth";
import { Navigate } from "react-router-dom";
import { T } from "@/i18n";
import { SidebarProvider } from "@/components/ui/sidebar";
import { AdminSidebar, AdminActiveSection } from "@/components/admin/AdminSidebar";
import { AdminSidebar } from "@/components/admin/AdminSidebar";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { Server, RefreshCw, Power } from "lucide-react";
import { BansManager } from "@/components/admin/BansManager";
import { ViolationsMonitor } from "@/components/admin/ViolationsMonitor";
import React, { Suspense } from "react";
import { Routes, Route, Navigate } from "react-router-dom";
// Lazy load AnalyticsDashboard
const AnalyticsDashboard = React.lazy(() => import("@/pages/analytics").then(module => ({ default: module.AnalyticsDashboard })));
const AdminPage = () => {
const { user, session, loading, roles } = useAuth();
const [activeSection, setActiveSection] = useState<AdminActiveSection>('users');
if (loading) {
return <div className="min-h-screen bg-background flex items-center justify-center">
@ -32,14 +35,22 @@ const AdminPage = () => {
return (
<SidebarProvider>
<div className="min-h-screen flex w-full bg-background pt-14">
<AdminSidebar activeSection={activeSection} onSectionChange={setActiveSection} />
<AdminSidebar />
<main className="flex-1 p-8 overflow-auto">
<div className="max-w-7xl mx-auto">
{activeSection === 'users' && <UserManagerSection />}
{activeSection === 'dashboard' && <DashboardSection />}
{activeSection === 'server' && <ServerSection session={session} />}
{activeSection === 'bans' && <BansSection session={session} />}
{activeSection === 'violations' && <ViolationsSection session={session} />}
<Routes>
<Route path="/" element={<Navigate to="users" replace />} />
<Route path="users" element={<UserManagerSection />} />
<Route path="dashboard" element={<DashboardSection />} />
<Route path="server" element={<ServerSection session={session} />} />
<Route path="bans" element={<BansSection session={session} />} />
<Route path="violations" element={<ViolationsSection session={session} />} />
<Route path="analytics" element={
<Suspense fallback={<div>Loading analytics...</div>}>
<AnalyticsDashboard />
</Suspense>
} />
</Routes>
</div>
</main>
</div>

View File

@ -893,11 +893,20 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
}
const containerClassName = embedded
? `flex flex-col bg-background h-full ${className || ''}`
: "bg-background flex flex-col h-full";
? `flex flex-col bg-background h-[inherit] ${className || ''}`
: "bg-background flex flex-col h-[inherit]";
if (!embedded && !className) {
className = "h-full"
}
/*
console.log('containerClassName', containerClassName);
console.log('className', className);
console.log('embedded', embedded);
*/
return (
<div className={`min-h-screen bg-background ${className}`}>
<div className={`bg-background ${className}`}>
{post && (
<SEO
title={post.title || mediaItem?.title}
@ -906,7 +915,7 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
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-[inherit]" : "w-full max-w-[1600px] mx-auto"}>
{viewMode === 'article' ? (
<ArticleRenderer {...rendererProps} mediaItem={mediaItem} />

View File

@ -42,7 +42,7 @@ export const CompactRenderer: React.FC<PostRendererProps> = (props) => {
const isVideo = isVideoType(normalizeMediaType(effectiveType));
return (
<div className={props.className || "h-full"}>
<div className={props.className || 'h-[inherit]'}>
{/* Mobile Header - Controls and Info at Top */}
<div className="lg:hidden landscape:hidden py-4 bg-card ">
<CompactPostHeader
@ -67,8 +67,8 @@ export const CompactRenderer: React.FC<PostRendererProps> = (props) => {
</div>
{/* Desktop layout: Media on left, content on right */}
<div className="overflow-hidden-x group h-full">
<div className="grid grid-cols-1 lg:grid-cols-2 h-full">
<div className="overflow-hidden-x group h-[inherit]">
<div className="grid grid-cols-1 lg:grid-cols-2 h-[inherit]">
{/* Left Column - Media */}
<div className={`${isVideo ? 'aspect-video' : 'aspect-square'} lg:aspect-auto bg-background border flex flex-col relative h-full`}>

View File

@ -5,11 +5,11 @@ import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { T } from "@/i18n";
import MarkdownRenderer from "@/components/MarkdownRenderer";
import UserAvatarBlock from "@/components/UserAvatarBlock";
import { ExportDropdown } from "../../components/ExportDropdown";
import { PostRendererProps } from '../../types';
import { PostMediaItem, UserProfile } from "../../types";
interface CompactPostHeaderProps {

View File

@ -174,10 +174,10 @@ export const Gallery: React.FC<GalleryProps> = ({
{/* Filmstrip */}
<div className={`
flex justify-center
${isSidebar ? 'h-full w-auto border-l border-zinc-800' : 'w-full h-auto'}
${thumbnailsPosition === 'left' ? 'border-r border-l-0' : ''}
${thumbnailsPosition === 'top' ? 'border-b' : ''}
${thumbnailsPosition === 'bottom' ? 'border-t' : ''}
${isSidebar ? 'h-full w-auto' : 'w-full h-auto'}
${thumbnailsPosition === 'left' ? '' : ''}
${thumbnailsPosition === 'top' ? '' : ''}
${thumbnailsPosition === 'bottom' ? '' : ''}
`}>
<CompactFilmStrip
mediaItems={mediaItems}

View File

@ -1,6 +1,6 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { invalidateServerCache } from '@/lib/db';
import { invalidateServerCache, deletePost, updatePostMeta } from '@/lib/db';
import { toast } from "sonner";
import { supabase } from "@/integrations/supabase/client";
import { MediaItem, PostItem, User } from "@/types";
@ -40,12 +40,7 @@ export const usePostActions = ({
const handleDeletePost = async () => {
if (!post) return;
try {
const { error } = await supabase
.from('posts')
.delete()
.eq('id', post.id);
if (error) throw error;
await deletePost(post.id);
toast.success(translate('Post deleted'));
await invalidateServerCache(['posts']);
@ -122,9 +117,7 @@ export const usePostActions = ({
.eq('id', item.id);
if (error) throw error;
toast.success(translate('Image removed from post'));
// Re-index remaining items
await updateMediaPositions(newItems, setMediaItems, setMediaItems); // Using same setter for both generic/local here if not editing
} catch (error) {
@ -133,39 +126,13 @@ export const usePostActions = ({
fetchMedia(); // Revert on error
}
};
const handleLike = async (itemToLike: MediaItem = mediaItem!) => {
if (!user || !itemToLike) return;
// Check if already liked check needs to be done in UI or here?
// Post.tsx used `isLiked` state.
// Refactoring `isLiked` inside hook is hard because it depends on `mediaItem` changing.
// So we'll just expose the toggle function and let Post.tsx manage `isLiked` state?
// Or move `isLiked` state here? `isLiked` depends on `userLikes` (list).
// Let's keep `handleLike` simple: toggle the like in DB.
// BUT Post.tsx logic for `handleLike` has optimistic updates on `mediaItems`.
// SIMPLIFIED: Just call the Supabase toggle RPC or insert/delete.
// Current implementation in Post.tsx uses simple insert/delete and updates local state.
// I'll leave `handleLike` in Post.tsx for now because it's tightly coupled with `userLikes` state array.
// Moving it requires moving `userLikes` state too.
// I will skip moving `handleLike` for now to reduce risk, as per "reduce complexity" - moving coupled state might increase it or require a larger refactor (context).
// The user asked to move "delete actions, etc". I moved deletes.
return;
};
const handleMetaUpdate = async (newMeta: any) => {
if (!post) return;
// Persist to database
try {
const { updatePostMeta } = await import('@/lib/db');
await updatePostMeta(post.id, newMeta);
toast.success(translate('Categories updated'));
// Trigger parent refresh
await invalidateServerCache(['posts']);
fetchMedia();
} catch (error) {
console.error('Failed to update post meta:', error);

View File

@ -106,6 +106,7 @@ const Profile = () => {
const fetchProfile = async () => {
try {
console.log('Fetching profile for user:', user?.id);
const { data, error } = await supabase
.from('profiles')
.select('username, display_name, bio, avatar_url, settings')
@ -688,6 +689,18 @@ const Profile = () => {
</Card>
)}
{activeSection === 'analytics' && roles.includes('admin') && (
<Card>
<CardHeader>
<CardTitle><T>Analytics</T></CardTitle>
</CardHeader>
<CardContent>
<AnalyticsDashboard />
</CardContent>
</Card>
)}
{activeSection === 'gallery' && (
<Card>
<CardHeader>
@ -738,6 +751,8 @@ const ProfileSidebar = ({
{ id: 'variables' as ActiveSection, label: translate('Variables'), icon: Hash },
];
return (
<Sidebar collapsible="icon">
<SidebarContent>

View File

@ -100,6 +100,7 @@ const UserProfile = () => {
const fetchUserProfile = async () => {
try {
// Fetch profile with user_roles
console.log('Fetching profile for user:', userId);
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select(`

View File

@ -131,6 +131,7 @@ export default function VideoFeedPlayground() {
const uniqueUserIds = [...new Set(userIds)].filter(id => !userProfilesRef.current[id]);
if (uniqueUserIds.length === 0) return {};
console.log('Fetching profiles for users:', uniqueUserIds);
const { data, error } = await supabase
.from('profiles')
.select('user_id, avatar_url, display_name, username')

View File

@ -0,0 +1,465 @@
import React, { useEffect, useState, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
DataGrid,
GridColDef,
GridFilterModel,
GridPaginationModel,
GridSortModel,
GridColumnVisibilityModel,
GridToolbar
} from '@mui/x-data-grid';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Loader2, Trash2, Download, Bookmark, X, Save } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { fetchAnalytics, clearAnalytics } from '@/lib/db';
import {
filterModelToParams,
paramsToFilterModel,
sortModelToParams,
paramsToSortModel,
visibilityModelToParams,
paramsToVisibilityModel,
paramsToColumnOrder
} from './gridUtils';
import { format } from 'date-fns';
interface AnalyticsEvent {
timestamp: string;
method: string;
path: string;
status: number;
ip: string;
userAgent: string;
userId?: string;
referer?: string;
geo?: {
country?: {
name: string;
countryFlagEmoji: string;
isoAlpha2: string;
};
city?: string;
};
isBot?: boolean;
isAI?: boolean;
}
interface SavedFilter {
name: string;
filterModel: GridFilterModel;
}
const AnalyticsDashboard = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [data, setData] = useState<AnalyticsEvent[]>([]);
const [loading, setLoading] = useState(true);
// Initialize state from URL params
const [filterModel, setFilterModel] = useState<GridFilterModel>(() => paramsToFilterModel(searchParams));
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>(() => {
const page = parseInt(searchParams.get('page') || '0', 10);
const pageSize = parseInt(searchParams.get('pageSize') || '100', 10);
return { page, pageSize };
});
const [sortModel, setSortModel] = useState<GridSortModel>(() => {
const urlSort = paramsToSortModel(searchParams);
return urlSort.length > 0 ? urlSort : [{ field: 'timestamp', sort: 'desc' }];
});
const [columnVisibilityModel, setColumnVisibilityModel] = useState<GridColumnVisibilityModel>(() => paramsToVisibilityModel(searchParams));
// Saved Filters State
const [savedFilters, setSavedFilters] = useState<SavedFilter[]>(() => {
try {
const saved = localStorage.getItem('analytics-filter-presets');
return saved ? JSON.parse(saved) : [];
} catch (e) {
return [];
}
});
const [newFilterName, setNewFilterName] = useState("");
const [isSavePopoverOpen, setIsSavePopoverOpen] = useState(false);
// Persist saved filters
useEffect(() => {
localStorage.setItem('analytics-filter-presets', JSON.stringify(savedFilters));
}, [savedFilters]);
const handleSaveFilter = () => {
if (!newFilterName.trim()) return;
const newHelper: SavedFilter = {
name: newFilterName.trim(),
filterModel: filterModel // Save current filter model
};
setSavedFilters([...savedFilters, newHelper]);
setNewFilterName("");
setIsSavePopoverOpen(false);
};
const handleLoadFilter = (saved: SavedFilter) => {
setFilterModel(saved.filterModel);
// Also update URL params
handleFilterModelChange(saved.filterModel);
};
const handleDeleteFilter = (e: React.MouseEvent, index: number) => {
e.stopPropagation();
const updated = [...savedFilters];
updated.splice(index, 1);
setSavedFilters(updated);
};
useEffect(() => {
let eventSource: EventSource | undefined;
let isMounted = true;
const loadData = async () => {
setLoading(true);
try {
// Initial fetch
const events = await fetchAnalytics({
limit: 1000
});
if (!isMounted) return;
setData(events.map((e: any, index: number) => ({ ...e, id: `init-${index}` })));
// Start Streaming
eventSource = new EventSource('/api/analytics/stream');
eventSource.addEventListener('log', (event: any) => {
if (!isMounted) return;
try {
const newEntry = JSON.parse(event.data);
setData(prev => {
// Add new entry, keep max 2000 rows
const updated = [{ ...newEntry, id: `stream-${Date.now()}-${Math.random()}` }, ...prev];
return updated.slice(0, 2000);
});
} catch (e) {
console.error('Failed to parse SSE event', e);
}
});
eventSource.onerror = (err) => {
console.error('SSE Error:', err);
if (eventSource) eventSource.close();
};
} catch (error) {
console.error("Failed to load analytics", error);
} finally {
if (isMounted) setLoading(false);
}
};
loadData();
return () => {
isMounted = false;
if (eventSource) {
eventSource.close();
}
};
}, []);
// Update URL when filter model changes
const handleFilterModelChange = (newFilterModel: GridFilterModel) => {
setFilterModel(newFilterModel);
setSearchParams(prev => {
const newParams = new URLSearchParams(prev);
// Clear old filters
Array.from(newParams.keys()).forEach(key => {
if (key.startsWith('filter_')) newParams.delete(key);
});
// Add new filters
const filterParams = filterModelToParams(newFilterModel);
Object.entries(filterParams).forEach(([key, value]) => {
newParams.set(key, value);
});
return newParams;
}, { replace: true });
};
// Update URL when pagination changes
const handlePaginationModelChange = (newPaginationModel: GridPaginationModel) => {
setPaginationModel(newPaginationModel);
setSearchParams(prev => {
const newParams = new URLSearchParams(prev);
newPaginationModel.page !== 0 ? newParams.set('page', newPaginationModel.page.toString()) : newParams.delete('page');
newPaginationModel.pageSize !== 100 ? newParams.set('pageSize', newPaginationModel.pageSize.toString()) : newParams.delete('pageSize');
return newParams;
}, { replace: true });
};
// Update URL when sort model changes
const handleSortModelChange = (newSortModel: GridSortModel) => {
setSortModel(newSortModel);
setSearchParams(prev => {
const newParams = new URLSearchParams(prev);
const sortParams = sortModelToParams(newSortModel);
newParams.delete('sort');
if (sortParams.sort) newParams.set('sort', sortParams.sort);
return newParams;
}, { replace: true });
};
// Update URL when column visibility changes
const handleColumnVisibilityModelChange = (newModel: GridColumnVisibilityModel) => {
setColumnVisibilityModel(newModel);
setSearchParams(prev => {
const newParams = new URLSearchParams(prev);
const visibilityParams = visibilityModelToParams(newModel);
newParams.delete('hidden');
if (visibilityParams.hidden !== undefined) newParams.set('hidden', visibilityParams.hidden);
return newParams;
}, { replace: true });
};
// Handle column reordering
const handleColumnOrderChange = (params: any) => {
// Simple implementation: just update the URL with the new full order if possible,
// but DataGrid doesn't expose the full new order easily in the event.
// For simplicity, we might skip full reorder persistence or need a more complex state tracking
// similar to the reference code.
// For now, let's skip reorder persistence to keep it simple, or implement if strictly needed.
};
const handleClearDatabase = async () => {
if (!confirm('Are you sure you want to clear the entire analytics database? This cannot be undone.')) return;
try {
await clearAnalytics();
setData([]); // Also clear local view
} catch (error) {
console.error("Failed to clear analytics database", error);
alert("Failed to clear database");
}
};
const handleClear = () => {
setData([]);
};
const handleExport = () => {
const jsonString = JSON.stringify(data, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `analytics-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const columns: GridColDef[] = [
{
field: 'timestamp',
headerName: 'Timestamp',
width: 180,
valueFormatter: (value: any) => {
try {
return value ? format(new Date(value), 'yyyy-MM-dd HH:mm:ss') : '';
} catch (e) {
return String(value);
}
}
},
{ field: 'method', headerName: 'Method', width: 90 },
{
field: 'country',
headerName: 'Country',
width: 150,
valueGetter: (value: any, row: any) => {
return row.geo?.country?.name || '';
},
renderCell: (params: any) => {
const country = params.row.geo?.country;
if (!country) return <span className="text-muted-foreground">-</span>;
return (
<div className="flex items-center gap-2">
<span className="text-lg">{country.countryFlagEmoji}</span>
<span className="truncate" title={country.name}>{country.name}</span>
</div>
);
}
},
{
field: 'isBot',
headerName: 'Bot',
width: 80,
renderCell: (params: any) => {
const isBot = params.value;
if (!isBot) return <span className="text-muted-foreground">-</span>;
return (
<div className="flex items-center justify-center w-full h-full">
<span className="text-lg" title="Bot Request">🤖</span>
</div>
);
}
},
{
field: 'isAI',
headerName: 'AI',
width: 80,
renderCell: (params: any) => {
const isAI = params.value;
if (!isAI) return <span className="text-muted-foreground">-</span>;
return (
<div className="flex items-center justify-center w-full h-full">
<span className="text-lg" title="AI Agent">🧠</span>
</div>
);
}
},
{
field: 'status', headerName: 'Status', width: 90,
renderCell: (params) => {
const status = params.value as number;
let color = 'text-green-600';
if (status >= 400 && status < 500) color = 'text-yellow-600';
if (status >= 500) color = 'text-red-600';
return <span className={`font-bold ${color}`}>{status}</span>;
}
},
{ field: 'path', headerName: 'Path', flex: 1, minWidth: 200 },
{ field: 'ip', headerName: 'IP Address', width: 130 },
{
field: 'userAgent', headerName: 'User Agent', width: 200,
renderCell: (params: any) => (
<div className="truncate w-full" title={params.value}>
{params.value}
</div>
)
},
{ field: 'referer', headerName: 'Referer', width: 200 },
{ field: 'userId', headerName: 'User ID', width: 150 },
];
// Apply column order from URL if needed (skipping for now based on complexity vs benefit)
return (
<Card className="h-[calc(100vh-100px)] flex flex-col">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Analytics Dashboard</CardTitle>
<div className="flex gap-2">
<Popover open={isSavePopoverOpen} onOpenChange={setIsSavePopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Save className="w-4 h-4 mr-2" />
Save Filter
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">
<div className="grid gap-4">
<div className="space-y-2">
<h4 className="font-medium leading-none">Save Filter Preset</h4>
<p className="text-sm text-muted-foreground">
Save the current filters as a preset.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<div className="flex gap-2">
<Input
id="name"
value={newFilterName}
onChange={(e) => setNewFilterName(e.target.value)}
placeholder="e.g. Errors Only"
className="h-8"
/>
<Button size="sm" onClick={handleSaveFilter}>Save</Button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Bookmark className="w-4 h-4 mr-2" />
Load Filter
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Saved Filters</DropdownMenuLabel>
<DropdownMenuSeparator />
{savedFilters.length === 0 ? (
<div className="p-2 text-sm text-muted-foreground text-center">
No saved filters
</div>
) : (
savedFilters.map((filter, index) => (
<DropdownMenuItem key={index} onClick={() => handleLoadFilter(filter)} className="flex justify-between items-center">
<span>{filter.name}</span>
<X
className="w-4 h-4 opacity-50 hover:opacity-100 cursor-pointer"
onClick={(e) => handleDeleteFilter(e, index)}
/>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm" onClick={handleClear}>
<Trash2 className="w-4 h-4 mr-2" />
Clear View
</Button>
<Button variant="destructive" size="sm" onClick={handleClearDatabase}>
<Trash2 className="w-4 h-4 mr-2" />
Clear Database
</Button>
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="w-4 h-4 mr-2" />
Export JSON
</Button>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden p-0">
<div style={{ height: '100%', width: '100%' }}>
<DataGrid
rows={data}
columns={columns}
loading={loading}
filterModel={filterModel}
onFilterModelChange={handleFilterModelChange}
paginationModel={paginationModel}
onPaginationModelChange={handlePaginationModelChange}
sortModel={sortModel}
onSortModelChange={handleSortModelChange}
columnVisibilityModel={columnVisibilityModel}
onColumnVisibilityModelChange={handleColumnVisibilityModelChange}
// onColumnOrderChange={handleColumnOrderChange}
pageSizeOptions={[20, 50, 100]}
slots={{
toolbar: GridToolbar,
}}
disableRowSelectionOnClick
className="border-0 rounded-none h-full"
/>
</div>
</CardContent>
</Card>
);
};
export default AnalyticsDashboard;

View File

@ -0,0 +1,132 @@
import type { GridFilterModel, GridSortModel, GridColumnVisibilityModel } from '@mui/x-data-grid';
// Convert filter model to human-readable URL params
export const filterModelToParams = (filterModel: GridFilterModel): Record<string, string> => {
const params: Record<string, string> = {};
filterModel.items.forEach((item) => {
if (item.field && item.operator && item.value !== undefined && item.value !== null) {
// Create param like: filter_city_contains=Barcelona
const key = `filter_${item.field}_${item.operator}`;
params[key] = String(item.value);
}
});
return params;
};
// Convert URL params back to filter model
export const paramsToFilterModel = (searchParams: URLSearchParams): GridFilterModel => {
const items: any[] = [];
searchParams.forEach((value, key) => {
if (key.startsWith('filter_')) {
// Parse: filter_city_contains -> field: city, operator: contains
// Handle potential spaces in keys
const cleanKey = key.trim();
const parts = cleanKey.replace('filter_', '').split('_');
if (parts.length >= 2) {
const operator = parts.pop(); // Last part is operator
const field = parts.join('_'); // Rest is field name (handles fields with underscores)
if (operator) {
items.push({
field,
operator: operator.trim(),
value,
});
}
}
}
});
return { items };
};
// Convert sort model to URL params
export const sortModelToParams = (sortModel: GridSortModel): Record<string, string> => {
const params: Record<string, string> = {};
if (sortModel.length > 0) {
const { field, sort } = sortModel[0];
if (sort) {
params.sort = `${field}:${sort}`;
}
}
return params;
};
// Convert URL params back to sort model
export const paramsToSortModel = (searchParams: URLSearchParams): GridSortModel => {
const sortParam = searchParams.get('sort');
if (sortParam) {
// Handle encoded spaces or trailing spaces
const cleanParam = sortParam.trim();
const [field, sort] = cleanParam.split(':');
const cleanSort = sort?.trim();
if (field && (cleanSort === 'asc' || cleanSort === 'desc')) {
return [{ field, sort: cleanSort as 'asc' | 'desc' }];
}
}
return [];
};
const DEFAULT_HIDDEN_COLUMNS = ['userAgent', 'referer'];
// Convert visibility model to URL params
export const visibilityModelToParams = (model: GridColumnVisibilityModel): Record<string, string> => {
const hiddenCols = Object.entries(model)
.filter(([_, visible]) => !visible)
.map(([field]) => field);
// Sort logic to make comparison consistent (optional but good for stability)
hiddenCols.sort();
const defaultHidden = [...DEFAULT_HIDDEN_COLUMNS].sort();
const isDefault =
hiddenCols.length === defaultHidden.length &&
hiddenCols.every((val, index) => val === defaultHidden[index]);
if (isDefault) {
return {}; // No param needed if it matches default
}
if (hiddenCols.length === 0) {
return { hidden: '' }; // Explicitly show all (override default)
}
return { hidden: hiddenCols.join(',') };
};
// Convert URL params back to visibility model
export const paramsToVisibilityModel = (searchParams: URLSearchParams): GridColumnVisibilityModel => {
const hiddenParam = searchParams.get('hidden');
// Case 1: No param -> Use defaults
if (hiddenParam === null) {
const model: GridColumnVisibilityModel = {};
DEFAULT_HIDDEN_COLUMNS.forEach(col => {
model[col] = false;
});
return model;
}
// Case 2: Empty param (hidden=) -> Show all
if (hiddenParam === '') {
return {};
}
// Case 3: Explicit hidden columns
const model: GridColumnVisibilityModel = {};
hiddenParam.split(',').forEach(field => {
if (field) model[field] = false;
});
return model;
};
// Get column order from URL params
export const paramsToColumnOrder = (searchParams: URLSearchParams): string[] => {
const orderParam = searchParams.get('order');
return orderParam ? orderParam.split(',') : [];
};

View File

@ -0,0 +1 @@
export { default as AnalyticsDashboard } from './AnalyticsDashboard';