diff --git a/packages/ui/src/components/Comments.tsx b/packages/ui/src/components/Comments.tsx
index 0f9e1b74..697240d4 100644
--- a/packages/ui/src/components/Comments.tsx
+++ b/packages/ui/src/components/Comments.tsx
@@ -103,6 +103,7 @@ const Comments = ({ pictureId, initialComments }: CommentsProps) => {
const missingUserIds = uniqueUserIds.filter(id => !userProfiles.has(id));
if (missingUserIds.length > 0) {
+ console.log('Fetching profiles for users:', missingUserIds);
const { data: profilesData, error: profilesError } = await supabase
.from('profiles')
.select('user_id, avatar_url, display_name, username')
diff --git a/packages/ui/src/components/ConfirmationDialog.tsx b/packages/ui/src/components/ConfirmationDialog.tsx
new file mode 100644
index 00000000..c812c8ed
--- /dev/null
+++ b/packages/ui/src/components/ConfirmationDialog.tsx
@@ -0,0 +1,110 @@
+import React, { useRef, useEffect } from "react";
+import {
+ AlertDialog,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogCancel,
+ AlertDialogAction,
+} from "@/components/ui/alert-dialog";
+import { cn } from "@/lib/utils";
+import { T } from "@/i18n";
+
+interface ConfirmationDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ description: React.ReactNode;
+ onConfirm: () => void;
+ onCancel?: () => void;
+ confirmLabel?: string;
+ cancelLabel?: string;
+ variant?: "default" | "destructive";
+}
+
+export const ConfirmationDialog = ({
+ open,
+ onOpenChange,
+ title,
+ description,
+ onConfirm,
+ onCancel,
+ confirmLabel = "Continue",
+ cancelLabel = "Cancel",
+ variant = "default",
+}: ConfirmationDialogProps) => {
+ const cancelRef = useRef
(null);
+ const confirmRef = useRef(null);
+ const lastFocusedRef = useRef(null);
+
+ // Initial focus when opening and restore logic
+ useEffect(() => {
+ if (open) {
+ lastFocusedRef.current = document.activeElement as HTMLElement;
+ // Slight delay to ensure content is mounted and capable of receiving focus
+ // Radix UI handles initial focus too, but we might want to enforce "Cancel" as default for safety
+ setTimeout(() => {
+ cancelRef.current?.focus();
+ }, 50);
+ } else {
+ // Restore focus when closed
+ if (lastFocusedRef.current) {
+ lastFocusedRef.current.focus();
+ lastFocusedRef.current = null;
+ }
+ }
+ }, [open]);
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "ArrowRight") {
+ e.preventDefault();
+ confirmRef.current?.focus();
+ } else if (e.key === "ArrowLeft") {
+ e.preventDefault();
+ cancelRef.current?.focus();
+ }
+ };
+
+ const handleConfirm = (e: React.MouseEvent) => {
+ e.preventDefault();
+ onConfirm();
+ };
+
+ const handleCancel = (e: React.MouseEvent) => {
+ e.preventDefault();
+ if (onCancel) onCancel();
+ else onOpenChange(false);
+ };
+
+ return (
+
+
+
+ {title}
+
+
+ {typeof description === 'string' ? {description} : description}
+
+
+
+
+
+ {cancelLabel}
+
+
+ {confirmLabel}
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/ImageLightbox.tsx b/packages/ui/src/components/ImageLightbox.tsx
index 311541ac..2f7fd114 100644
--- a/packages/ui/src/components/ImageLightbox.tsx
+++ b/packages/ui/src/components/ImageLightbox.tsx
@@ -138,6 +138,7 @@ export default function ImageLightbox({
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
+
// Check if user is typing in the textarea or any input field
const target = event.target as HTMLElement;
const isTypingInInput = target instanceof HTMLTextAreaElement ||
diff --git a/packages/ui/src/components/ImageWizard/db.ts b/packages/ui/src/components/ImageWizard/db.ts
index c0767eae..3522cb2a 100644
--- a/packages/ui/src/components/ImageWizard/db.ts
+++ b/packages/ui/src/components/ImageWizard/db.ts
@@ -344,6 +344,7 @@ export const getUserOpenAIKey = async (userId: string): Promise =
* Get user secrets from user_secrets table (settings column)
*/
export const getUserSecrets = async (userId: string): Promise | null> => {
+ console.log('Fetching user secrets for user:', userId);
try {
const { data: secretData } = await supabase
.from('user_secrets')
diff --git a/packages/ui/src/components/ImageWizard/handlers/publishHandlers.ts b/packages/ui/src/components/ImageWizard/handlers/publishHandlers.ts
index 0d5be0ad..e003cb55 100644
--- a/packages/ui/src/components/ImageWizard/handlers/publishHandlers.ts
+++ b/packages/ui/src/components/ImageWizard/handlers/publishHandlers.ts
@@ -1,6 +1,7 @@
import { ImageFile } from '../types';
import { uploadImage } from '@/lib/uploadUtils';
import { supabase } from '@/integrations/supabase/client';
+import { createPost, updatePostDetails } from '@/lib/db';
import { toast } from 'sonner';
import { translate } from '@/i18n';
@@ -65,35 +66,26 @@ export const publishImage = async (
if (editingPostId) {
// Update existing post
- const { error: updateError } = await supabase
- .from('posts')
- .update({
- title: title,
- description: description,
- settings: settings || undefined,
- meta: meta || undefined,
- updated_at: new Date().toISOString()
- })
- .eq('id', editingPostId);
-
- if (updateError) throw updateError;
+ await updatePostDetails(editingPostId, {
+ title: title,
+ description: description,
+ settings: settings,
+ meta: meta
+ });
+ // updated_at is handled by server or trigger usually, or we can add it to api if needed but usually standard fields are auto.
+ // API currently doesn't key `updated_at` from body, but `db-posts.ts` doesn't set it explicitly either.
+ // Usually supabase sets it via trigger or we should set it.
+ // The old code set it: `updated_at: new Date().toISOString()`.
+ // The API `handleUpdatePost` does `update({ ... })`.
} else {
// Create new post
- const { data: postData, error: postError } = await supabase
- .from('posts')
- .insert({
- user_id: user.id,
- title: title,
- description: description,
- settings: settings || { visibility: 'public' },
- meta: meta || {},
- })
- .select()
- .single();
-
- if (postError) throw postError;
- if (!postData) throw new Error('Failed to create post');
- postId = postData.id;
+ const response = await createPost({
+ title,
+ description,
+ settings: settings,
+ meta: meta,
+ });
+ postId = response.post.id;
}
if (!postId) throw new Error('No post ID available');
@@ -307,18 +299,11 @@ export const quickPublishAsNew = async (
setIsPublishing(true);
try {
// 1. Create Post
- const { data: postData, error: postError } = await supabase
- .from('posts')
- .insert({
- user_id: user.id,
- title: imageTitle || 'Quick Publish',
- description: prompt.trim(), // Use prompt as description
- })
- .select()
- .single();
-
- if (postError) throw postError;
- const postId = postData.id;
+ const response = await createPost({
+ title: imageTitle || 'Quick Publish',
+ description: prompt.trim(),
+ });
+ const postId = response.post.id;
let file;
diff --git a/packages/ui/src/components/ListLayout.tsx b/packages/ui/src/components/ListLayout.tsx
index c2130600..d271fbdf 100644
--- a/packages/ui/src/components/ListLayout.tsx
+++ b/packages/ui/src/components/ListLayout.tsx
@@ -199,7 +199,7 @@ export const ListLayout = ({
+
{selectedId ? (
(() => {
const selectedPost = feedPosts.find((p: any) => p.id === selectedId);
@@ -223,7 +223,7 @@ export const ListLayout = ({
key={selectedId} // Force remount on ID change
postId={selectedId}
embedded
- className="h-full overflow-y-auto scrollbar-custom"
+ className="h-[inherit] overflow-y-auto scrollbar-custom"
/>
);
})()
diff --git a/packages/ui/src/components/MarkdownRenderer.tsx b/packages/ui/src/components/MarkdownRenderer.tsx
index 7660111f..5ad7c9f6 100644
--- a/packages/ui/src/components/MarkdownRenderer.tsx
+++ b/packages/ui/src/components/MarkdownRenderer.tsx
@@ -21,6 +21,7 @@ const SmartLightbox = React.lazy(() => import('../pages/Post/components/SmartLig
interface MarkdownRendererProps {
content: string;
className?: string;
+ variables?: Record
;
}
// Helper function to format URL display text (ported from previous implementation)
@@ -61,10 +62,18 @@ const slugify = (text: string) => {
.replace(/^-+|-+$/g, '');
};
-const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRendererProps) => {
+import { substitute } from '@/lib/variables';
+
+const MarkdownRenderer = React.memo(({ content, className = "", variables }: MarkdownRendererProps) => {
const containerRef = React.useRef(null);
const { user } = useAuth();
+ // Substitute variables in content if provided
+ const finalContent = useMemo(() => {
+ if (!variables || Object.keys(variables).length === 0) return content;
+ return substitute(false, content, variables);
+ }, [content, variables]);
+
// Lightbox state
const [lightboxOpen, setLightboxOpen] = useState(false);
const [currentImageIndex, setCurrentImageIndex] = useState(0);
@@ -76,34 +85,34 @@ const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRender
let match;
// We clone the regex to avoid stateful issues if reuse happens, though local var is fine
const localRegex = new RegExp(regex);
- while ((match = localRegex.exec(content)) !== null) {
+ while ((match = localRegex.exec(finalContent)) !== null) {
images.push({
alt: match[1],
src: match[2]
});
}
return images;
- }, [content]);
+ }, [finalContent]);
// Memoize content analysis (keep existing logic for simple hashtag views)
const contentAnalysis = useMemo(() => {
- const hasHashtags = /#[a-zA-Z0-9_]+/.test(content);
- const hasMarkdownLinks = /\[.*?\]\(.*?\)/.test(content);
- const hasMarkdownSyntax = /(\*\*|__|##?|###?|####?|#####?|######?|\*|\n\*|\n-|\n\d+\.)/.test(content);
+ const hasHashtags = /#[a-zA-Z0-9_]+/.test(finalContent);
+ const hasMarkdownLinks = /\[.*?\]\(.*?\)/.test(finalContent);
+ const hasMarkdownSyntax = /(\*\*|__|##?|###?|####?|#####?|######?|\*|\n\*|\n-|\n\d+\.)/.test(finalContent);
return {
hasHashtags,
hasMarkdownLinks,
hasMarkdownSyntax
};
- }, [content]);
+ }, [finalContent]);
// Apply syntax highlighting after render
useEffect(() => {
if (containerRef.current) {
Prism.highlightAllUnder(containerRef.current);
}
- }, [content]);
+ }, [finalContent]);
const handleImageClick = (src: string) => {
const index = allImages.findIndex(img => img.src === src);
@@ -145,7 +154,7 @@ const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRender
if (contentAnalysis.hasHashtags && !contentAnalysis.hasMarkdownLinks && !contentAnalysis.hasMarkdownSyntax) {
return (
- {content}
+ {finalContent}
);
}
@@ -238,7 +247,7 @@ const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRender
},
}}
>
- {content}
+ {finalContent}
diff --git a/packages/ui/src/components/admin/AdminSidebar.tsx b/packages/ui/src/components/admin/AdminSidebar.tsx
index ced90328..f83861e2 100644
--- a/packages/ui/src/components/admin/AdminSidebar.tsx
+++ b/packages/ui/src/components/admin/AdminSidebar.tsx
@@ -10,26 +10,27 @@ import {
useSidebar
} from "@/components/ui/sidebar";
import { T, translate } from "@/i18n";
-import { LayoutDashboard, Users, Server, Shield, AlertTriangle } from "lucide-react";
+import { LayoutDashboard, Users, Server, Shield, AlertTriangle, ChartBar } from "lucide-react";
-export type AdminActiveSection = 'dashboard' | 'users' | 'server' | 'bans' | 'violations';
+import { useLocation, useNavigate } from "react-router-dom";
-export const AdminSidebar = ({
- activeSection,
- onSectionChange
-}: {
- activeSection: AdminActiveSection;
- onSectionChange: (section: AdminActiveSection) => void;
-}) => {
+export type AdminActiveSection = 'dashboard' | 'users' | 'server' | 'bans' | 'violations' | 'analytics';
+
+export const AdminSidebar = () => {
const { state } = useSidebar();
+ const location = useLocation();
+ const navigate = useNavigate();
const isCollapsed = state === "collapsed";
+ const currentSection = location.pathname.split('/').pop() as AdminActiveSection || 'users';
+
const menuItems = [
{ id: 'dashboard' as AdminActiveSection, label: translate('Dashboard'), icon: LayoutDashboard },
{ id: 'users' as AdminActiveSection, label: translate('Users'), icon: Users },
{ id: 'server' as AdminActiveSection, label: translate('Server'), icon: Server },
{ id: 'bans' as AdminActiveSection, label: translate('Bans'), icon: Shield },
{ id: 'violations' as AdminActiveSection, label: translate('Violations'), icon: AlertTriangle },
+ { id: 'analytics' as AdminActiveSection, label: translate('Analytics'), icon: ChartBar },
];
return (
@@ -39,17 +40,22 @@ export const AdminSidebar = ({
Admin
- {menuItems.map((item) => (
-
- onSectionChange(item.id)}
- className={activeSection === item.id ? "bg-muted text-primary font-medium" : "hover:bg-muted/50"}
- >
-
- {!isCollapsed && {item.label}}
-
-
- ))}
+ {menuItems.map((item) => {
+ // Handle base /admin/ path matching 'users' if we redirect
+ const isActive = (item.id === 'users' && (location.pathname === '/admin' || location.pathname === '/admin/')) || location.pathname.includes(`/admin/${item.id}`);
+
+ return (
+
+ navigate(`/admin/${item.id}`)}
+ className={isActive ? "bg-muted text-primary font-medium" : "hover:bg-muted/50"}
+ >
+
+ {!isCollapsed && {item.label}}
+
+
+ )
+ })}
diff --git a/packages/ui/src/components/containers/ContainerPropertyPanel.tsx b/packages/ui/src/components/containers/ContainerPropertyPanel.tsx
new file mode 100644
index 00000000..0b10c6a0
--- /dev/null
+++ b/packages/ui/src/components/containers/ContainerPropertyPanel.tsx
@@ -0,0 +1,205 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+import { Switch } from '@/components/ui/switch';
+import { TailwindClassPicker } from '@/components/widgets/TailwindClassPicker';
+import { useLayout } from '@/modules/layout/LayoutContext';
+import { LayoutContainer } from '@/modules/layout/LayoutManager';
+import { Grid3X3 } from 'lucide-react';
+
+interface ContainerPropertyPanelProps {
+ pageId: string;
+ selectedContainerId: string;
+}
+
+export const ContainerPropertyPanel: React.FC
= ({
+ pageId,
+ selectedContainerId,
+}) => {
+ const { loadedPages, updatePageContainerSettings } = useLayout();
+ const layout = loadedPages.get(pageId);
+
+ // Find the container in layout
+ const findContainer = useCallback((containers: LayoutContainer[], id: string): LayoutContainer | null => {
+ for (const c of containers) {
+ if (c.id === id) return c;
+ const found = findContainer(c.children, id);
+ if (found) return found;
+ }
+ return null;
+ }, []);
+
+ const container = layout ? findContainer(layout.containers, selectedContainerId) : null;
+
+ const [settings, setSettings] = useState>({});
+
+ // Sync settings when container changes
+ useEffect(() => {
+ if (container?.settings) {
+ setSettings({ ...container.settings });
+ } else {
+ setSettings({});
+ }
+ }, [container?.id, container?.settings]);
+
+ const updateSetting = useCallback(>(
+ key: K,
+ value: NonNullable[K]
+ ) => {
+ const newSettings = { ...settings, [key]: value };
+ setSettings(newSettings);
+ updatePageContainerSettings(pageId, selectedContainerId, { [key]: value });
+ }, [settings, pageId, selectedContainerId, updatePageContainerSettings]);
+
+ if (!container) {
+ return (
+
+ Container not found
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
Container Properties
+
{selectedContainerId}
+
+
+
+ {/* General Properties */}
+
+
+
+ {/* Enabled Toggle */}
+
+
+
+
Turn off to hide this container.
+
+
updateSetting('enabled', checked)}
+ className="scale-90"
+ />
+
+
+ {/* Columns Info */}
+
+
+
{container.columns}
+
+
+ {/* Gap Info */}
+
+
+
{container.gap}px
+
+
+
+ {/* CSS Class */}
+
+
+
updateSetting('customClassName', newValue)}
+ placeholder={`container-${selectedContainerId.split('-').pop()}`}
+ className="w-full"
+ />
+
+ Custom CSS class applied to this container.
+
+
+
+ {/* Title Settings */}
+
+
+
+
+
+
+
Display a title bar above the container.
+
+
updateSetting('showTitle', checked)}
+ className="scale-90"
+ />
+
+
+ {settings.showTitle && (
+
+
+ updateSetting('title', e.target.value)}
+ placeholder={`Container (${container.columns} col${container.columns !== 1 ? 's' : ''})`}
+ className="w-full h-8 text-sm"
+ />
+
+ )}
+
+
+ {/* Collapsible Settings */}
+
+
+
+
+
+
+
Allow users to collapse/expand.
+
+
{
+ updateSetting('collapsible', checked);
+ if (!checked) {
+ updateSetting('collapsed', false);
+ }
+ }}
+ className="scale-90"
+ />
+
+
+ {settings.collapsible && (
+
+
+
+
Start with container collapsed.
+
+
updateSetting('collapsed', checked)}
+ className="scale-90"
+ />
+
+ )}
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/lazy-editors/AIGenerationPlugin.tsx b/packages/ui/src/components/lazy-editors/AIGenerationPlugin.tsx
index 19821174..141a0438 100644
--- a/packages/ui/src/components/lazy-editors/AIGenerationPlugin.tsx
+++ b/packages/ui/src/components/lazy-editors/AIGenerationPlugin.tsx
@@ -32,6 +32,7 @@ const useProviderApiKey = () => {
}
try {
+ console.log('Fetching API key for user:', user.id, 'provider:', provider);
const { data: userProvider, error } = await supabase
.from('provider_configs')
.select('settings')
diff --git a/packages/ui/src/components/lazy-editors/AIImagePromptPopup.tsx b/packages/ui/src/components/lazy-editors/AIImagePromptPopup.tsx
index b1aa5e45..db245d67 100644
--- a/packages/ui/src/components/lazy-editors/AIImagePromptPopup.tsx
+++ b/packages/ui/src/components/lazy-editors/AIImagePromptPopup.tsx
@@ -182,6 +182,7 @@ export const AIImagePromptPopup: React.FC = ({
};
const handleKeyDown = (e: React.KeyboardEvent) => {
+ console.log(e.key);
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleGenerate();
@@ -202,9 +203,6 @@ export const AIImagePromptPopup: React.FC = ({
}
};
- console.log(model);
-
-
// Helper to check if model supports advanced options
// All Google models in our router support these parameters
const isGoogleModel = model.startsWith('google/');
diff --git a/packages/ui/src/components/lazy-editors/MDXEditorInternal.tsx b/packages/ui/src/components/lazy-editors/MDXEditorInternal.tsx
index e94defb4..8bf6cdd7 100644
--- a/packages/ui/src/components/lazy-editors/MDXEditorInternal.tsx
+++ b/packages/ui/src/components/lazy-editors/MDXEditorInternal.tsx
@@ -111,49 +111,43 @@ export default function MDXEditorInternal({
}
}, [theme]);
+ const allPlugins = React.useMemo(() => [
+ headingsPlugin(),
+ linkPlugin(),
+ linkDialogPlugin(),
+ imagePlugin(),
+ quotePlugin(),
+ thematicBreakPlugin(),
+ listsPlugin(),
+ tablePlugin(),
+ codeBlockPlugin({ defaultCodeBlockLanguage: 'js' }),
+ codeMirrorPlugin({ codeBlockLanguages: { js: 'JavaScript', css: 'CSS', 'json': 'JSON', '': 'Text' } }),
+ sandpackPlugin({ sandpackConfig: simpleSandpackConfig }),
+ slashCommandPlugin({ onRequestImage }),
+ aiGenerationPlugin(),
+ aiImageGenerationPlugin(),
+ toolbarPlugin({
+ toolbarClassName: 'mdx-toolbar',
+ toolbarContents: () => (
+ <>
+
+
+
+
+
+
+ >
+ )
+ })
+ ], [onRequestImage]);
+
return (
(
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- )
- })
- ]}
+ plugins={allPlugins}
placeholder={placeholder}
contentEditableClassName="prose prose-sm max-w-none dark:prose-invert"
/>
diff --git a/packages/ui/src/components/lazy-editors/SlashCommandPlugin.tsx b/packages/ui/src/components/lazy-editors/SlashCommandPlugin.tsx
index ae893631..db8096a7 100644
--- a/packages/ui/src/components/lazy-editors/SlashCommandPlugin.tsx
+++ b/packages/ui/src/components/lazy-editors/SlashCommandPlugin.tsx
@@ -198,7 +198,7 @@ function SlashCommandMenu({
}
return anchorElementRef.current && ReactDOM.createPortal(
-
+
{options.map((option, i) => {
// Filter manually if the plugin doesn't do it for us (it usually does but we want custom fuzzy logic maybe)
diff --git a/packages/ui/src/components/ui/card.tsx b/packages/ui/src/components/ui/card.tsx
index 26cca74a..fd6f80a5 100644
--- a/packages/ui/src/components/ui/card.tsx
+++ b/packages/ui/src/components/ui/card.tsx
@@ -3,7 +3,7 @@ import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef
>(({ className, ...props }, ref) => (
-
+
));
Card.displayName = "Card";
diff --git a/packages/ui/src/components/variables/VariableBuilder.tsx b/packages/ui/src/components/variables/VariableBuilder.tsx
index 8d7815a7..4ac1862f 100644
--- a/packages/ui/src/components/variables/VariableBuilder.tsx
+++ b/packages/ui/src/components/variables/VariableBuilder.tsx
@@ -4,7 +4,10 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
-import { Type as TypeIcon, Hash, ToggleLeft, Trash2, Lock, Plus } from 'lucide-react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, DialogDescription } from "@/components/ui/dialog";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
+import { Type as TypeIcon, Hash, ToggleLeft, Trash2, Lock, Plus, Import, Download, ChevronDown, Copy, FileJson } from 'lucide-react';
+import { toast } from "sonner";
export interface VariableElement {
id: string;
@@ -117,8 +120,8 @@ export const VariableBuilder = ({
onSave,
isSaving
}: {
- initialData: Record,
- onSave: (data: Record) => void,
+ initialData: Record,
+ onSave: (data: Record) => void,
isSaving?: boolean
}) => {
const [elements, setElements] = useState([]);
@@ -127,11 +130,20 @@ export const VariableBuilder = ({
// Load initial data
useEffect(() => {
- const initialElements: VariableElement[] = Object.entries(initialData).map(([key, value]) => {
- // Infer type
+ const initialElements: VariableElement[] = Object.entries(initialData || {}).map(([key, rawValue]) => {
+ const value = String(rawValue);
+ // Infer type from raw value if possible, otherwise fallback to string string inference
let type: VariableElement['type'] = 'string';
- if (value === 'true' || value === 'false') type = 'boolean';
- else if (!isNaN(Number(value)) && value.trim() !== '') type = 'number';
+
+ if (typeof rawValue === 'boolean') {
+ type = 'boolean';
+ } else if (typeof rawValue === 'number') {
+ type = 'number';
+ } else {
+ if (value === 'true' || value === 'false') type = 'boolean';
+ else if (!isNaN(Number(value)) && value.trim() !== '') type = 'number';
+ }
+
// Simple heuristic for secrets: key contains SECRET, KEY, TOKEN, PASSWORD
if (/SECRET|KEY|TOKEN|PASSWORD|PASS/i.test(key)) type = 'secret';
@@ -165,10 +177,19 @@ export const VariableBuilder = ({
};
const handleSave = () => {
- const result: Record = {};
+ const result: Record = {};
elements.forEach(el => {
if (el.key) {
- result[el.key] = el.value;
+ let val: any = el.value;
+ if (el.type === 'boolean') {
+ val = el.value === 'true';
+ } else if (el.type === 'number') {
+ const num = Number(el.value);
+ if (!isNaN(num) && el.value.trim() !== '') {
+ val = num;
+ }
+ }
+ result[el.key] = val;
}
});
onSave(result);
@@ -231,34 +252,182 @@ const VariableBuilderContent = ({
updateSelectedElement: (u: Partial) => void,
onAddVariable: (type: VariableElement['type']) => void
}) => {
+ const [importJson, setImportJson] = useState('');
+ const [isImportOpen, setIsImportOpen] = useState(false);
+
const filteredElements = elements.filter(e =>
e.key.toLowerCase().includes(searchTerm.toLowerCase()) ||
e.value.toLowerCase().includes(searchTerm.toLowerCase())
);
+ const handleImport = () => {
+ try {
+ const parsed = JSON.parse(importJson);
+ if (typeof parsed !== 'object' || parsed === null) {
+ throw new Error("Invalid JSON: must be an object");
+ }
+
+ const newElements: VariableElement[] = [];
+ // Keep existing elements that are not overwritten
+ const existingKeys = new Set(Object.keys(parsed));
+
+ // Create new elements from JSON
+ Object.entries(parsed).forEach(([key, value]) => {
+ let type: VariableElement['type'] = 'string';
+
+ if (typeof value === 'boolean') {
+ type = 'boolean';
+ } else if (typeof value === 'number') {
+ type = 'number';
+ } else {
+ const s = String(value);
+ if (s === 'true' || s === 'false') type = 'boolean';
+ else if (!isNaN(Number(s)) && s.trim() !== '') type = 'number';
+ }
+
+ if (/SECRET|KEY|TOKEN|PASSWORD|PASS/i.test(key)) type = 'secret';
+
+ const strValue = String(value);
+
+ newElements.push({
+ id: `var-${key}-${Date.now()}`, // Ensure unique ID
+ key,
+ value: strValue,
+ type
+ });
+ });
+
+ // Merge: append new elements to existing ones, or replace if we want to be stricter?
+ // The prompt says "import", usually implies adding/merging.
+ // Let's append, but maybe warn about duplicates?
+ // Actually, let's just add them. The duplicate checker will handle conflicts visually.
+
+ setElements(prev => [...prev, ...newElements]);
+ setImportJson('');
+ setIsImportOpen(false);
+ toast.success(`Imported ${newElements.length} variables`);
+ } catch (error) {
+ console.error("Import failed", error);
+ toast.error("Invalid JSON format");
+ }
+ };
+
+ const handleExportCopy = () => {
+ const data: Record = {};
+ elements.forEach(el => {
+ if (el.key) {
+ let val: any = el.value;
+ if (el.type === 'boolean') val = el.value === 'true';
+ else if (el.type === 'number') {
+ const num = Number(el.value);
+ if (!isNaN(num) && el.value.trim() !== '') val = num;
+ }
+ data[el.key] = val;
+ }
+ });
+ navigator.clipboard.writeText(JSON.stringify(data, null, 2));
+ toast.success("Copied to clipboard");
+ };
+
+ const handleExportFile = () => {
+ const data: Record = {};
+ elements.forEach(el => {
+ if (el.key) {
+ let val: any = el.value;
+ if (el.type === 'boolean') val = el.value === 'true';
+ else if (el.type === 'number') {
+ const num = Number(el.value);
+ if (!isNaN(num) && el.value.trim() !== '') val = num;
+ }
+ data[el.key] = val;
+ }
+ });
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'variables.json';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ toast.success("Download started");
+ };
+
return (
{/* Top Toolbar: Palette & Search & Actions */}
-
-
+
+
+
Variables
+
+
+
setSearchTerm(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+
+ Copy to Clipboard
+
+
+ Download JSON
+
+
+
+
+
+
+
+
+
+
+
+
Add:
onAddVariable('string')} />
onAddVariable('number')} />
onAddVariable('boolean')} />
onAddVariable('secret')} />
-
-
- setSearchTerm(e.target.value)}
- />
-
-
diff --git a/packages/ui/src/components/variables/VariablesEditor.tsx b/packages/ui/src/components/variables/VariablesEditor.tsx
index acc81f26..b3dbbd04 100644
--- a/packages/ui/src/components/variables/VariablesEditor.tsx
+++ b/packages/ui/src/components/variables/VariablesEditor.tsx
@@ -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
>;
- onSave: (data: Record) => Promise;
+ onLoad: () => Promise>;
+ onSave: (data: Record) => Promise;
}
export const VariablesEditor: React.FC = ({
onLoad,
onSave
}) => {
- const [variables, setVariables] = useState>({});
+ const [variables, setVariables] = useState>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -33,7 +33,7 @@ export const VariablesEditor: React.FC = ({
loadVariables();
}, [loadVariables]);
- const handleSave = async (newVariables: Record) => {
+ const handleSave = async (newVariables: Record) => {
setSaving(true);
try {
await onSave(newVariables);
diff --git a/packages/ui/src/components/widgets/CategoryManager.tsx b/packages/ui/src/components/widgets/CategoryManager.tsx
index c753b5d7..e6983894 100644
--- a/packages/ui/src/components/widgets/CategoryManager.tsx
+++ b/packages/ui/src/components/widgets/CategoryManager.tsx
@@ -486,7 +486,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
{editingCategory && (
{
- return (editingCategory.meta?.variables as Record) || {};
+ return (editingCategory.meta?.variables as Record) || {};
}}
onSave={async (newVars) => {
setEditingCategory({
diff --git a/packages/ui/src/components/widgets/HtmlWidget.tsx b/packages/ui/src/components/widgets/HtmlWidget.tsx
index 575bb873..01b58d98 100644
--- a/packages/ui/src/components/widgets/HtmlWidget.tsx
+++ b/packages/ui/src/components/widgets/HtmlWidget.tsx
@@ -262,15 +262,15 @@ export const HtmlWidget: React.FC = ({
if (loading) {
- return ;
+ return ;
}
if (error) {
- return {error}
;
+ return {error}
;
}
if (!processedContent) {
- return No content
;
+ return No content
;
}
return (
diff --git a/packages/ui/src/components/widgets/LayoutContainerWidget.tsx b/packages/ui/src/components/widgets/LayoutContainerWidget.tsx
index 133c5878..493a26d6 100644
--- a/packages/ui/src/components/widgets/LayoutContainerWidget.tsx
+++ b/packages/ui/src/components/widgets/LayoutContainerWidget.tsx
@@ -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;
}
const LayoutContainerWidget: React.FC = ({
@@ -18,7 +26,15 @@ const LayoutContainerWidget: React.FC = ({
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 = ({
}
}, [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 Initializing nested layout...
;
}
@@ -48,6 +81,12 @@ const LayoutContainerWidget: React.FC = ({
isEditMode={isEditMode}
showControls={showControls}
className="p-0"
+ initialLayout={nestedLayoutData}
+ selectedWidgetId={selectedWidgetId}
+ onSelectWidget={onSelectWidget}
+ editingWidgetId={editingWidgetId}
+ onEditWidget={onEditWidget}
+ contextVariables={contextVariables}
/>
);
};
diff --git a/packages/ui/src/components/widgets/MarkdownTextWidget-Edit.tsx b/packages/ui/src/components/widgets/MarkdownTextWidget-Edit.tsx
new file mode 100644
index 00000000..101ca0d7
--- /dev/null
+++ b/packages/ui/src/components/widgets/MarkdownTextWidget-Edit.tsx
@@ -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) => void;
+ contextVariables?: Record;
+ onClose: () => void;
+}
+
+const MarkdownTextWidgetEdit: React.FC = ({
+ content: propContent = '',
+ placeholder: propPlaceholder = 'Enter your text here...',
+ templates: propTemplates = [],
+ onPropsChange,
+ contextVariables,
+ onClose
+}) => {
+ const { user } = useAuth();
+ const [content, setContent] = useState(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([]);
+ 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('');
+ 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(null);
+ const audioChunksRef = useRef([]);
+ const abortControllerRef = useRef(null);
+ const editorRef = useRef(null);
+ const insertTransactionRef = useRef<{ text: string, onComplete: () => void } | null>(null);
+ const lastSavedContentRef = useRef(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(null);
+ const lastSubmittedContentRef = useRef(propContent);
+ const prevPropContentRef = useRef(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 => {
+ 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 => {
+ 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 (
+
+
+
+
+ Fullscreen Editor
+
+
+
+
+
+
+
+
+
+ setIsLeftPaneCollapsed(true)}
+ onExpand={() => setIsLeftPaneCollapsed(false)}
+ className={isLeftPaneCollapsed ? "hidden" : ""}
+ >
+
+
+
+
+
+
+ AI Generator
+
+
+
+
+
+
+
{
+ 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}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ // Normal Card layout
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ AI Assistant
}
+ 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"
+ >
+ {
+ 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}
+ />
+
+
+
+
+ );
+};
+
+export default MarkdownTextWidgetEdit;
diff --git a/packages/ui/src/components/widgets/MarkdownTextWidget.tsx b/packages/ui/src/components/widgets/MarkdownTextWidget.tsx
index 03a4215f..41bd95f2 100644
--- a/packages/ui/src/components/widgets/MarkdownTextWidget.tsx
+++ b/packages/ui/src/components/widgets/MarkdownTextWidget.tsx
@@ -1,37 +1,15 @@
-import React, { useState, useEffect, useRef, useCallback } from 'react';
-import { T, translate } from '@/i18n';
+import React, { useState, Suspense } from 'react';
+import { T } from '@/i18n';
import {
FileText,
- Wand2,
Type,
- Filter,
- Maximize,
- Minimize,
- 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 MarkdownRenderer from '@/components/MarkdownRenderer';
import { Button } from '@/components/ui/button';
-import { Badge } from '@/components/ui/badge';
-import { generateText, transcribeAudio, runTools } from '@/lib/openai';
-import OpenAI from 'openai';
-import { supabase } from '@/integrations/supabase/client';
-import { useAuth } from '@/hooks/useAuth';
-import { createMarkdownToolPreset } from '@/lib/markdownImageTools';
-import { toast } from 'sonner';
import { Card, CardContent } from '@/components/ui/card';
-import { FilterPanel } from '@/components/filters/FilterPanel';
-import AITextGenerator from '@/components/AITextGenerator';
-import { getUserSecrets } from '@/components/ImageWizard/db';
-import * as db from '@/lib/db';
+
+// Lazy load the editor component
+const MarkdownTextWidgetEdit = React.lazy(() => import('./MarkdownTextWidget-Edit'));
interface MarkdownTextWidgetProps {
isEditMode?: boolean;
@@ -41,6 +19,7 @@ interface MarkdownTextWidgetProps {
// Widget instance management
widgetInstanceId?: string;
onPropsChange?: (props: Record
) => void;
+ contextVariables?: Record;
}
const MarkdownTextWidget: React.FC = ({
@@ -48,638 +27,17 @@ const MarkdownTextWidget: React.FC = ({
content: propContent = '',
placeholder: propPlaceholder = 'Enter your text here...',
templates: propTemplates = [],
- onPropsChange
+ onPropsChange,
+ contextVariables
}) => {
- const { user } = useAuth();
- const [content, setContent] = useState(propContent);
- const [templates, setTemplates] = useState(propTemplates);
const [isEditing, setIsEditing] = useState(false);
- const [showPrompt, setShowPrompt] = useState(true); // Always open by default
- const [showFilters, setShowFilters] = useState(false); // Filter panel visibility
- const [isLeftPaneCollapsed, setIsLeftPaneCollapsed] = useState(false); // Left pane collapse state
- const [activeLeftTab, setActiveLeftTab] = useState('ai'); // 'ai' or 'filters'
- const [isFullscreen, setIsFullscreen] = useState(false); // Fullscreen mode
- const [prompt, setPrompt] = useState('');
- const [promptHistory, setPromptHistory] = useState([]);
- 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('');
- 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(null);
- const audioChunksRef = useRef([]);
- const abortControllerRef = useRef(null);
- const editorRef = useRef(null);
- const insertTransactionRef = useRef<{ text: string, onComplete: () => void } | null>(null);
- const lastSavedContentRef = useRef(propContent);
- const isInternalUpdate = useRef(false);
- const lastSelectionRef = useRef<{ isAtStart: boolean } | null>(null);
-
- // Load AI Text Generator settings from profile
- useEffect(() => {
- const loadSettings = async () => {
- // Only load settings if user exists AND we are in edit mode
- // This prevents 100s of requests when just viewing a page with many widgets
- if (!user || !isEditMode) {
- setSettingsLoaded(true);
- return;
- }
-
- try {
- // Use the centralized cached fetcher instead of direct call
- // This handles deduplication if multiple widgets load at once
- 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, isEditMode]);
-
- // Save AI Text Generator settings to profile (debounced)
- 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]);
-
- // Sync content from props (only external changes)
- useEffect(() => {
- if (!isInternalUpdate.current && propContent !== content) {
- setContent(propContent);
- lastSavedContentRef.current = propContent;
- }
- isInternalUpdate.current = false;
- }, [propContent]);
-
- // Sync templates from props
- useEffect(() => {
- const templatesChanged = JSON.stringify(propTemplates) !== JSON.stringify(templates);
- if (templatesChanged && !isInternalUpdate.current) {
- setTemplates(propTemplates || []);
- }
- }, [propTemplates]);
-
- const handleContentChange = useCallback((newContent: string) => {
- // Check if an insert transaction is pending and if it's now complete
- if (insertTransactionRef.current && newContent.includes(insertTransactionRef.current.text)) {
- insertTransactionRef.current.onComplete();
- insertTransactionRef.current = null; // Clear the transaction
- }
-
- // Only update if content actually changed
- if (newContent === lastSavedContentRef.current) return;
-
- setContent(newContent);
- lastSavedContentRef.current = newContent;
- isInternalUpdate.current = true;
-
- onPropsChange?.({
- content: newContent,
- placeholder: propPlaceholder,
- templates
- });
- }, [onPropsChange, propPlaceholder, templates]);
-
- const handleSave = useCallback(() => {
- // Explicitly save current content
- isInternalUpdate.current = true;
- onPropsChange?.({
- content,
- placeholder: propPlaceholder,
- templates
- });
- }, [content, onPropsChange, propPlaceholder, templates]);
-
- const addToHistory = (text: string) => {
- if (text.trim()) {
- setPromptHistory(prev => [text, ...prev.slice(0, 49)]); // Keep last 50
- setHistoryIndex(-1);
- }
- };
-
- // Get API key for provider from user configuration
- const getProviderApiKey = async (provider: string): Promise => {
- if (!user) return null;
-
- // Use centralized user secrets for OpenAI
- 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) {
- // Only warn if checks in user_secrets also failed or if it's a different provider
- 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;
- }
- };
-
- // Provider-aware text generation
- const generateWithProvider = async (
- prompt: string,
- provider: string,
- model: string,
- signal?: AbortSignal,
- webSearch?: boolean
- ): Promise => {
- 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;
- }
-
- // Capture selection state BEFORE the async call
- const isAtStart = lastSelectionRef.current?.isAtStart === true;
-
- abortControllerRef.current = new AbortController();
- const signal = abortControllerRef.current.signal;
-
- setIsGenerating(true);
- addToHistory(prompt);
-
- try {
- let generatedText: string | null = null;
-
- // Build context-aware prompt based on contextMode setting
- let contextPrompt = '';
-
- switch (contextMode) {
- case 'clear':
- contextPrompt = `USER REQUEST: ${prompt}\n\n`;
- break;
-
- case 'selection':
- if (selectedText && selectedText.trim()) {
- contextPrompt = `CONTEXT - Selected Text:\n\`\`\`\n${selectedText}\n\`\`\`\n\nUSER REQUEST: ${prompt}\n\n`;
- } else {
- contextPrompt = `USER REQUEST: ${prompt}\n\n`;
- }
- break;
-
- case 'all':
- if (content && content.trim()) {
- contextPrompt = `CONTEXT - Existing Content:\n\`\`\`\n${content}\n\`\`\`\n\nUSER REQUEST: ${prompt}\n\n`;
- } else {
- contextPrompt = `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) {
- // Clean up any code block wrappers if the LLM ignored our instruction
- 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 newContent = content ? `${content}\n\n---\n\n${generatedText}` : generatedText;
- handleContentChange(newContent);
- 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`);
- // We return here because handleContentChange will finalize the state update
- setPrompt('');
- return;
- }
- // Fallback if editor methods are not available
- const newContent = content && content.trim() ? `${content}\n${generatedText}` : generatedText;
- handleContentChange(newContent);
- 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 {
- // Create optimization prompt
- 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) {
- // Stop recording
- if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
- mediaRecorderRef.current.stop();
- }
- return;
- }
-
- // Start recording
- 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);
- // Stop all tracks
- 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; // Mark as internal change
- lastSavedContentRef.current = content; // Track 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; // Mark as internal change
- lastSavedContentRef.current = content; // Track 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'));
- }, []);
-
- // Handle filtered content from the filter panel
- const handleFilteredContent = useCallback((filteredContent: string) => {
- // Update content state directly
- setContent(filteredContent);
- lastSavedContentRef.current = filteredContent;
- isInternalUpdate.current = true;
-
- // Save to props
- onPropsChange?.({
- content: filteredContent,
- placeholder: propPlaceholder,
- templates
- });
-
- toast.success(translate('Content filtered successfully!'));
- }, [onPropsChange, propPlaceholder, templates]);
-
- // Handle text selection changes from the editor
- const handleSelectionChange = useCallback((newSelectedText: string) => {
- setSelectedText(newSelectedText);
-
- // Auto-switch to 'replace' mode when text is selected
- if (newSelectedText && newSelectedText.trim()) {
- if (applicationMode !== 'replace') {
- setApplicationMode('replace');
- }
- }
-
- // Simple check to see if cursor is at the start of the document
- 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 };
-
- // Show a helpful toast when text is selected and filters are not visible
- if (newSelectedText && !showFilters && !isFullscreen) {
- toast.info(translate('Text selected! Use AI tools to work with selection.'));
- }
- }, [showFilters, isFullscreen, applicationMode]);
-
- // Handle fullscreen toggle
- const handleFullscreenToggle = useCallback(() => {
- setIsFullscreen(prev => {
- const newValue = !prev;
- // Toggle body overflow to prevent background scrolling
- if (newValue) {
- document.body.style.setProperty('overflow', 'hidden', 'important');
- } else {
- document.body.style.removeProperty('overflow');
- }
- return newValue;
- });
- }, []);
-
- // Cleanup: restore body overflow on unmount
- useEffect(() => {
- return () => {
- if (isFullscreen) {
- document.body.style.removeProperty('overflow');
- }
- };
- }, [isFullscreen]);
// If in layout edit mode and not editing, show preview
if (isEditMode && !isEditing) {
return (
- {content ? (
+ {propContent ? (
@@ -696,7 +54,7 @@ Return ONLY the optimized prompt, no explanations or additional text.`;
-
+
) : (
@@ -722,284 +80,33 @@ Return ONLY the optimized prompt, no explanations or additional text.`;
// Editing mode (layout edit mode + editing state)
if (isEditMode && isEditing) {
- // Fullscreen layout - all components in one unified view
- if (isFullscreen) {
- return (
-
- {/* Fullscreen Header */}
-
-
-
- Fullscreen Editor
-
-
-
-
-
-
-
- {/* Fullscreen Content - Resizable Layout */}
-
- {/* Left Pane: AI & Filters */}
- setIsLeftPaneCollapsed(true)}
- onExpand={() => setIsLeftPaneCollapsed(false)}
- className={isLeftPaneCollapsed ? "hidden" : ""}
- >
-
-
-
-
-
-
- AI Generator
-
-
-
- Filters
-
-
-
-
-
-
-
-
{
- 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}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Right Pane: Editor */}
-
-
-
-
-
-
-
- );
- }
-
- // Normal layout - existing functionality
return (
-
-
- {/* Header */}
-
-
-
-
Text Editor
+
+
+
-
-
-
-
-
-
-
-
- {/* Content Editor */}
-
-
- {/* Filter Panel - Between Editor and AI Prompt */}
- {showFilters && (
-
-
-
- )}
-
- {/* AI Prompt Section */}
- {showPrompt && (
-
-
{
- 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}
- />
-
- )}
-
-
+
Loading Editor...
+
+
+ }>
+
setIsEditing(false)}
+ />
+
);
}
// View mode (normal page view)
- if (!content) {
+ if (!propContent) {
return (
@@ -1015,7 +122,7 @@ Return ONLY the optimized prompt, no explanations or additional text.`;
return (
-
+
);
diff --git a/packages/ui/src/components/widgets/PhotoCardWidget.tsx b/packages/ui/src/components/widgets/PhotoCardWidget.tsx
index 0f37cbbf..9feab5ff 100644
--- a/packages/ui/src/components/widgets/PhotoCardWidget.tsx
+++ b/packages/ui/src/components/widgets/PhotoCardWidget.tsx
@@ -117,6 +117,7 @@ const PhotoCardWidget: React.FC = ({
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')
diff --git a/packages/ui/src/components/widgets/TabsWidget.tsx b/packages/ui/src/components/widgets/TabsWidget.tsx
index 55683fc5..a9d8883a 100644
--- a/packages/ui/src/components/widgets/TabsWidget.tsx
+++ b/packages/ui/src/components/widgets/TabsWidget.tsx
@@ -30,6 +30,7 @@ interface TabsWidgetProps {
onSelectContainer?: (containerId: string | null, pageId?: string) => void;
editingWidgetId?: string | null;
onEditWidget?: (id: string | null) => void;
+ contextVariables?: Record;
}
const TabsWidget: React.FC = ({
@@ -48,6 +49,7 @@ const TabsWidget: React.FC = ({
onSelectContainer,
editingWidgetId,
onEditWidget,
+ contextVariables,
}) => {
const [currentTabId, setCurrentTabId] = useState(activeTabId);
const { loadedPages, addPageContainer } = useLayout();
@@ -194,7 +196,7 @@ const TabsWidget: React.FC = ({
{/* Content Area */}
-
+
{currentTab ? (
= ({
onSelectContainer={onSelectContainer}
editingWidgetId={editingWidgetId}
onEditWidget={onEditWidget}
+ contextVariables={contextVariables}
/>
) : (
diff --git a/packages/ui/src/components/widgets/WidgetMovementControls.tsx b/packages/ui/src/components/widgets/WidgetMovementControls.tsx
index 4d406685..bd3aa03e 100644
--- a/packages/ui/src/components/widgets/WidgetMovementControls.tsx
+++ b/packages/ui/src/components/widgets/WidgetMovementControls.tsx
@@ -20,12 +20,12 @@ export const WidgetMovementControls: React.FC
= ({
className = ''
}) => {
return (
-
+
{/* Cross/Plus pattern with connecting lines */}
-
+
{/* Vertical line */}
-
+
{/* Horizontal line */}
diff --git a/packages/ui/src/components/widgets/WidgetPropertyPanel.tsx b/packages/ui/src/components/widgets/WidgetPropertyPanel.tsx
index 59a60264..31cd14d7 100644
--- a/packages/ui/src/components/widgets/WidgetPropertyPanel.tsx
+++ b/packages/ui/src/components/widgets/WidgetPropertyPanel.tsx
@@ -70,7 +70,7 @@ export const WidgetPropertyPanel: React.FC
= ({
};
return (
-
+
{widgetDefinition.metadata.name}
diff --git a/packages/ui/src/hooks/useAuth.tsx b/packages/ui/src/hooks/useAuth.tsx
index 910b115b..35315a5a 100644
--- a/packages/ui/src/hooks/useAuth.tsx
+++ b/packages/ui/src/hooks/useAuth.tsx
@@ -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(null);
const [roles, setRoles] = useState([]);
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}/`;
diff --git a/packages/ui/src/hooks/useImageDrop.ts b/packages/ui/src/hooks/useImageDrop.ts
new file mode 100644
index 00000000..d9fba211
--- /dev/null
+++ b/packages/ui/src/hooks/useImageDrop.ts
@@ -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
+ }
+ };
+};
diff --git a/packages/ui/src/hooks/usePageGenerator.ts b/packages/ui/src/hooks/usePageGenerator.ts
index 4151168c..b31b4072 100644
--- a/packages/ui/src/hooks/usePageGenerator.ts
+++ b/packages/ui/src/hooks/usePageGenerator.ts
@@ -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';
diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css
index 7253f870..7261f3dd 100644
--- a/packages/ui/src/index.css
+++ b/packages/ui/src/index.css
@@ -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 {
diff --git a/packages/ui/src/lib/db.ts b/packages/ui/src/lib/db.ts
index 44f16609..4cd8b06d 100644
--- a/packages/ui/src/lib/db.ts
+++ b/packages/ui/src/lib/db.ts
@@ -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 {
+ // 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 => {
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 | null> => {
+export const getUserVariables = async (userId: string): Promise | 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;
- return (settings.variables as Record) || {};
+ return (settings.variables as Record) || {};
} catch (error) {
console.error('Error fetching user variables:', error);
return null;
@@ -1103,7 +1226,8 @@ export const getUserVariables = async (userId: string): Promise): Promise => {
+export const updateUserVariables = async (userId: string, variables: Record): Promise => {
+ console.log('Updating user variables for user:', userId);
try {
// Check if record exists
const { data: existing } = await defaultSupabase
diff --git a/packages/ui/src/lib/page-variables.ts b/packages/ui/src/lib/page-variables.ts
new file mode 100644
index 00000000..d3e1480b
--- /dev/null
+++ b/packages/ui/src/lib/page-variables.ts
@@ -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 = {}): Record => {
+ const globalVariables: Record = {};
+
+ // 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;
+};
diff --git a/packages/ui/src/lib/uploadUtils.ts b/packages/ui/src/lib/uploadUtils.ts
index 26bc03ad..ab28fb03 100644
--- a/packages/ui/src/lib/uploadUtils.ts
+++ b/packages/ui/src/lib/uploadUtils.ts
@@ -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);
+};
diff --git a/packages/ui/src/modules/layout/GenericCanvas.tsx b/packages/ui/src/modules/layout/GenericCanvas.tsx
index 0f93721c..82f20ff7 100644
--- a/packages/ui/src/modules/layout/GenericCanvas.tsx
+++ b/packages/ui/src/modules/layout/GenericCanvas.tsx
@@ -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 && (
+
+ {onSave && (
+
+ )}
+
+ )}*/
interface GenericCanvasProps {
pageId: string;
@@ -14,6 +65,7 @@ interface GenericCanvasProps {
showControls?: boolean;
className?: string;
selectedWidgetId?: string | null;
+ selectedWidgetIds?: Set;
onSelectWidget?: (widgetId: string, pageId?: string) => void;
selectedContainerId?: string | null;
onSelectContainer?: (containerId: string | null, pageId?: string) => void;
@@ -32,6 +84,7 @@ const GenericCanvasComponent: React.FC = ({
showControls = true,
className = '',
selectedWidgetId,
+ selectedWidgetIds,
onSelectWidget,
selectedContainerId: propSelectedContainerId,
onSelectContainer: propOnSelectContainer,
@@ -191,6 +244,37 @@ const GenericCanvasComponent: React.FC = ({
}
};
+ // 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 = ({
{/* Header with Controls */}
{showControls && (
-
+
{layout.name}
-
{/* Edit Mode Controls */}
- {isEditMode && (
-
-
- {onSave && (
-
- )}
-
- )}
{/* Layout Info */}
@@ -312,6 +334,7 @@ const GenericCanvasComponent: React.FC
= ({
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 = ({
console.error('Failed to remove container:', error);
}
}}
+ onFilesDrop={(files, targetColumn) => handleFilesDrop(files, container.id, targetColumn)}
/>
))}
diff --git a/packages/ui/src/modules/layout/LayoutContainer.tsx b/packages/ui/src/modules/layout/LayoutContainer.tsx
index 119242cd..28357f68 100644
--- a/packages/ui/src/modules/layout/LayoutContainer.tsx
+++ b/packages/ui/src/modules/layout/LayoutContainer.tsx
@@ -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;
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;
+ onFilesDrop?: (files: File[], targetColumn?: number) => void;
}
const LayoutContainerComponent: React.FC = ({
@@ -53,6 +56,7 @@ const LayoutContainerComponent: React.FC = ({
canMoveContainerUp,
canMoveContainerDown,
selectedWidgetId,
+ selectedWidgetIds,
onSelectWidget,
depth = 0,
isCompactMode = false,
@@ -60,7 +64,12 @@ const LayoutContainerComponent: React.FC = ({
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 = ({
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 = ({
onSelectWidget={onSelectWidget}
editingWidgetId={editingWidgetId}
contextVariables={contextVariables}
+ selectedContainerId={selectedContainerId}
+ onSelectContainer={onSelect}
/>
))}
@@ -182,6 +193,7 @@ const LayoutContainerComponent: React.FC = ({
canMoveContainerUp={canMoveContainerUp}
canMoveContainerDown={canMoveContainerDown}
selectedWidgetId={selectedWidgetId}
+ selectedWidgetIds={selectedWidgetIds}
onSelectWidget={onSelectWidget}
depth={depth + 1}
isCompactMode={isCompactMode}
@@ -221,14 +233,32 @@ const LayoutContainerComponent: React.FC = ({
>
);
+ // Handle Enabled State
+ const isContainerEnabled = container.settings?.enabled !== false; // Default to true
+
+ if (!isContainerEnabled && !isEditMode) {
+ return null;
+ }
+
return (
-
+
{/* Edit Mode Controls */}
{isEditMode && (
+ )}
+ onClick={(e) => {
+ e.stopPropagation();
+ if (isEditMode) {
+ onSelect?.(container.id, pageId);
+ }
+ }}
+ >
{/* Responsive layout: title and buttons wrap on small screens */}
@@ -371,13 +401,15 @@ const LayoutContainerComponent: React.FC
= ({
{/* Container Content */}
{
e.stopPropagation();
@@ -436,6 +468,14 @@ const LayoutContainerComponent: React.FC = ({
)}
+ {/* Drop Overlay */}
+ {isDragging && (
+
+
+ Drop to add Image
+
+
+ )}
{/* Container Settings Dialog */}
{showContainerSettings && (
@@ -474,6 +514,8 @@ interface WidgetItemProps {
onSelectWidget?: (widgetId: string, pageId?: string) => void;
editingWidgetId?: string | null;
contextVariables?: Record
;
+ selectedContainerId?: string | null;
+ onSelectContainer?: (containerId: string, pageId?: string) => void;
}
const WidgetItem: React.FC = ({
@@ -493,12 +535,22 @@ const WidgetItem: React.FC = ({
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) => {
+ 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 = ({
{/* Move Controls - Cross Pattern (Only show on hover or selection) */}
= ({
{/* Widget Content - With selection wrapper */}
= ({
widgetInstanceId={widget.id}
widgetDefId={widget.widgetId}
isEditMode={isEditMode}
- onPropsChange={async (newProps: Record) => {
- 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} />
{/* Generic Settings Modal */}
diff --git a/packages/ui/src/modules/layout/LayoutContext.tsx b/packages/ui/src/modules/layout/LayoutContext.tsx
index 5bbd0ed1..e1052820 100644
--- a/packages/ui/src/modules/layout/LayoutContext.tsx
+++ b/packages/ui/src/modules/layout/LayoutContext.tsx
@@ -16,6 +16,7 @@ import {
ReplaceLayoutCommand
} from '@/modules/layout/commands';
+
interface LayoutContextType {
// Generic page management
loadPageLayout: (pageId: string, defaultName?: string) => Promise;
@@ -23,7 +24,7 @@ interface LayoutContextType {
clearPageLayout: (pageId: string) => Promise;
// Generic page actions
- addWidgetToPage: (pageId: string, containerId: string, widgetId: string, targetColumn?: number) => Promise;
+ addWidgetToPage: (pageId: string, containerId: string, widgetId: string, targetColumn?: number, initialProps?: Record) => Promise;
removeWidgetFromPage: (pageId: string, widgetInstanceId: string) => Promise;
moveWidgetInPage: (pageId: string, widgetInstanceId: string, direction: 'up' | 'down' | 'left' | 'right') => Promise;
updatePageContainerColumns: (pageId: string, containerId: string, columns: number) => Promise;
@@ -134,11 +135,14 @@ export const LayoutProvider: React.FC = ({ 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) => {
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
diff --git a/packages/ui/src/modules/layout/LayoutManager.ts b/packages/ui/src/modules/layout/LayoutManager.ts
index 19f382cf..522883b1 100644
--- a/packages/ui/src/modules/layout/LayoutManager.ts
+++ b/packages/ui/src/modules/layout/LayoutManager.ts
@@ -22,6 +22,8 @@ export interface LayoutContainer {
collapsed?: boolean;
title?: string;
showTitle?: boolean;
+ customClassName?: string;
+ enabled?: boolean;
};
}
diff --git a/packages/ui/src/modules/layout/SelectionContext.tsx b/packages/ui/src/modules/layout/SelectionContext.tsx
new file mode 100644
index 00000000..aed1ffbf
--- /dev/null
+++ b/packages/ui/src/modules/layout/SelectionContext.tsx
@@ -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;
+ 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(undefined);
+
+export const SelectionProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
+ const [selectedWidgetIds, setSelectedWidgetIds] = useState>(new Set());
+ const [selectedContainerId, setSelectedContainerId] = useState(null);
+ const [clipboard, setClipboard] = useState(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 (
+
+ {children}
+
+ );
+};
+
+export const useSelection = () => {
+ const context = useContext(SelectionContext);
+ if (!context) {
+ throw new Error('useSelection must be used within a SelectionProvider');
+ }
+ return context;
+};
diff --git a/packages/ui/src/modules/layout/WidgetPalette.tsx b/packages/ui/src/modules/layout/WidgetPalette.tsx
index be8e942f..93c9884a 100644
--- a/packages/ui/src/modules/layout/WidgetPalette.tsx
+++ b/packages/ui/src/modules/layout/WidgetPalette.tsx
@@ -70,12 +70,12 @@ export const WidgetPalette: React.FC = ({
const modalContent = (
e.stopPropagation()}
style={{ zIndex: 100000 }}
>
@@ -91,7 +91,7 @@ export const WidgetPalette: React.FC = ({
-
+
{/* Search */}
diff --git a/packages/ui/src/modules/layout/client-layouts.ts b/packages/ui/src/modules/layout/client-layouts.ts
index 1be00ed2..217fa5b8 100644
--- a/packages/ui/src/modules/layout/client-layouts.ts
+++ b/packages/ui/src/modules/layout/client-layouts.ts
@@ -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;
+};
diff --git a/packages/ui/src/modules/layout/commands.ts b/packages/ui/src/modules/layout/commands.ts
index 40fea0ad..650e23a2 100644
--- a/packages/ui/src/modules/layout/commands.ts
+++ b/packages/ui/src/modules/layout/commands.ts
@@ -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
{
+ 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 {
+ 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);
+ }
+}
diff --git a/packages/ui/src/modules/pages/NewPage.tsx b/packages/ui/src/modules/pages/NewPage.tsx
index 840280dd..dda1196f 100644
--- a/packages/ui/src/modules/pages/NewPage.tsx
+++ b/packages/ui/src/modules/pages/NewPage.tsx
@@ -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("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 */}
Visibility Settings
-
+
);
+
+ );
};
const UserPage = (props: UserPageProps) => (
diff --git a/packages/ui/src/modules/pages/editor/UserPageDetails.tsx b/packages/ui/src/modules/pages/editor/UserPageDetails.tsx
index c3850750..c395c2e1 100644
--- a/packages/ui/src/modules/pages/editor/UserPageDetails.tsx
+++ b/packages/ui/src/modules/pages/editor/UserPageDetails.tsx
@@ -71,6 +71,7 @@ interface UserPageDetailsProps {
onWidgetRename: (id: string | null) => void;
templates?: Layout[];
onLoadTemplate?: (template: Layout) => void;
+ showActions?: boolean;
}
export const UserPageDetails: React.FC
= ({
@@ -85,6 +86,7 @@ export const UserPageDetails: React.FC = ({
onWidgetRename,
templates,
onLoadTemplate,
+ showActions = true,
}) => {
const navigate = useNavigate();
@@ -227,8 +229,8 @@ export const UserPageDetails: React.FC = ({
};
return (
-
-
+
+
{/* Parent Page Eyebrow */}
{page.parent_page && (
@@ -333,7 +335,7 @@ export const UserPageDetails: React.FC = ({
-
+
{/* Tags and Type */}
@@ -351,7 +353,7 @@ export const UserPageDetails: React.FC
= ({
)}
{/* PageActions - Only visible in View Mode (Edit Mode uses PageRibbonBar) */}
- {!isEditMode && (
+ {!isEditMode && showActions && (
}>
= ({
)}
-
-
-
{/* Editable Tags */}
{editingTags && isOwner && isEditMode ? (
diff --git a/packages/ui/src/modules/pages/editor/UserPageEdit.tsx b/packages/ui/src/modules/pages/editor/UserPageEdit.tsx
index dcc20592..931449c5 100644
--- a/packages/ui/src/modules/pages/editor/UserPageEdit.tsx
+++ b/packages/ui/src/modules/pages/editor/UserPageEdit.tsx
@@ -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
;
}
-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(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(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(null);
const iframeRef = useRef(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(null);
+ const { executeCommand } = useLayout();
+ // selectedContainerId is now from useSelection (see above)
const [editingWidgetId, setEditingWidgetId] = useState(null);
const [newlyAddedWidgetId, setNewlyAddedWidgetId] = useState(null);
const [activeTemplateId, setActiveTemplateId] = useState(null);
@@ -204,6 +253,9 @@ const UserPageEdit = ({
const [settingsWidgetId, setSettingsWidgetId] = useState(null);
const [settingsLayoutId, setSettingsLayoutId] = useState(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): 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}
/>
-
+
{/* Sidebar Left */}
{!showEmailPreview && (headings.length > 0 || childPages.length > 0 || showHierarchy) && (
-
-
+
+
- {/* Footer */}
-
-
-
-
Last updated: {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 && (
+
+
+
+ Last updated: {new Date(page.updated_at).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ })}
+
+ {page.parent && (
+
+
View parent page
+
+ )}
+
- {page.parent && (
-
-
View parent page
-
- )}
-
+ )}
{/* Right Sidebar - Property Panel OR Type Fields */}
- {(selectedWidgetId || showTypeFields) && (
- <>
-
-
-
- {selectedWidgetId ? (
- Loading settings...
}>
-
-
- ) : showTypeFields ? (
-
- Loading types...
}>
- onPageUpdate({ ...page, meta: newMeta })}
+ {
+ (selectedWidgetId || selectedContainerId || showTypeFields) && (
+ <>
+
+
+
+ {selectedWidgetId ? (
+ Loading settings...
}>
+
-
- ) : null}
-
-
- >
- )}
-
-
+ ) : selectedContainerId ? (
+
Loading settings...}>
+
+
+ ) : showTypeFields ? (
+
+ Loading types...
}>
+
onPageUpdate({ ...page, meta: newMeta })}
+ />
+
+
+ ) : null}
+
+
+ >
+ )
+ }
+
+
+
+ setConfirmationState(prev => ({ ...prev, open }))}
+ title={confirmationState.title}
+ description={confirmationState.description}
+ onConfirm={confirmationState.onConfirm}
+ confirmLabel={confirmationState.confirmLabel}
+ variant={confirmationState.variant}
+ />
>
);
};
+const UserPageEdit = (props: UserPageEditProps) => (
+
+
+
+);
+
export default UserPageEdit;
diff --git a/packages/ui/src/modules/pages/editor/ribbons/PageRibbonBar.tsx b/packages/ui/src/modules/pages/editor/ribbons/PageRibbonBar.tsx
index 82dcec85..a394ed19 100644
--- a/packages/ui/src/modules/pages/editor/ribbons/PageRibbonBar.tsx
+++ b/packages/ui/src/modules/pages/editor/ribbons/PageRibbonBar.tsx
@@ -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;
+ 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
);
-const RibbonItemLarge = ({
- icon: Icon,
- label,
- onClick,
- active,
- iconColor = "text-foreground",
- disabled = false
-}: {
- icon: any,
- label: string,
- onClick?: () => void,
- active?: boolean,
- iconColor?: string,
- disabled?: boolean
-}) => (
-
-);
+
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
}) => (
-
+
);
+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 (
+
+ {visibleActions.map((action, i) => (
+
+ ))}
+ {shouldOverflow && (
+
+
+
+
+ More
+
+
+
+ {overflowActions.map((action, i) => (
+
+
+ {action.label}
+ {action.onDelete && (
+ {
+ e.stopPropagation();
+ action.onDelete?.();
+ }}
+ className="ml-auto p-1 hover:text-red-500"
+ >
+
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+
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 (
-
+
{/* Context & Tabs Row */}
-
+
DESIGN
@@ -498,50 +574,114 @@ export const PageRibbonBar = ({
{/* Ribbon Toolbar Area */}
-
+
{/* Fixed 'Finish' Section - Always Visible */}
-
- {exitActions.map(action => (
- 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"}
+
+
+ {exitActions.map(action => (
+ 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"}
+ />
+ ))}
+ {
+ 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"
/>
- ))}
+
+
+
- {historyActions.map(action => (
- action.handler && action.handler()}
- disabled={action.disabled}
- iconColor="text-purple-600 dark:text-purple-400"
- />
- ))}
+ ({
+ 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"
+ }))}
+ />
+
+
+
+
{/* Fields Toggle */}
-
- setShowVariablesDialog(true)}
- iconColor="text-indigo-600 dark:text-indigo-400"
+ setShowVariablesDialog(true),
+ iconColor: "text-indigo-600 dark:text-indigo-400"
+ }
+ ]}
/>
@@ -560,92 +700,92 @@ export const PageRibbonBar = ({
{activeTab === 'page' && (
<>
-
-
-
-
- setShowCategoryManager(true)}
- iconColor="text-yellow-600 dark:text-yellow-400"
- />
- setShowPagePicker(true)}
- iconColor="text-orange-500 dark:text-orange-400"
- />
- setShowCreationWizard(true)}
- iconColor="text-green-600 dark:text-green-500"
+ 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"
+ }
+ ]}
/>
- {
- 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"
+ {
+ 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 && (
-
- )}
-
-
-
-
>
)}
@@ -680,67 +820,72 @@ export const PageRibbonBar = ({
return (
<>
- {/* Add Container Action */}
- ({
+ 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 => (
- 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"}
- />
- ))}
{mediaWidgets.length > 0 && (
- {mediaWidgets.map(widget => (
- 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"}
- />
- ))}
+ ({
+ 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"
+ }))}
+ />
)}
{contentWidgets.length > 0 && (
- {contentWidgets.map(widget => (
- 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"}
- />
- ))}
+ ({
+ 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"
+ }))}
+ />
)}
{advancedWidgets.length > 0 && (
- {advancedWidgets.map(widget => (
- 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"}
- />
- ))}
+ ({
+ 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"
+ }))}
+ />
)}
>
@@ -751,41 +896,44 @@ export const PageRibbonBar = ({
{activeTab === 'layouts' && (
<>
-
- {onSaveAsNewTemplate && (
-
- )}
- {activeTemplateId && onSaveToTemplate && (
-
- )}
- {templates?.map(t => (
- onLoadTemplate?.(t)}
- active={t.id === activeTemplateId}
- iconColor="text-indigo-500 dark:text-indigo-400"
- />
- ))}
+ ({
+ 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) && (
No Templates
)}
@@ -797,13 +945,19 @@ export const PageRibbonBar = ({
{activeTab === 'view' && (
<>
-
+
>
)}
@@ -812,17 +966,22 @@ export const PageRibbonBar = ({
{activeTab === 'advanced' && (
<>
-
-
@@ -837,20 +996,44 @@ export const PageRibbonBar = ({
Limit: 102KB
-
-
+
+
+
+
+ Selected
+
+
+ {selectedContainerId ? (
+
C: {selectedContainerId.slice(0, 8)}...
+ ) :
No Container
}
+ {selectedWidgetId ? (
+
W: {selectedWidgetId.slice(0, 8)}...{(selectedWidgetIds?.size ?? 0) > 1 ? ` +${(selectedWidgetIds?.size ?? 1) - 1}` : ''}
+ ) :
No Widget
}
+
+
+ Count: {(selectedContainerId ? 1 : 0) + (selectedWidgetIds?.size ?? (selectedWidgetId ? 1 : 0))}
+
+
+
>
)}
@@ -983,7 +1166,7 @@ export const PageRibbonBar = ({
-
+
);
};
diff --git a/packages/ui/src/pages/AdminPage.tsx b/packages/ui/src/pages/AdminPage.tsx
index 9af9ae9b..c570926f 100644
--- a/packages/ui/src/pages/AdminPage.tsx
+++ b/packages/ui/src/pages/AdminPage.tsx
@@ -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
('users');
if (loading) {
return
@@ -32,14 +35,22 @@ const AdminPage = () => {
return (
-
+
- {activeSection === 'users' && }
- {activeSection === 'dashboard' && }
- {activeSection === 'server' && }
- {activeSection === 'bans' && }
- {activeSection === 'violations' && }
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ Loading analytics...
}>
+
+
+ } />
+
diff --git a/packages/ui/src/pages/Post.tsx b/packages/ui/src/pages/Post.tsx
index 0998efab..9ae54079 100644
--- a/packages/ui/src/pages/Post.tsx
+++ b/packages/ui/src/pages/Post.tsx
@@ -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 (
-
+
{post && (
type={isVideo ? 'video.other' : 'article'}
/>
)}
-
+
{viewMode === 'article' ? (
diff --git a/packages/ui/src/pages/Post/renderers/CompactRenderer.tsx b/packages/ui/src/pages/Post/renderers/CompactRenderer.tsx
index fdce33b7..abab3dc8 100644
--- a/packages/ui/src/pages/Post/renderers/CompactRenderer.tsx
+++ b/packages/ui/src/pages/Post/renderers/CompactRenderer.tsx
@@ -42,7 +42,7 @@ export const CompactRenderer: React.FC
= (props) => {
const isVideo = isVideoType(normalizeMediaType(effectiveType));
return (
-
+
{/* Mobile Header - Controls and Info at Top */}
= (props) => {
{/* Desktop layout: Media on left, content on right */}
-
-
+
+
{/* Left Column - Media */}
diff --git a/packages/ui/src/pages/Post/renderers/components/CompactPostHeader.tsx b/packages/ui/src/pages/Post/renderers/components/CompactPostHeader.tsx
index e4291dfb..7e6e0e43 100644
--- a/packages/ui/src/pages/Post/renderers/components/CompactPostHeader.tsx
+++ b/packages/ui/src/pages/Post/renderers/components/CompactPostHeader.tsx
@@ -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 {
diff --git a/packages/ui/src/pages/Post/renderers/components/Gallery.tsx b/packages/ui/src/pages/Post/renderers/components/Gallery.tsx
index 4c04c373..11ceb336 100644
--- a/packages/ui/src/pages/Post/renderers/components/Gallery.tsx
+++ b/packages/ui/src/pages/Post/renderers/components/Gallery.tsx
@@ -174,10 +174,10 @@ export const Gallery: React.FC
= ({
{/* Filmstrip */}
{
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);
diff --git a/packages/ui/src/pages/Profile.tsx b/packages/ui/src/pages/Profile.tsx
index 8a1a7a3d..3f20334c 100644
--- a/packages/ui/src/pages/Profile.tsx
+++ b/packages/ui/src/pages/Profile.tsx
@@ -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 = () => {
)}
+ {activeSection === 'analytics' && roles.includes('admin') && (
+
+
+ Analytics
+
+
+
+
+
+ )}
+
+
{activeSection === 'gallery' && (
@@ -738,6 +751,8 @@ const ProfileSidebar = ({
{ id: 'variables' as ActiveSection, label: translate('Variables'), icon: Hash },
];
+
+
return (
diff --git a/packages/ui/src/pages/UserProfile.tsx b/packages/ui/src/pages/UserProfile.tsx
index a18fced9..02a9304a 100644
--- a/packages/ui/src/pages/UserProfile.tsx
+++ b/packages/ui/src/pages/UserProfile.tsx
@@ -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(`
diff --git a/packages/ui/src/pages/VideoFeedPlayground.tsx b/packages/ui/src/pages/VideoFeedPlayground.tsx
index a17797a9..915a87c9 100644
--- a/packages/ui/src/pages/VideoFeedPlayground.tsx
+++ b/packages/ui/src/pages/VideoFeedPlayground.tsx
@@ -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')
diff --git a/packages/ui/src/pages/analytics/AnalyticsDashboard.tsx b/packages/ui/src/pages/analytics/AnalyticsDashboard.tsx
new file mode 100644
index 00000000..7b835379
--- /dev/null
+++ b/packages/ui/src/pages/analytics/AnalyticsDashboard.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(true);
+
+ // Initialize state from URL params
+ const [filterModel, setFilterModel] = useState(() => paramsToFilterModel(searchParams));
+
+ const [paginationModel, setPaginationModel] = useState(() => {
+ const page = parseInt(searchParams.get('page') || '0', 10);
+ const pageSize = parseInt(searchParams.get('pageSize') || '100', 10);
+ return { page, pageSize };
+ });
+
+ const [sortModel, setSortModel] = useState(() => {
+ const urlSort = paramsToSortModel(searchParams);
+ return urlSort.length > 0 ? urlSort : [{ field: 'timestamp', sort: 'desc' }];
+ });
+
+ const [columnVisibilityModel, setColumnVisibilityModel] = useState(() => paramsToVisibilityModel(searchParams));
+
+ // Saved Filters State
+ const [savedFilters, setSavedFilters] = useState(() => {
+ 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 -;
+ return (
+
+ {country.countryFlagEmoji}
+ {country.name}
+
+ );
+ }
+ },
+ {
+ field: 'isBot',
+ headerName: 'Bot',
+ width: 80,
+ renderCell: (params: any) => {
+ const isBot = params.value;
+ if (!isBot) return -;
+ return (
+
+ 🤖
+
+ );
+ }
+ },
+ {
+ field: 'isAI',
+ headerName: 'AI',
+ width: 80,
+ renderCell: (params: any) => {
+ const isAI = params.value;
+ if (!isAI) return -;
+ return (
+
+ ðŸ§
+
+ );
+ }
+ },
+ {
+ 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 {status};
+ }
+ },
+ { 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) => (
+
+ {params.value}
+
+ )
+ },
+ { 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 (
+
+
+ Analytics Dashboard
+
+
+
+
+
+ Save Filter
+
+
+
+
+
+
Save Filter Preset
+
+ Save the current filters as a preset.
+
+
+
+
+
+ setNewFilterName(e.target.value)}
+ placeholder="e.g. Errors Only"
+ className="h-8"
+ />
+ Save
+
+
+
+
+
+
+
+
+
+
+ Load Filter
+
+
+
+ Saved Filters
+
+ {savedFilters.length === 0 ? (
+
+ No saved filters
+
+ ) : (
+ savedFilters.map((filter, index) => (
+ handleLoadFilter(filter)} className="flex justify-between items-center">
+ {filter.name}
+ handleDeleteFilter(e, index)}
+ />
+
+ ))
+ )}
+
+
+
+
+
+ Clear View
+
+
+
+ Clear Database
+
+
+
+ Export JSON
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default AnalyticsDashboard;
diff --git a/packages/ui/src/pages/analytics/gridUtils.ts b/packages/ui/src/pages/analytics/gridUtils.ts
new file mode 100644
index 00000000..aa80f70c
--- /dev/null
+++ b/packages/ui/src/pages/analytics/gridUtils.ts
@@ -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 => {
+ const params: Record = {};
+
+ 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 => {
+ const params: Record = {};
+ 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 => {
+ 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(',') : [];
+};
diff --git a/packages/ui/src/pages/analytics/index.ts b/packages/ui/src/pages/analytics/index.ts
new file mode 100644
index 00000000..f3c48770
--- /dev/null
+++ b/packages/ui/src/pages/analytics/index.ts
@@ -0,0 +1 @@
+export { default as AnalyticsDashboard } from './AnalyticsDashboard';