canvas layout / widgets / commands

This commit is contained in:
lovebird 2026-02-10 00:02:50 +01:00
parent 444f78ceb7
commit 504a4028e7
22 changed files with 2436 additions and 715 deletions

View File

@ -162,6 +162,8 @@ import CacheTest from "./pages/CacheTest";
// ... (imports)
import { FeedCacheProvider } from "@/contexts/FeedCacheContext";
import { StreamProvider } from "@/contexts/StreamContext";
import { StreamInvalidator } from "@/components/StreamInvalidator";
// ... (imports)
@ -185,9 +187,12 @@ const App = () => {
<OrganizationProvider>
<ProfilesProvider>
<WebSocketProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
<FeedCacheProvider>
<AppWrapper />
</FeedCacheProvider>
<StreamProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
<StreamInvalidator />
<FeedCacheProvider>
<AppWrapper />
</FeedCacheProvider>
</StreamProvider>
</WebSocketProvider>
</ProfilesProvider>
</OrganizationProvider>

View File

@ -2,20 +2,24 @@ import { useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Eye, EyeOff, Edit3, Trash2, GitMerge, Share2, Link as LinkIcon, FileText, Download, FilePlus, FolderTree, FileJson } from "lucide-react";
import { Eye, EyeOff, Edit3, Trash2, GitMerge, Share2, Link as LinkIcon, FileText, Download, FilePlus, FolderTree, FileJson, LayoutTemplate } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel
DropdownMenuLabel,
DropdownMenuGroup
} from "@/components/ui/dropdown-menu";
import { T, translate } from "@/i18n";
import { PagePickerDialog } from "./widgets/PagePickerDialog";
import { PageCreationWizard } from "./widgets/PageCreationWizard";
import { CategoryManager } from "./widgets/CategoryManager";
import { cn } from "@/lib/utils";
import { Database } from '@/integrations/supabase/types';
type Layout = Database['public']['Tables']['layouts']['Row'];
interface Page {
id: string;
@ -39,6 +43,8 @@ interface PageActionsProps {
onMetaUpdated?: () => void;
className?: string;
showLabels?: boolean;
templates?: Layout[];
onLoadTemplate?: (template: Layout) => void;
}
export const PageActions = ({
@ -50,7 +56,9 @@ export const PageActions = ({
onDelete,
onMetaUpdated,
className,
showLabels = true
showLabels = true,
templates,
onLoadTemplate
}: PageActionsProps) => {
const [loading, setLoading] = useState(false);
const [showPagePicker, setShowPagePicker] = useState(false);
@ -78,7 +86,8 @@ export const PageActions = ({
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
paths: [apiPath, htmlPath]
paths: [apiPath, htmlPath],
types: ['pages']
})
});
console.log('Cache invalidated for:', page.slug);
@ -488,6 +497,33 @@ draft: ${!page.visible}
</Button>
)}
{/* Layout Template Picker - Only in Edit Mode */}
{isEditMode && templates && onLoadTemplate && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2" onClick={(e) => e.stopPropagation()}>
<LayoutTemplate className="h-4 w-4" />
{showLabels && <span className="hidden md:inline"><T>Layout</T></span>}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end">
<DropdownMenuLabel><T>Load Template</T></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{templates.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground"><T>No templates found</T></div>
) : (
templates.map((t) => (
<DropdownMenuItem key={t.id} onClick={() => onLoadTemplate(t)}>
{t.name}
</DropdownMenuItem>
))
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Categorization - New All-in-One Component */}
<Button
variant="outline"

View File

@ -0,0 +1,40 @@
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useStream } from '@/contexts/StreamContext';
import { logger } from '@/Logger';
// Mapping of AppEvent.type to React Query Keys
// This acts as a configuration template for all auto-invalidations.
const EVENT_TO_QUERY_KEY: Record<string, string[]> = {
'categories': ['categories'],
'posts': ['posts'],
'pages': ['pages'],
'users': ['users'],
'organizations': ['organizations'],
};
export const StreamInvalidator = () => {
const { subscribe } = useStream();
const queryClient = useQueryClient();
useEffect(() => {
const unsubscribe = subscribe((event) => {
// Verify it's a cache invalidation event
if (event.kind === 'cache' && event.type) {
const queryKey = EVENT_TO_QUERY_KEY[event.type];
if (queryKey) {
logger.info(`[StreamInvalidator] Invalidating query: [${queryKey}] due to event: ${event.type}`);
queryClient.invalidateQueries({ queryKey });
} else {
// Optional: Log unhandled types if you want to verify what's missing
logger.debug(`[StreamInvalidator] Unknown event type for invalidation: ${event.type}`);
}
}
});
return unsubscribe;
}, [subscribe, queryClient]);
return null; // This component handles logic only, no UI
};

View File

@ -15,7 +15,12 @@ interface GenericCanvasProps {
className?: string;
selectedWidgetId?: string | null;
onSelectWidget?: (widgetId: string) => void;
selectedContainerId?: string | null;
onSelectContainer?: (containerId: string | null) => void;
initialLayout?: any;
editingWidgetId?: string | null;
onEditWidget?: (widgetId: string | null) => void;
newlyAddedWidgetId?: string | null;
}
const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
@ -26,7 +31,12 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
className = '',
selectedWidgetId,
onSelectWidget,
initialLayout
selectedContainerId: propSelectedContainerId,
onSelectContainer: propOnSelectContainer,
initialLayout,
editingWidgetId,
onEditWidget,
newlyAddedWidgetId
}) => {
const {
loadedPages,
@ -47,7 +57,6 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
} = useLayout();
const layout = loadedPages.get(pageId);
// Load the page layout on mount
// Load the page layout on mount or hydrate from prop
useEffect(() => {
if (initialLayout && !layout) {
@ -60,7 +69,16 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
}
}, [pageId, pageName, layout, loadPageLayout, hydratePageLayout, initialLayout]);
const [selectedContainer, setSelectedContainer] = useState<string | null>(null);
const [internalSelectedContainer, setInternalSelectedContainer] = useState<string | null>(null);
const selectedContainer = propSelectedContainerId !== undefined ? propSelectedContainerId : internalSelectedContainer;
const setSelectedContainer = (id: string | null) => {
if (propOnSelectContainer) {
propOnSelectContainer(id);
} else {
setInternalSelectedContainer(id);
}
};
const [showWidgetPalette, setShowWidgetPalette] = useState(false);
const [targetContainerId, setTargetContainerId] = useState<string | null>(null);
const [targetColumn, setTargetColumn] = useState<number | undefined>(undefined);
@ -312,6 +330,9 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
isCompactMode={className.includes('p-0')}
selectedWidgetId={selectedWidgetId}
onSelectWidget={onSelectWidget}
editingWidgetId={editingWidgetId}
onEditWidget={onEditWidget}
newlyAddedWidgetId={newlyAddedWidgetId}
onRemoveWidget={async (widgetId) => {
try {
await removeWidgetFromPage(pageId, widgetId);

View File

@ -30,6 +30,9 @@ interface LayoutContainerProps {
onSelectWidget?: (widgetId: string) => void;
depth?: number;
isCompactMode?: boolean;
editingWidgetId?: string | null;
onEditWidget?: (widgetId: string | null) => void;
newlyAddedWidgetId?: string | null;
}
const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
@ -52,6 +55,9 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
onSelectWidget,
depth = 0,
isCompactMode = false,
editingWidgetId,
onEditWidget,
newlyAddedWidgetId,
}) => {
const maxDepth = 3; // Limit nesting depth
const canNest = depth < maxDepth;
@ -115,6 +121,8 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
canMoveDown={index < container.widgets.length - 1}
onRemove={onRemoveWidget}
onMove={onMoveWidget}
isEditing={editingWidgetId === widget.id}
onEditWidget={onEditWidget}
/>
))}
@ -170,10 +178,14 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
onSelectWidget={onSelectWidget}
depth={depth + 1}
isCompactMode={isCompactMode}
editingWidgetId={editingWidgetId}
onEditWidget={onEditWidget}
newlyAddedWidgetId={newlyAddedWidgetId}
/>
</div>
))}
{/* Empty State - only show when not showing column indicators */}
{container.widgets.length === 0 && container.children.length === 0 && !(isEditMode && isSelected) && (
<div
@ -447,6 +459,9 @@ interface WidgetItemProps {
onMove?: (widgetInstanceId: string, direction: 'up' | 'down' | 'left' | 'right') => void;
isSelected?: boolean;
onSelect?: () => void;
isEditing?: boolean;
onEditWidget?: (widgetId: string | null) => void;
isNew?: boolean;
}
const WidgetItem: React.FC<WidgetItemProps> = ({
@ -458,11 +473,15 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
onRemove,
onMove,
isSelected,
onSelect
onSelect,
isEditing,
onEditWidget,
isNew
}) => {
const widgetDefinition = widgetRegistry.get(widget.widgetId);
const { updateWidgetProps, renameWidget } = useLayout();
const [showSettingsModal, setShowSettingsModal] = useState(false);
// Internal state removed in favor of controlled state
// const [showSettingsModal, setShowSettingsModal] = useState(false);
// pageId is now passed as a prop from the parent component
@ -500,13 +519,37 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
}
};
const handleSettingsCancel = () => {
if (isNew) {
// If it's a new widget and the user cancels settings, remove it
onRemove?.(widget.id);
}
onEditWidget?.(null); // Close the settings modal
};
// Handle Enabled State
const isEnabled = widget.props?.enabled !== false; // Default to true
if (!isEnabled && !isEditMode) {
return null;
}
return (
<div className="relative group" id={`widget-item-${widget.id}`}>
<div className={cn(
"relative group",
!isEnabled && "opacity-50 grayscale transition-all hover:grayscale-0"
)} id={`widget-item-${widget.id}`}>
{/* Edit Mode Controls */}
{isEditMode && (
<>
{/* Widget Info Overlay */}
<div className="absolute top-0 left-0 right-0 bg-green-500/90 text-white text-xs px-2 py-1 rounded-t-lg z-10">
<div
className="absolute top-0 left-0 right-0 bg-green-500/90 text-white text-xs px-2 py-1 rounded-t-lg z-10 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onSelect?.();
}}
>
<div className="flex items-center justify-between">
<span>{widgetDefinition.metadata.name}</span>
<div className="flex items-center gap-1">
@ -518,7 +561,8 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
className="h-4 w-4 text-white hover:bg-white/20"
onClick={(e) => {
e.stopPropagation();
setShowSettingsModal(true);
// Open settings modal via prop
onEditWidget?.(widget.id);
}}
title="Widget settings"
>
@ -564,6 +608,8 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
<div
className={cn(
"w-full bg-white dark:bg-slate-800 overflow-hidden rounded-lg transition-all duration-200",
widget.props?.customClassName || `widget-${widget.id.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
`widget-type-${widget.widgetId}`,
// Selection Visuals & Margins
isEditMode && "border-2",
isEditMode && isSelected ? "border-blue-500 ring-4 ring-blue-500/10 shadow-lg z-10" : "border-transparent",
@ -597,10 +643,10 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
{/* Generic Settings Modal */}
{
widgetDefinition.metadata.configSchema && showSettingsModal && (
widgetDefinition.metadata.configSchema && isEditing && (
<WidgetSettingsManager
isOpen={showSettingsModal}
onClose={() => setShowSettingsModal(false)}
isOpen={!!isEditing} // coerce to boolean although it should be boolean | undefined from comparison
onClose={() => onEditWidget?.(null)}
widgetDefinition={widgetDefinition}
currentProps={widget.props || {}}
onSave={handleSettingsSave}

View File

@ -0,0 +1,438 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { T, translate } from "@/i18n";
import { toast } from "sonner";
import { supabase } from "@/integrations/supabase/client";
import { invalidateUserPageCache } from "@/lib/db";
import { PageActions } from "@/components/PageActions";
import {
FileText, Check, X, Calendar, FolderTree, EyeOff, Plus
} from "lucide-react";
import { Separator } from "@/components/ui/separator";
// Interfaces mostly matching UserPage.tsx
interface Page {
id: string;
title: string;
slug: string;
content: any;
owner: string;
parent: string | null;
parent_page?: {
title: string;
slug: string;
} | null;
type: string | null;
tags: string[] | null;
is_public: boolean;
visible: boolean;
created_at: string;
updated_at: string;
meta?: any;
category_paths?: {
id: string;
name: string;
slug: string;
}[][];
categories?: {
id: string;
name: string;
slug: string;
}[];
}
interface UserProfile {
id: string;
username: string | null;
display_name: string | null;
avatar_url: string | null;
}
import { Database } from '@/integrations/supabase/types';
type Layout = Database['public']['Tables']['layouts']['Row'];
interface UserPageDetailsProps {
page: Page;
userProfile: UserProfile | null;
isOwner: boolean;
isEditMode: boolean;
userId: string;
orgSlug?: string;
onPageUpdate: (updatedPage: Page) => void;
onToggleEditMode: () => void;
onWidgetRename: (id: string | null) => void;
templates?: Layout[];
onLoadTemplate?: (template: Layout) => void;
}
export const UserPageDetails: React.FC<UserPageDetailsProps> = ({
page,
userProfile,
isOwner,
isEditMode,
userId,
orgSlug,
onPageUpdate,
onToggleEditMode,
onWidgetRename,
templates,
onLoadTemplate,
}) => {
const navigate = useNavigate();
// State for inline editing
const [editingTitle, setEditingTitle] = useState(false);
const [editingSlug, setEditingSlug] = useState(false);
const [editingTags, setEditingTags] = useState(false);
const [titleValue, setTitleValue] = useState("");
const [slugValue, setSlugValue] = useState("");
const [tagsValue, setTagsValue] = useState("");
const [slugError, setSlugError] = useState<string | null>(null);
const [savingField, setSavingField] = useState<string | null>(null);
const checkSlugCollision = async (newSlug: string): Promise<boolean> => {
if (newSlug === page?.slug) return false;
try {
const { data, error } = await supabase
.from('pages')
.select('id')
.eq('slug', newSlug)
.eq('owner', userId)
.maybeSingle();
if (error) throw error;
return !!data;
} catch (error) {
console.error('Error checking slug collision:', error);
return false;
}
};
const handleSaveTitle = async () => {
if (!page || !titleValue.trim()) {
toast.error(translate('Title cannot be empty'));
return;
}
setSavingField('title');
try {
const { error } = await supabase
.from('pages')
.update({ title: titleValue.trim(), updated_at: new Date().toISOString() })
.eq('id', page.id)
.eq('owner', userId);
if (error) throw error;
onPageUpdate({ ...page, title: titleValue.trim() });
setEditingTitle(false);
if (userId && page.slug) invalidateUserPageCache(userId, page.slug);
toast.success(translate('Title updated'));
} catch (error) {
console.error('Error updating title:', error);
toast.error(translate('Failed to update title'));
} finally {
setSavingField(null);
}
};
const handleSaveSlug = async () => {
if (!page || !slugValue.trim()) {
toast.error(translate('Slug cannot be empty'));
return;
}
const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
if (!slugRegex.test(slugValue)) {
setSlugError('Slug must be lowercase, alphanumeric, and use hyphens only');
return;
}
setSavingField('slug');
setSlugError(null);
const hasCollision = await checkSlugCollision(slugValue);
if (hasCollision) {
setSlugError('This slug is already used by another page');
setSavingField(null);
return;
}
try {
const { error } = await supabase
.from('pages')
.update({ slug: slugValue.trim(), updated_at: new Date().toISOString() })
.eq('id', page.id)
.eq('owner', userId);
if (error) throw error;
onPageUpdate({ ...page, slug: slugValue.trim() });
setEditingSlug(false);
toast.success(translate('Slug updated'));
const newPath = orgSlug
? `/org/${orgSlug}/user/${userId}/pages/${slugValue}`
: `/user/${userId}/pages/${slugValue}`;
navigate(newPath, { replace: true });
} catch (error) {
console.error('Error updating slug:', error);
toast.error(translate('Failed to update slug'));
} finally {
if (userId && page?.slug) invalidateUserPageCache(userId, page.slug);
if (userId) invalidateUserPageCache(userId, slugValue.trim());
setSavingField(null);
}
};
const handleSaveTags = async () => {
if (!page) return;
setSavingField('tags');
try {
const newTags = tagsValue
.split(',')
.map(tag => tag.trim())
.filter(tag => tag.length > 0);
const { error } = await supabase
.from('pages')
.update({ tags: newTags.length > 0 ? newTags : null, updated_at: new Date().toISOString() })
.eq('id', page.id)
.eq('owner', userId);
if (error) throw error;
onPageUpdate({ ...page, tags: newTags.length > 0 ? newTags : null });
setEditingTags(false);
toast.success(translate('Tags updated'));
} catch (error) {
console.error('Error updating tags:', error);
toast.error(translate('Failed to update tags'));
} finally {
if (userId && page?.slug) invalidateUserPageCache(userId, page.slug);
setSavingField(null);
}
};
const handleStartEditTitle = () => {
setTitleValue(page?.title || '');
setEditingTitle(true);
};
const handleStartEditTags = () => {
setTagsValue(page?.tags?.join(', ') || '');
setEditingTags(true);
};
return (
<div className="mb-8">
<div className="flex items-start gap-4 mb-4">
<div className="flex-1">
{/* Parent Page Eyebrow */}
{page.parent_page && (
<div className="flex items-center gap-1 text-sm text-muted-foreground mb-2">
<Link
to={orgSlug ? `/org/${orgSlug}/user/${userId}/pages/${page.parent_page.slug}` : `/user/${userId}/pages/${page.parent_page.slug}`}
className="hover:text-primary transition-colors flex items-center gap-1"
>
<FileText className="h-3 w-3" />
<span>{page.parent_page.title}</span>
</Link>
</div>
)}
{/* Editable Title */}
{editingTitle && isOwner && isEditMode ? (
<div className="flex items-center gap-2 mb-2">
<Input
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
className="text-3xl font-bold h-12"
placeholder="Page title..."
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveTitle();
if (e.key === 'Escape') setEditingTitle(false);
}}
autoFocus
disabled={savingField === 'title'}
/>
<Button
size="sm"
variant="ghost"
onClick={handleSaveTitle}
disabled={savingField === 'title'}
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setEditingTitle(false)}
disabled={savingField === 'title'}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<h1
className={`text-3xl font-bold mb-2 ${isOwner && isEditMode ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
onClick={() => isOwner && isEditMode && handleStartEditTitle()}
title={isOwner && isEditMode ? 'Click to edit title' : ''}
>
{page.title}
</h1>
)}
<div className="flex flex-col gap-2 mt-2">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
{userProfile && (
<Link
to={orgSlug ? `/org/${orgSlug}/user/${userId}` : `/user/${userId}`}
className="hover:text-primary transition-colors"
>
{userProfile.display_name || userProfile.username || `User ${userId?.slice(0, 8)}`}
</Link>
)}
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{new Date(page.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
</div>
{(() => {
const displayPaths = page.category_paths || (page.categories?.map(c => [c]) || []);
if (displayPaths.length === 0) return null;
return (
<div className="flex flex-col gap-1 mt-1">
{displayPaths.map((path, pathIdx) => (
<div key={pathIdx} className="flex items-center gap-2 text-sm text-muted-foreground">
<FolderTree className="h-4 w-4 shrink-0" />
{path.map((cat, idx) => (
<span key={cat.id} className="flex items-center">
{idx > 0 && <span className="mx-1 text-muted-foreground/50">/</span>}
<Link to={`/categories/${cat.slug}`} className="hover:text-primary transition-colors hover:underline">
{cat.name}
</Link>
</span>
))}
</div>
))}
</div>
);
})()}
</div>
</div>
</div>
<Separator className="my-6" />
{/* Tags and Type */}
<div className="space-y-3 mb-8">
<div className="flex items-center gap-2 flex-wrap w-full">
{!page.visible && isOwner && (
<Badge variant="destructive" className="flex items-center gap-1">
<EyeOff className="h-3 w-3" />
<T>Hidden</T>
</Badge>
)}
{!page.is_public && (
<Badge variant="secondary" className="bg-blue-500/10 text-blue-500">
<T>Private</T>
</Badge>
)}
{/* PageActions - Only visible in View Mode (Edit Mode uses PageRibbonBar) */}
{!isEditMode && (
<PageActions
page={page}
isOwner={isOwner}
isEditMode={isEditMode}
onToggleEditMode={() => {
onToggleEditMode();
if (isEditMode) onWidgetRename(null);
}}
onPageUpdate={onPageUpdate}
onMetaUpdated={() => userId && page.slug && invalidateUserPageCache(userId, page.slug)} // Simple invalidation trigger
templates={templates}
onLoadTemplate={onLoadTemplate}
/>
)}
</div>
<Separator className="my-6" />
{/* Editable Tags */}
{editingTags && isOwner && isEditMode ? (
<div className="flex items-start gap-2">
<div className="flex-1">
<Input
value={tagsValue}
onChange={(e) => setTagsValue(e.target.value)}
className="text-sm"
placeholder="tag1, tag2, tag3..."
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveTags();
if (e.key === 'Escape') setEditingTags(false);
}}
autoFocus
disabled={savingField === 'tags'}
/>
<p className="text-xs text-muted-foreground mt-1">
Separate tags with commas
</p>
</div>
<Button
size="sm"
variant="ghost"
onClick={handleSaveTags}
disabled={savingField === 'tags'}
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setEditingTags(false)}
disabled={savingField === 'tags'}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<div className="flex items-center gap-2 flex-wrap w-full">
{page.tags && page.tags.map((tag, index) => (
<Badge
key={index}
variant="secondary"
className={isOwner && isEditMode ? 'cursor-pointer hover:bg-secondary/80' : ''}
onClick={() => isOwner && isEditMode && handleStartEditTags()}
>
#{tag}
</Badge>
))}
{isOwner && isEditMode && (
<Button
variant="ghost"
size="sm"
className="h-6 text-xs text-muted-foreground"
onClick={handleStartEditTags}
>
<Plus className="h-3 w-3 mr-1" />
<T>Edit Tags</T>
</Button>
)}
</div>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,39 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import { T } from "@/i18n";
interface UserPageTopBarProps {
embedded?: boolean;
orgSlug?: string;
userId: string;
isOwner?: boolean;
}
export const UserPageTopBar: React.FC<UserPageTopBarProps> = ({
embedded = false,
orgSlug,
userId,
isOwner,
}) => {
const navigate = useNavigate();
if (embedded) return null;
return (
<div className="border-b bg-background/95 backdrop-blur z-10 shrink-0">
<div className="container mx-auto py-2 flex items-center justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(orgSlug ? `/org/${orgSlug}/user/${userId}` : `/user/${userId}`)}
className="text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"><T>Back to profile</T></span>
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,491 @@
import React, { useState } from 'react';
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { supabase } from "@/integrations/supabase/client";
import { toast } from "sonner";
import {
LayoutTemplate,
Eye,
EyeOff,
Trash2,
GitMerge,
FolderTree,
FilePlus,
Settings,
Grid,
Type,
Image as ImageIcon,
Component,
MousePointer2,
Save,
Undo2,
Redo2,
FileJson
} from "lucide-react";
import { T, translate } from "@/i18n";
import { Database } from '@/integrations/supabase/types';
import { CategoryManager } from "@/components/widgets/CategoryManager";
import { widgetRegistry } from "@/lib/widgetRegistry";
type Layout = Database['public']['Tables']['layouts']['Row'];
interface Page {
id: string;
title: string;
content: any;
visible: boolean;
is_public: boolean;
owner: string;
slug: string;
parent: string | null;
meta?: any;
}
interface PageRibbonBarProps {
page: Page;
isOwner: boolean;
onToggleEditMode: () => void;
onPageUpdate: (updatedPage: Page) => void;
onDelete?: () => void;
onMetaUpdated?: () => void;
templates?: Layout[];
onLoadTemplate?: (template: Layout) => void;
onAddWidget?: (widgetId: string) => void;
onAddContainer?: () => void;
className?: string;
onUndo?: () => void;
onRedo?: () => void;
canUndo?: boolean;
canRedo?: boolean;
}
// Ribbon UI Components
const RibbonTab = ({ active, onClick, children }: { active: boolean, onClick: () => void, children: React.ReactNode }) => (
<button
onClick={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)]"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
)}
>
{children}
</button>
);
const RibbonGroup = ({ label, children }: { label: string, children: React.ReactNode }) => (
<div className="flex flex-col h-full border-r border-border/40 px-2 last:border-r-0 relative group">
<div className="flex-1 flex items-center gap-1 justify-center px-1">
{children}
</div>
<div className="text-[10px] text-center text-muted-foreground/70 uppercase tracking-wider font-semibold select-none pb-1 transition-colors group-hover:text-muted-foreground">
<T>{label}</T>
</div>
</div>
);
const RibbonItemLarge = ({
icon: Icon,
label,
onClick,
active,
iconColor = "text-foreground",
disabled = false
}: {
icon: any,
label: string,
onClick?: () => void,
active?: boolean,
iconColor?: string,
disabled?: boolean
}) => (
<button
onClick={onClick}
disabled={disabled}
className={cn(
"flex flex-col items-center justify-center h-16 min-w-[4rem] px-2 gap-1 rounded-md transition-all duration-200 group/btn",
!disabled && "hover:bg-accent/80 hover:shadow-sm hover:-translate-y-0.5 active:translate-y-0 active:shadow-inner",
active && "bg-blue-100/50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 ring-1 ring-blue-200 dark:ring-blue-800",
disabled && "opacity-40 cursor-not-allowed grayscale"
)}
title={label}
>
<div className={cn("transition-transform duration-200 group-hover/btn:scale-110 drop-shadow-sm", iconColor)}>
<Icon className="h-7 w-7" strokeWidth={1.5} />
</div>
<span className="text-[10px] font-medium leading-tight text-center whitespace-nowrap"><T>{label}</T></span>
</button>
);
const RibbonItemSmall = ({
icon: Icon,
label,
onClick,
active,
iconColor = "text-foreground",
disabled = false
}: {
icon: any,
label: string,
onClick?: () => void,
active?: boolean,
iconColor?: string,
disabled?: boolean
}) => (
<button
onClick={onClick}
disabled={disabled}
className={cn(
"flex items-center gap-2 px-2 py-0.5 h-7 w-full text-left rounded-sm transition-colors text-xs font-medium group/btn",
!disabled && "hover:bg-accent/60",
active && "bg-blue-100/40 dark:bg-blue-900/10 text-blue-700 dark:text-blue-300",
disabled && "opacity-50 cursor-not-allowed"
)}
>
<Icon className={cn("h-4 w-4 transition-colors", iconColor)} />
<span className="truncate"><T>{label}</T></span>
</button>
);
export const PageRibbonBar = ({
page,
isOwner,
onToggleEditMode,
onPageUpdate,
onDelete,
onMetaUpdated,
templates,
onLoadTemplate,
onAddWidget,
onAddContainer,
className,
onUndo,
onRedo,
canUndo = false,
canRedo = false
}: PageRibbonBarProps) => {
const [activeTab, setActiveTab] = useState<'page' | 'insert' | 'view' | 'advanced'>('page');
const [loading, setLoading] = useState(false);
const [showCategoryManager, setShowCategoryManager] = useState(false);
const [showPagePicker, setShowPagePicker] = useState(false);
// Logic duplicated from PageActions
const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
const invalidatePageCache = 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);
}
};
const handleToggleVisibility = async (e?: React.MouseEvent) => {
e?.stopPropagation();
if (loading) return;
setLoading(true);
try {
const { error } = await supabase
.from('pages')
.update({ visible: !page.visible })
.eq('id', page.id);
if (error) throw error;
onPageUpdate({ ...page, visible: !page.visible });
toast.success(translate(page.visible ? 'Page hidden' : 'Page made visible'));
invalidatePageCache();
} catch (error) {
console.error('Error toggling visibility:', error);
toast.error(translate('Failed to update page visibility'));
} finally {
setLoading(false);
}
};
const handleTogglePublic = async (e?: React.MouseEvent) => {
e?.stopPropagation();
if (loading) return;
setLoading(true);
try {
const { error } = await supabase
.from('pages')
.update({ is_public: !page.is_public })
.eq('id', page.id);
if (error) throw error;
onPageUpdate({ ...page, is_public: !page.is_public });
toast.success(translate(page.is_public ? 'Page made private' : 'Page made public'));
invalidatePageCache();
} catch (error) {
console.error('Error toggling public status:', error);
toast.error(translate('Failed to update page status'));
} finally {
setLoading(false);
}
};
const handleDumpJson = async () => {
try {
const pageJson = JSON.stringify(page, null, 2);
console.log('Page JSON:', pageJson);
await navigator.clipboard.writeText(pageJson);
toast.success("Page JSON dumped to console and clipboard");
} catch (e) {
console.error("Failed to dump JSON", e);
toast.error("Failed to dump JSON");
}
};
if (!isOwner) return null;
return (
<div className={cn("flex flex-col w-full bg-background border-b shadow-sm relative z-40", className)}>
{/* Context & Tabs Row */}
<div className="flex items-center border-b bg-muted/30">
<div className="px-4 py-1.5 bg-gradient-to-r from-blue-600 to-blue-500 text-white text-xs font-bold tracking-widest shadow-sm">
<T>DESIGN</T>
</div>
<div className="flex-1 flex overflow-x-auto scrollbar-none pl-2">
<RibbonTab active={activeTab === 'page'} onClick={() => setActiveTab('page')}><T>PAGE</T></RibbonTab>
<RibbonTab active={activeTab === 'insert'} onClick={() => setActiveTab('insert')}><T>INSERT</T></RibbonTab>
<RibbonTab active={activeTab === 'view'} onClick={() => setActiveTab('view')}><T>VIEW</T></RibbonTab>
<RibbonTab active={activeTab === 'advanced'} onClick={() => setActiveTab('advanced')}><T>ADVANCED</T></RibbonTab>
</div>
</div>
{/* Ribbon Toolbar Area */}
<div className="h-24 flex items-center bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 overflow-x-auto shadow-inner px-2">
{/* === PAGE TAB === */}
{activeTab === 'page' && (
<>
<RibbonGroup label="Manage">
<div className="flex flex-col gap-0.5 justify-center">
<RibbonItemSmall
icon={page.visible ? Eye : EyeOff}
label={page.visible ? "Visible" : "Hidden"}
active={!page.visible}
onClick={handleToggleVisibility}
iconColor={page.visible ? "text-emerald-500" : "text-gray-400"}
/>
<RibbonItemSmall
icon={page.is_public ? GitMerge : Settings}
label={page.is_public ? "Public" : "Private"}
active={!page.is_public}
onClick={handleTogglePublic}
iconColor={page.is_public ? "text-amber-500" : "text-gray-400"}
/>
</div>
<RibbonItemLarge
icon={FolderTree}
label="Categories"
onClick={() => setShowCategoryManager(true)}
iconColor="text-yellow-600 dark:text-yellow-400"
/>
</RibbonGroup>
<RibbonGroup label="History">
<div className="flex gap-1">
<RibbonItemLarge
icon={Undo2}
label="Undo"
onClick={onUndo}
disabled={!canUndo}
iconColor="text-purple-600 dark:text-purple-400"
/>
<RibbonItemLarge
icon={Redo2}
label="Redo"
onClick={onRedo}
disabled={!canRedo}
iconColor="text-purple-600 dark:text-purple-400"
/>
</div>
</RibbonGroup>
<RibbonGroup label="Actions">
<RibbonItemLarge
icon={Save}
label="Update"
onClick={onMetaUpdated}
iconColor="text-blue-600 dark:text-blue-400"
/>
{onDelete && (
<RibbonItemLarge
icon={Trash2}
label="Delete"
onClick={onDelete}
active
iconColor="text-red-500 hover:text-red-600"
/>
)}
</RibbonGroup>
<RibbonGroup label="Layouts">
{templates?.map(t => (
<RibbonItemLarge
key={t.id}
icon={LayoutTemplate}
label={t.name}
onClick={() => onLoadTemplate?.(t)}
iconColor="text-indigo-500 dark:text-indigo-400"
/>
))}
{(!templates || templates.length === 0) && (
<div className="text-xs text-muted-foreground px-2 italic"><T>No Layouts</T></div>
)}
</RibbonGroup>
</>
)}
{/* === INSERT TAB === */}
{activeTab === 'insert' && (
<>
<RibbonGroup label="Layout">
<RibbonItemLarge
icon={Grid}
label="Add Container"
onClick={onAddContainer}
iconColor="text-cyan-500"
/>
</RibbonGroup>
{/* Dynamic Widget Groups */}
{Array.from(new Set(widgetRegistry.getAll().map(w => w.metadata.category)))
.filter(cat => cat !== 'system' && cat !== 'hidden') // Filter internal categories if needed
.sort((a, b) => {
// Custom sort order: display, chart, control, others
const order = { display: 1, chart: 2, control: 3, custom: 4 };
return (order[a as keyof typeof order] || 99) - (order[b as keyof typeof order] || 99);
})
.map(category => {
const widgets = widgetRegistry.getByCategory(category);
if (widgets.length === 0) return null;
return (
<RibbonGroup key={category} label={category.charAt(0).toUpperCase() + category.slice(1)}>
<div className="flex flex-col flex-wrap justify-center h-full gap-y-1">
{/* If we have many items, use small items in grid/column, else large */}
{widgets.length <= 2 ? (
widgets.map(widget => (
<RibbonItemLarge
key={widget.metadata.id}
icon={widget.metadata.icon}
label={widget.metadata.name}
onClick={() => onAddWidget?.(widget.metadata.id)}
iconColor="text-blue-600 dark:text-blue-400"
/>
))
) : (
<div className="grid grid-cols-2 gap-x-2 gap-y-1">
{widgets.map(widget => (
<RibbonItemSmall
key={widget.metadata.id}
icon={widget.metadata.icon}
label={widget.metadata.name}
onClick={() => onAddWidget?.(widget.metadata.id)}
iconColor="text-blue-600 dark:text-blue-400"
/>
))}
</div>
)}
</div>
</RibbonGroup>
);
})
}
</>
)}
{/* === VIEW TAB === */}
{activeTab === 'view' && (
<>
<RibbonGroup label="Mode">
<RibbonItemLarge
icon={MousePointer2}
label="Finish Edit"
onClick={onToggleEditMode}
active
iconColor="text-green-600 dark:text-green-400"
/>
</RibbonGroup>
</>
)}
{/* === ADVANCED TAB === */}
{activeTab === 'advanced' && (
<>
<RibbonGroup label="Developer">
<RibbonItemLarge
icon={FileJson}
label="Dump JSON"
onClick={handleDumpJson}
iconColor="text-orange-600 dark:text-orange-400"
/>
</RibbonGroup>
</>
)}
{/* Always visible 'Finish' on far right? Or just in View tab. Fusion has 'Finish Sketch' big checkmark */}
<div className="ml-auto px-4 border-l flex items-center">
<Button
onClick={onToggleEditMode}
className="gap-2 bg-green-600 hover:bg-green-700 text-white shadow-md hover:shadow-lg transition-all"
>
<MousePointer2 className="h-4 w-4" />
<span className="font-semibold tracking-wide text-xs">FINISH</span>
</Button>
</div>
</div>
{/* Managed Dialogs */}
<CategoryManager
isOpen={showCategoryManager}
onClose={() => setShowCategoryManager(false)}
currentPageId={page.id}
currentPageMeta={page.meta}
onPageMetaUpdate={async (newMeta) => {
// Similar logic to handleMetaUpdate in PageActions
try {
const { updatePageMeta } = await import('@/lib/db');
await updatePageMeta(page.id, newMeta);
invalidatePageCache();
onPageUpdate({ ...page, meta: newMeta });
if (onMetaUpdated) onMetaUpdated();
} catch (error) {
console.error('Failed to update page meta:', error);
toast.error(translate('Failed to update categories'));
}
}}
filterByType="pages"
defaultMetaType="pages"
/>
</div>
);
};
export default PageRibbonBar;

View File

@ -1,4 +1,5 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
@ -9,6 +10,7 @@ import { toast } from "sonner";
import { Plus, Edit2, Trash2, FolderTree, Link as LinkIcon, Check, X, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { T } from "@/i18n";
interface CategoryManagerProps {
isOpen: boolean;
onClose: () => void;
@ -20,8 +22,8 @@ interface CategoryManagerProps {
}
export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMeta, onPageMetaUpdate, filterByType, defaultMetaType }: CategoryManagerProps) => {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(false);
// const [categories, setCategories] = useState<Category[]>([]); // Replaced by useQuery
// const [loading, setLoading] = useState(false); // Replaced by useQuery
const [actionLoading, setActionLoading] = useState(false);
// Selection state
@ -44,15 +46,10 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
const linkedCategoryIds = getLinkedCategoryIds();
useEffect(() => {
if (isOpen) {
loadCategories();
}
}, [isOpen]);
const loadCategories = async () => {
setLoading(true);
try {
// React Query Integration
const { data: categories = [], isLoading: loading } = useQuery({
queryKey: ['categories'],
queryFn: async () => {
const data = await fetchCategories({ includeChildren: true });
// Filter by type if specified
let filtered = filterByType
@ -60,17 +57,9 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
: data;
// Only show root-level categories (those without a parent)
// Children will be rendered recursively via the children property
filtered = filtered.filter(cat => !cat.parent_category_id);
setCategories(filtered);
} catch (error) {
console.error(error);
toast.error("Failed to load categories");
} finally {
setLoading(false);
return filtered.filter(cat => !cat.parent_category_id);
}
};
});
const handleCreateStart = (parentId: string | null = null) => {
setIsCreating(true);
@ -88,6 +77,8 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
setEditingCategory({ ...category });
};
const queryClient = useQueryClient();
const handleSave = async () => {
if (!editingCategory || !editingCategory.name || !editingCategory.slug) {
toast.error("Name and Slug are required");
@ -119,7 +110,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
toast.success("Category updated");
}
setEditingCategory(null);
loadCategories();
queryClient.invalidateQueries({ queryKey: ['categories'] });
} catch (error) {
console.error(error);
toast.error(isCreating ? "Failed to create category" : "Failed to update category");
@ -136,7 +127,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
try {
await deleteCategory(id);
toast.success("Category deleted");
loadCategories();
queryClient.invalidateQueries({ queryKey: ['categories'] });
if (selectedCategoryId === id) setSelectedCategoryId(null);
} catch (error) {
console.error(error);

View File

@ -0,0 +1,181 @@
import React, { useState, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Check, Wand2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { T } from '@/i18n';
interface TailwindClassGroup {
label: string;
classes: string[];
}
const TAILWIND_GROUPS: TailwindClassGroup[] = [
{
label: 'Layout & Sizing',
classes: [
'flex', 'grid', 'hidden', 'block', 'inline-block',
'flex-col', 'flex-row', 'flex-wrap',
'items-center', 'items-start', 'items-end', 'items-stretch',
'justify-center', 'justify-between', 'justify-start', 'justify-end',
'gap-1', 'gap-2', 'gap-4', 'gap-6', 'gap-8',
'w-full', 'h-full', 'w-screen', 'h-screen',
'max-w-sm', 'max-w-md', 'max-w-lg', 'max-w-xl', 'max-w-2xl',
'min-h-screen', 'min-h-[200px]',
'relative', 'absolute', 'fixed', 'sticky', 'top-0', 'left-0', 'z-10', 'z-50',
'overflow-hidden', 'overflow-auto'
]
},
{
label: 'Spacing',
classes: [
'p-0', 'p-1', 'p-2', 'p-4', 'p-6', 'p-8', 'p-12',
'px-2', 'px-4', 'px-6', 'px-8',
'py-2', 'py-4', 'py-6', 'py-8',
'm-0', 'm-2', 'm-4', 'm-8', 'm-auto',
'mt-2', 'mt-4', 'mt-8', 'mb-2', 'mb-4', 'mb-8',
'mx-auto'
]
},
{
label: 'Typography',
classes: [
'text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl', 'text-2xl', 'text-3xl', 'text-4xl', 'text-5xl',
'font-thin', 'font-light', 'font-normal', 'font-medium', 'font-semibold', 'font-bold', 'font-black',
'tracking-tighter', 'tracking-tight', 'tracking-normal', 'tracking-wide', 'tracking-wider',
'leading-none', 'leading-tight', 'leading-snug', 'leading-normal', 'leading-relaxed', 'leading-loose',
'text-center', 'text-left', 'text-right', 'text-justify',
'uppercase', 'lowercase', 'capitalize',
'underline', 'line-through', 'no-underline',
'truncate', 'break-words', 'whitespace-nowrap'
]
},
{
label: 'Colors (Semantic)',
classes: [
'text-foreground', 'text-muted-foreground', 'text-primary', 'text-primary-foreground',
'text-secondary', 'text-secondary-foreground', 'text-accent', 'text-accent-foreground',
'text-destructive', 'text-white', 'text-black', 'text-transparent',
'bg-background', 'bg-foreground', 'bg-card', 'bg-popover',
'bg-primary', 'bg-secondary', 'bg-muted', 'bg-accent', 'bg-destructive',
'bg-white', 'bg-black', 'bg-transparent'
]
},
{
label: 'Appearance',
classes: [
'border', 'border-2', 'border-t', 'border-b', 'border-input', 'border-primary', 'border-border',
'rounded-none', 'rounded-sm', 'rounded-md', 'rounded-lg', 'rounded-xl', 'rounded-2xl', 'rounded-full',
'shadow-sm', 'shadow', 'shadow-md', 'shadow-lg', 'shadow-xl', 'shadow-2xl', 'shadow-none',
'opacity-0', 'opacity-25', 'opacity-50', 'opacity-75', 'opacity-100',
'backdrop-blur-none', 'backdrop-blur-sm', 'backdrop-blur', 'backdrop-blur-md', 'backdrop-blur-lg', 'backdrop-blur-xl',
'grayscale', 'grayscale-0', 'invert', 'invert-0'
]
},
{
label: 'Animation (Enter/Exit)',
classes: [
'animate-in', 'animate-out',
'fade-in', 'fade-out',
'zoom-in', 'zoom-out', 'zoom-in-95', 'zoom-in-50',
'slide-in-from-top-2', 'slide-in-from-bottom-2', 'slide-in-from-left-2', 'slide-in-from-right-2',
'duration-100', 'duration-200', 'duration-300', 'duration-500', 'duration-700', 'duration-1000',
'ease-in', 'ease-out', 'ease-in-out',
'delay-100', 'delay-200', 'delay-300', 'delay-500'
]
}
];
interface TailwindClassPickerProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}
export const TailwindClassPicker: React.FC<TailwindClassPickerProps> = ({
value = '',
onChange,
placeholder = "Select classes...",
className
}) => {
const [open, setOpen] = useState(false);
// Split current value into array of classes
const currentClasses = useMemo(() => {
return value.trim().split(/\s+/).filter(Boolean);
}, [value]);
const handleSelect = (cls: string) => {
const newClasses = currentClasses.includes(cls)
? currentClasses.filter(c => c !== cls)
: [...currentClasses, cls];
onChange(newClasses.join(' '));
};
const removeClass = (cls: string) => {
onChange(currentClasses.filter(c => c !== cls).join(' '));
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
};
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="relative flex-1">
<Input
value={value}
onChange={handleInputChange}
placeholder={placeholder}
className="font-mono text-xs pr-8"
/>
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-9 w-9 shrink-0"
title="Pick Tailwind Classes"
>
<Wand2 className="h-4 w-4 text-blue-500" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="end">
<Command>
<CommandInput placeholder="Search classes..." />
<CommandList className="max-h-[300px]">
<CommandEmpty>No class found.</CommandEmpty>
{TAILWIND_GROUPS.map((group) => (
<CommandGroup key={group.label} heading={group.label}>
{group.classes.map((cls) => (
<CommandItem
key={cls}
value={cls}
onSelect={() => handleSelect(cls)}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
currentClasses.includes(cls) ? "opacity-100" : "opacity-0"
)}
/>
{cls}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
};

View File

@ -11,6 +11,7 @@ import { Image as ImageIcon, Maximize2 } from 'lucide-react';
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import MarkdownEditor from '@/components/MarkdownEditorEx';
import { TailwindClassPicker } from './TailwindClassPicker';
export interface WidgetPropertiesFormProps {
widgetDefinition: WidgetDefinition;
@ -233,38 +234,79 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
return (
<div className="space-y-4">
{/* Widget ID Helper (Editable) */}
{widgetInstanceId && onRename && (
<div className="space-y-1 pb-4 border-b border-border">
<Label htmlFor="widget-id-editor" className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Widget ID (Variable Name)
</Label>
<div className="flex gap-2">
<Input
key={widgetInstanceId} // Force remount when widget changes to update defaultValue
id="widget-id-editor"
defaultValue={widgetInstanceId}
className="h-8 font-mono text-xs bg-muted/50"
onBlur={(e) => {
const val = e.target.value.trim();
if (val && val !== widgetInstanceId) {
onRename(val);
} else {
e.target.value = widgetInstanceId; // Reset if empty or same
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
}
}}
/>
{/* General Properties */}
<div className="space-y-4 pb-4 border-b border-border">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
General
</Label>
{/* Widget ID Helper (Editable) */}
{widgetInstanceId && onRename && (
<div className="space-y-2">
<Label htmlFor="widget-id-editor" className="text-xs font-medium text-slate-500 dark:text-slate-400">
Widget ID (Variable Name)
</Label>
<div className="flex gap-2">
<Input
key={widgetInstanceId} // Force remount when widget changes to update defaultValue
id="widget-id-editor"
defaultValue={widgetInstanceId}
className="h-8 font-mono text-xs bg-muted/50"
onBlur={(e) => {
const val = e.target.value.trim();
if (val && val !== widgetInstanceId) {
onRename(val);
} else {
e.target.value = widgetInstanceId; // Reset if empty or same
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
}
}}
/>
</div>
<p className="text-[10px] text-muted-foreground">
Unique identifier used for templating key reference.
</p>
</div>
<p className="text-[10px] text-muted-foreground">
Unique identifier used for templating key reference.
</p>
)}
{/* Enabled Toggle */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="widget-enabled" className="text-xs font-medium">Enabled</Label>
<p className="text-[10px] text-muted-foreground">Turn off to hide this widget.</p>
</div>
<Switch
id="widget-enabled"
checked={settings.enabled !== false} // Default to true
onCheckedChange={(checked) => updateSetting('enabled', checked)}
className="scale-90"
/>
</div>
)}
</div>
{/* Custom CSS Class Editor */}
<div className="space-y-1 pb-4 border-b border-border">
<Label htmlFor="widget-class-editor" className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
CSS Class
</Label>
<div className="flex gap-2">
<TailwindClassPicker
value={settings.customClassName || ''}
onChange={(newValue) => updateSetting('customClassName', newValue)}
placeholder={widgetInstanceId ? `widget-${widgetInstanceId.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : 'Select classes...'}
className="w-full"
/>
</div>
<p className="text-[10px] text-muted-foreground">
Custom CSS class for this widget instance. Defaults to widget-ID.
</p>
</div>
<div className="space-y-4">
{Object.entries(configSchema).map(([key, config]) =>

View File

@ -13,6 +13,7 @@ interface WidgetSettingsManagerProps {
currentProps: Record<string, any>;
widgetInstanceId?: string;
onRename?: (newId: string) => void;
onCancel?: () => void;
}
const WidgetSettingsManagerComponent: React.FC<WidgetSettingsManagerProps> = ({
@ -22,7 +23,8 @@ const WidgetSettingsManagerComponent: React.FC<WidgetSettingsManagerProps> = ({
widgetDefinition,
currentProps,
widgetInstanceId,
onRename
onRename,
onCancel
}) => {
const [settings, setSettings] = useState<Record<string, any>>(currentProps);
@ -42,11 +44,22 @@ const WidgetSettingsManagerComponent: React.FC<WidgetSettingsManagerProps> = ({
const handleCancel = () => {
setSettings(currentProps);
if (onCancel) {
onCancel();
}
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={isOpen} onOpenChange={(open) => {
if (!open) {
// Treat closing via backdrop/ESC as cancel
handleCancel();
} else {
// Should be handled by isOpen prop, but Dialog might invoke this
// This branch is rarely hit if controlled, but good for safety
}
}}>
<DialogContent className="sm:max-w-lg max-w-[90vw]">
<DialogHeader>
<DialogTitle>

View File

@ -1,6 +1,9 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import { UnifiedLayoutManager, PageLayout, WidgetInstance, LayoutContainer } from '@/lib/unifiedLayoutManager';
import { widgetRegistry } from '@/lib/widgetRegistry';
import { HistoryManager } from '@/lib/page-commands/HistoryManager';
import { CommandContext } from '@/lib/page-commands/types';
import { AddWidgetCommand, RemoveWidgetCommand, UpdateWidgetSettingsCommand } from '@/lib/page-commands/commands';
interface LayoutContextType {
// Generic page management
@ -26,6 +29,12 @@ interface LayoutContextType {
// Manual save
saveToApi: () => Promise<boolean>;
// History Actions
undo: () => Promise<void>;
redo: () => Promise<void>;
canUndo: boolean;
canRedo: boolean;
// State
isLoading: boolean;
loadedPages: Map<string, PageLayout>;
@ -41,6 +50,45 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
const [loadedPages, setLoadedPages] = useState<Map<string, PageLayout>>(new Map());
const [isLoading, setIsLoading] = useState(true);
// History State
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const updateLayoutCallback = useCallback((pageId: string, layout: PageLayout) => {
setLoadedPages(prev => new Map(prev).set(pageId, layout));
UnifiedLayoutManager.savePageLayout(layout).catch(e => console.error("Failed to persist layout update", e));
}, []);
const [historyManager] = useState(() => new HistoryManager({
pageId: '', // Placeholder, locally updated in execute
layouts: loadedPages, // Placeholder, updated in execute
updateLayout: updateLayoutCallback
}));
// Update history UI state
const updateHistoryState = useCallback(() => {
setCanUndo(historyManager.canUndo());
setCanRedo(historyManager.canRedo());
}, [historyManager]);
// Helper to persist to storage (database/localStorage)
const saveLayoutToCache = useCallback(async (pageId: string, layout?: PageLayout) => {
const currentLayout = layout || loadedPages.get(pageId);
if (!currentLayout) {
console.error(`Cannot save page ${pageId}: layout not loaded`);
return;
}
try {
await UnifiedLayoutManager.savePageLayout(currentLayout);
// Ensure state is in sync if we modified a clone
setLoadedPages(prev => new Map(prev).set(pageId, currentLayout));
} catch (e) {
console.error("Failed to save layout", e);
}
}, [loadedPages]);
// Initialize layouts on mount
useEffect(() => {
const initializeLayouts = async () => {
@ -66,10 +114,6 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
initializeLayouts();
}, []);
useEffect(() => {
}, [loadedPages]);
const loadPageLayout = async (pageId: string, defaultName?: string) => {
// Only load if not already cached
if (!loadedPages.has(pageId)) {
@ -99,103 +143,86 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
name: currentLayout.name,
containers: [
{
id: UnifiedLayoutManager.generateContainerId(),
type: 'container',
id: crypto.randomUUID(),
type: 'container', // Using 'container' as per ULM default, was 'grid' in previous context but 'container' in ULM
columns: 1,
gap: 16,
gap: 16, // ULM default
widgets: [],
children: [],
order: 0
}
],
// Preserve created if exists
createdAt: currentLayout.createdAt,
updatedAt: Date.now()
};
// Update the in-memory cache
setLoadedPages(prev => new Map(prev).set(pageId, clearedLayout));
// Save to localStorage cache
await saveLayoutToCache(pageId);
// Also clear persistence
await UnifiedLayoutManager.savePageLayout(clearedLayout);
// Clear history as this is a destructive reset
historyManager.clear();
updateHistoryState();
} catch (error) {
console.error(`Failed to clear page layout ${pageId}:`, error);
console.error('Failed to clear layout:', error);
throw error;
}
};
const saveLayoutToCache = async (pageId: string) => {
const currentLayout = loadedPages.get(pageId);
if (!currentLayout) {
console.error(`Cannot save page ${pageId}: layout not loaded`);
return;
}
// Save the specific page to database
await UnifiedLayoutManager.savePageLayout(currentLayout);
// Force re-render of the specific page with deep clone
// Create a deep clone to ensure React detects the change
const clonedLayout = JSON.parse(JSON.stringify(currentLayout));
setLoadedPages(prev => new Map(prev).set(pageId, clonedLayout));
};
const addWidgetToPage = async (pageId: string, containerId: string, widgetId: string, targetColumn?: number): Promise<WidgetInstance> => {
try {
const currentLayout = loadedPages.get(pageId);
if (!currentLayout) {
throw new Error(`Layout for page ${pageId} not loaded`);
}
const currentLayout = loadedPages.get(pageId);
if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`);
const widget = UnifiedLayoutManager.addWidgetToContainer(currentLayout, containerId, widgetId, targetColumn);
currentLayout.updatedAt = Date.now();
const container = UnifiedLayoutManager.findContainer(currentLayout.containers, containerId);
if (!container) throw new Error(`Container ${containerId} not found`);
await saveLayoutToCache(pageId);
const index = UnifiedLayoutManager.calculateWidgetInsertionIndex(container, targetColumn);
const newWidget = UnifiedLayoutManager.createWidgetInstance(widgetId);
return widget;
} catch (error) {
console.error(`Failed to add widget to page ${pageId}:`, error);
throw error;
}
// Command takes the resolved index
const command = new AddWidgetCommand(pageId, containerId, newWidget, index);
await historyManager.execute(command, {
pageId,
layouts: loadedPages,
updateLayout: updateLayoutCallback
});
updateHistoryState();
return newWidget;
};
const removeWidgetFromPage = async (pageId: string, widgetInstanceId: string) => {
try {
const currentLayout = loadedPages.get(pageId);
if (!currentLayout) {
throw new Error(`Layout for page ${pageId} not loaded`);
}
const command = new RemoveWidgetCommand(pageId, widgetInstanceId);
const removed = UnifiedLayoutManager.removeWidgetFromContainer(currentLayout, widgetInstanceId);
if (!removed) {
throw new Error(`Widget ${widgetInstanceId} not found`);
}
currentLayout.updatedAt = Date.now();
await saveLayoutToCache(pageId);
} catch (error) {
console.error(`Failed to remove widget from page ${pageId}:`, error);
throw error;
}
await historyManager.execute(command, {
pageId,
layouts: loadedPages,
updateLayout: updateLayoutCallback
});
updateHistoryState();
};
// --- Legacy / Non-Command Implementation for now (Todo: migrate) ---
const moveWidgetInPage = async (pageId: string, widgetInstanceId: string, direction: 'up' | 'down' | 'left' | 'right') => {
try {
const currentLayout = loadedPages.get(pageId);
if (!currentLayout) {
throw new Error(`Layout for page ${pageId} not loaded`);
if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`);
const newLayout = JSON.parse(JSON.stringify(currentLayout)) as PageLayout;
const success = UnifiedLayoutManager.moveWidgetInContainer(newLayout, widgetInstanceId, direction);
if (success) {
setLoadedPages(prev => new Map(prev).set(pageId, newLayout));
await saveLayoutToCache(pageId, newLayout);
}
const moved = UnifiedLayoutManager.moveWidgetInContainer(currentLayout, widgetInstanceId, direction);
if (!moved) {
throw new Error(`Failed to move widget ${widgetInstanceId}`);
}
currentLayout.updatedAt = Date.now();
await saveLayoutToCache(pageId);
} catch (error) {
console.error(`Failed to move widget in page ${pageId}:`, error);
console.error('Failed to move widget:', error);
throw error;
}
};
@ -203,20 +230,17 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
const updatePageContainerColumns = async (pageId: string, containerId: string, columns: number) => {
try {
const currentLayout = loadedPages.get(pageId);
if (!currentLayout) {
throw new Error(`Layout for page ${pageId} not loaded`);
if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`);
const newLayout = JSON.parse(JSON.stringify(currentLayout)) as PageLayout;
const success = UnifiedLayoutManager.updateContainerColumns(newLayout, containerId, columns);
if (success) {
setLoadedPages(prev => new Map(prev).set(pageId, newLayout));
await saveLayoutToCache(pageId, newLayout);
}
const updated = UnifiedLayoutManager.updateContainerColumns(currentLayout, containerId, columns);
if (!updated) {
throw new Error(`Container ${containerId} not found`);
}
currentLayout.updatedAt = Date.now();
await saveLayoutToCache(pageId);
} catch (error) {
console.error(`Failed to update container columns in page ${pageId}:`, error);
console.error('Failed to update columns:', error);
throw error;
}
};
@ -224,39 +248,35 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
const updatePageContainerSettings = async (pageId: string, containerId: string, settings: Partial<LayoutContainer['settings']>) => {
try {
const currentLayout = loadedPages.get(pageId);
if (!currentLayout) {
throw new Error(`Layout for page ${pageId} not loaded`);
if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`);
const newLayout = JSON.parse(JSON.stringify(currentLayout)) as PageLayout;
const success = UnifiedLayoutManager.updateContainerSettings(newLayout, containerId, settings);
if (success) {
setLoadedPages(prev => new Map(prev).set(pageId, newLayout));
await saveLayoutToCache(pageId, newLayout);
}
const updated = UnifiedLayoutManager.updateContainerSettings(currentLayout, containerId, settings);
if (!updated) {
throw new Error(`Container ${containerId} not found`);
}
currentLayout.updatedAt = Date.now();
await saveLayoutToCache(pageId);
} catch (error) {
console.error(`Failed to update container settings in page ${pageId}:`, error);
console.error('Failed to update container settings:', error);
throw error;
}
};
const addPageContainer = async (pageId: string, parentContainerId?: string): Promise<LayoutContainer> => {
const addPageContainer = async (pageId: string, parentContainerId?: string) => {
try {
const currentLayout = loadedPages.get(pageId);
if (!currentLayout) {
throw new Error(`Layout for page ${pageId} not loaded`);
}
if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`);
const container = UnifiedLayoutManager.addContainer(currentLayout, parentContainerId);
currentLayout.updatedAt = Date.now();
const newLayout = JSON.parse(JSON.stringify(currentLayout)) as PageLayout;
const newContainer = UnifiedLayoutManager.addContainer(newLayout, parentContainerId); // Mutates newLayout AND returns container
await saveLayoutToCache(pageId);
setLoadedPages(prev => new Map(prev).set(pageId, newLayout));
await saveLayoutToCache(pageId, newLayout);
return container;
return newContainer;
} catch (error) {
console.error(`Failed to add container to page ${pageId}:`, error);
console.error('Failed to add container:', error);
throw error;
}
};
@ -264,23 +284,17 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
const removePageContainer = async (pageId: string, containerId: string) => {
try {
const currentLayout = loadedPages.get(pageId);
if (!currentLayout) {
throw new Error(`Layout for page ${pageId} not loaded`);
if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`);
const newLayout = JSON.parse(JSON.stringify(currentLayout)) as PageLayout;
const success = UnifiedLayoutManager.removeContainer(newLayout, containerId);
if (success) {
setLoadedPages(prev => new Map(prev).set(pageId, newLayout));
await saveLayoutToCache(pageId, newLayout);
}
// For extension slots, allow removing the last container (to effectively remove the canvas)
const isExtensionSlot = pageId.includes('-slot-');
const removed = UnifiedLayoutManager.removeContainer(currentLayout, containerId, isExtensionSlot);
if (!removed) {
throw new Error(`Container ${containerId} not found or cannot be removed`);
}
currentLayout.updatedAt = Date.now();
await saveLayoutToCache(pageId);
} catch (error) {
console.error(`Failed to remove container from page ${pageId}:`, error);
console.error('Failed to remove container:', error);
throw error;
}
};
@ -288,42 +302,34 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
const movePageContainer = async (pageId: string, containerId: string, direction: 'up' | 'down') => {
try {
const currentLayout = loadedPages.get(pageId);
if (!currentLayout) {
throw new Error(`Layout for page ${pageId} not loaded`);
if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`);
const newLayout = JSON.parse(JSON.stringify(currentLayout)) as PageLayout;
const success = UnifiedLayoutManager.moveRootContainer(newLayout, containerId, direction);
if (success) {
setLoadedPages(prev => new Map(prev).set(pageId, newLayout));
await saveLayoutToCache(pageId, newLayout);
}
const moved = UnifiedLayoutManager.moveRootContainer(currentLayout, containerId, direction);
if (!moved) {
// This can fail gracefully if the container is at the top/bottom, so no error needed.
return;
}
currentLayout.updatedAt = Date.now();
await saveLayoutToCache(pageId);
} catch (error) {
console.error(`Failed to move container in page ${pageId}:`, error);
console.error('Failed to move container:', error);
throw error;
}
};
const updateWidgetProps = async (pageId: string, widgetInstanceId: string, props: Record<string, any>) => {
try {
const currentLayout = loadedPages.get(pageId);
if (!currentLayout) {
throw new Error(`Layout for page ${pageId} not loaded`);
}
const command = new UpdateWidgetSettingsCommand(pageId, widgetInstanceId, props);
const updated = UnifiedLayoutManager.updateWidgetProps(currentLayout, widgetInstanceId, props);
if (!updated) {
throw new Error(`Widget ${widgetInstanceId} not found`);
}
await historyManager.execute(command, {
pageId,
layouts: loadedPages,
updateLayout: updateLayoutCallback
});
currentLayout.updatedAt = Date.now();
await saveLayoutToCache(pageId);
updateHistoryState();
} catch (error) {
console.error(`Failed to update widget props in page ${pageId}:`, error);
console.error('Failed to update widget props:', error);
throw error;
}
};
@ -331,110 +337,99 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
const renameWidget = async (pageId: string, widgetInstanceId: string, newId: string): Promise<boolean> => {
try {
const currentLayout = loadedPages.get(pageId);
if (!currentLayout) {
throw new Error(`Layout for page ${pageId} not loaded`);
if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`);
const newLayout = JSON.parse(JSON.stringify(currentLayout)) as PageLayout;
const success = UnifiedLayoutManager.renameWidget(newLayout, widgetInstanceId, newId);
if (success) {
setLoadedPages(prev => new Map(prev).set(pageId, newLayout));
await saveLayoutToCache(pageId, newLayout);
return true;
}
const success = UnifiedLayoutManager.renameWidget(currentLayout, widgetInstanceId, newId);
if (!success) {
return false;
}
currentLayout.updatedAt = Date.now();
await saveLayoutToCache(pageId);
return true;
return false;
} catch (error) {
console.error(`Failed to rename widget in page ${pageId}:`, error);
console.error('Failed to rename widget:', error);
throw error;
}
};
const exportPageLayout = async (pageId: string): Promise<string> => {
try {
// Export directly from memory state to ensure consistency with UI
const currentLayout = loadedPages.get(pageId);
if (currentLayout) {
return JSON.stringify(currentLayout, null, 2);
}
// Fallback to ULM if not loaded (though it should be)
return await UnifiedLayoutManager.exportPageLayout(pageId);
} catch (error) {
console.error(`Failed to export page layout ${pageId}:`, error);
throw error;
}
};
const importPageLayout = async (pageId: string, jsonData: string): Promise<PageLayout> => {
try {
console.log(`[LayoutContext] Importing page layout for ${pageId}...`);
const layout = await UnifiedLayoutManager.importPageLayout(pageId, jsonData);
console.log('[LayoutContext] Layout imported successfully from ULM:', layout);
// Directly update the state with the newly imported layout
setLoadedPages(prev => {
const newPages = new Map(prev);
newPages.set(pageId, layout);
console.log('[LayoutContext] Updating loadedPages state with new layout for pageId:', pageId, newPages);
return newPages;
});
return layout;
} catch (error) {
console.error(`[LayoutContext] Failed to import page layout ${pageId}:`, error);
throw error;
}
};
const hydratePageLayout = (pageId: string, layout: PageLayout) => {
// Only set if not already loaded or if we want to force update (usually we want to trust the prop)
// But check timestamps? No, if we pass explicit data, we assume it's fresh.
if (!loadedPages.has(pageId)) {
setLoadedPages(prev => new Map(prev).set(pageId, layout));
}
};
const saveToApi = async (): Promise<boolean> => {
return true;
};
const exportPageLayout = async (pageId: string): Promise<string> => {
const layout = loadedPages.get(pageId);
if (!layout) throw new Error('Layout not found');
return JSON.stringify(layout, null, 2);
};
const importPageLayout = async (pageId: string, jsonData: string): Promise<PageLayout> => {
try {
// Save all loaded pages to database
let allSaved = true;
for (const [pageId, layout] of loadedPages.entries()) {
try {
await UnifiedLayoutManager.savePageLayout(layout);
} catch (error) {
console.error(`Failed to save page ${pageId}:`, error);
allSaved = false;
}
}
return allSaved;
} catch (error) {
console.error('Failed to save layouts to database:', error);
return false;
const importedLayout = JSON.parse(jsonData) as PageLayout;
importedLayout.id = pageId;
setLoadedPages(prev => new Map(prev).set(pageId, importedLayout));
await UnifiedLayoutManager.savePageLayout(importedLayout);
historyManager.clear();
updateHistoryState();
return importedLayout;
} catch (e) {
console.error('Failed to import layout:', e);
throw e;
}
};
const value: LayoutContextType = {
loadPageLayout,
getLoadedPageLayout,
clearPageLayout,
addWidgetToPage,
removeWidgetFromPage,
moveWidgetInPage,
updatePageContainerColumns,
updatePageContainerSettings,
addPageContainer,
removePageContainer,
movePageContainer,
updateWidgetProps,
exportPageLayout,
importPageLayout,
hydratePageLayout,
saveToApi,
isLoading,
loadedPages,
renameWidget,
const hydratePageLayout = (pageId: string, layout: PageLayout) => {
setLoadedPages(prev => new Map(prev).set(pageId, layout));
};
const undo = async () => {
await historyManager.undo({
pageId: '',
layouts: loadedPages,
updateLayout: updateLayoutCallback
});
updateHistoryState();
};
const redo = async () => {
await historyManager.redo({
pageId: '',
layouts: loadedPages,
updateLayout: updateLayoutCallback
});
updateHistoryState();
};
return (
<LayoutContext.Provider value={value}>
<LayoutContext.Provider value={{
loadPageLayout,
getLoadedPageLayout,
clearPageLayout,
addWidgetToPage,
removeWidgetFromPage,
moveWidgetInPage,
updatePageContainerColumns,
updatePageContainerSettings,
addPageContainer,
removePageContainer,
movePageContainer,
updateWidgetProps,
renameWidget,
saveToApi,
exportPageLayout,
importPageLayout,
hydratePageLayout,
isLoading,
loadedPages,
undo,
redo,
canUndo,
canRedo
}}>
{children}
</LayoutContext.Provider>
);

View File

@ -0,0 +1,122 @@
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { AppEvent } from '../types-server';
import logger from '@/Logger';
type StreamStatus = 'DISCONNECTED' | 'CONNECTING' | 'CONNECTED' | 'ERROR';
interface StreamContextType {
status: StreamStatus;
lastEvent: AppEvent | null;
isConnected: boolean;
subscribe: (callback: (event: AppEvent) => void) => () => void;
}
const StreamContext = createContext<StreamContextType | undefined>(undefined);
export const useStream = () => {
const context = useContext(StreamContext);
if (context === undefined) {
throw new Error('useStream must be used within a StreamProvider');
}
return context;
};
interface StreamProviderProps {
children: ReactNode;
url?: string;
}
export const StreamProvider: React.FC<StreamProviderProps> = ({ children, url }) => {
const [status, setStatus] = useState<StreamStatus>('DISCONNECTED');
const [lastEvent, setLastEvent] = useState<AppEvent | null>(null);
const listenersRef = React.useRef<Set<(event: AppEvent) => void>>(new Set());
const subscribe = React.useCallback((callback: (event: AppEvent) => void) => {
listenersRef.current.add(callback);
return () => listenersRef.current.delete(callback);
}, []);
useEffect(() => {
if (!url) return;
let eventSource: EventSource | null = null;
let reconnectTimer: NodeJS.Timeout | null = null;
const connect = () => {
setStatus('CONNECTING');
// Append /api/stream if not present, handling trailing slashes
const baseUrl = url.replace(/\/+$/, '');
const streamUrl = `${baseUrl}/api/stream`;
try {
eventSource = new EventSource(streamUrl);
eventSource.onopen = () => {
setStatus('CONNECTED');
// Clear any pending reconnect
if (reconnectTimer) clearTimeout(reconnectTimer);
};
eventSource.onerror = (err) => {
setStatus('ERROR');
eventSource?.close();
// Auto-reconnect after 5s
reconnectTimer = setTimeout(() => {
connect();
}, 10000);
};
// Listen for 'connected' event (handshake)
eventSource.addEventListener('connected', (e) => {
const data = JSON.parse(e.data);
});
// Listen for specific event types
// We listen to the generic 'app-update' if the server sends it,
// OR specific kinds like 'cache', 'system' if the server sends named events.
// The server implementation sends: event: event.kind ('cache', 'system', etc.)
const handleEvent = (e: MessageEvent) => {
try {
const eventData: AppEvent = JSON.parse(e.data);
// 1. Notify listeners (synchronously/immediately)
listenersRef.current.forEach(listener => listener(eventData));
// 2. Update state (subject to React batching, useful for debug/simple UI)
setLastEvent(eventData);
console.log('Stream event received', eventData);
// logger.debug('Stream event received', eventData);
} catch (err) {
logger.error('Failed to parse stream event', err);
}
};
// Add listeners for known event kinds
eventSource.addEventListener('cache', handleEvent);
eventSource.addEventListener('system', handleEvent);
eventSource.addEventListener('chat', handleEvent);
eventSource.addEventListener('other', handleEvent);
} catch (err) {
logger.error('Failed to initialize EventSource', err);
setStatus('ERROR');
}
};
connect();
return () => {
logger.info('Closing EventStream');
eventSource?.close();
if (reconnectTimer) clearTimeout(reconnectTimer);
};
}, [url]);
return (
<StreamContext.Provider value={{ status, lastEvent, isConnected: status === 'CONNECTED', subscribe }}>
{children}
</StreamContext.Provider>
);
};

View File

@ -100,6 +100,28 @@ export const invalidateCache = (key: string) => {
}
};
export const invalidateServerCache = async (types: string[]) => {
try {
const { supabase } = await import("@/integrations/supabase/client");
const session = await supabase.auth.getSession();
const token = session.data.session?.access_token;
if (!token) return;
const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
await fetch(`${baseUrl}/api/cache/invalidate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ types })
});
} catch (e) {
console.error('Failed to invalidate server cache:', e);
}
};
export const fetchPostById = async (id: string, client?: SupabaseClient) => {
// Use API-mediated fetching instead of direct Supabase calls
// This returns enriched FeedPost data including category_paths, author info, etc.
@ -305,6 +327,9 @@ export const updatePostDetails = async (postId: string, updates: { title: string
if (requestCache.has(cacheKey)) requestCache.delete(cacheKey);
const fullCacheKey = `full-post-${postId}`;
if (requestCache.has(fullCacheKey)) requestCache.delete(fullCacheKey);
// Invalidate Server Cache
await invalidateServerCache(['posts']);
};
export const unlinkPictures = async (ids: string[], client?: SupabaseClient) => {

View File

@ -0,0 +1,55 @@
import { Command, CommandContext } from './types';
export class HistoryManager {
private past: Command[] = [];
private future: Command[] = [];
private context: CommandContext;
constructor(context: CommandContext) {
this.context = context;
}
public async execute(command: Command, context?: CommandContext): Promise<void> {
if (context) this.context = context;
await command.execute(this.context);
this.past.push(command);
this.future = []; // Clear redo stack on new action
}
public async undo(context?: CommandContext): Promise<void> {
if (this.past.length === 0) return;
if (context) this.context = context;
const command = this.past.pop();
if (command) {
await command.undo(this.context);
this.future.push(command);
}
}
public async redo(context?: CommandContext): Promise<void> {
if (this.future.length === 0) return;
if (context) this.context = context;
const command = this.future.pop();
if (command) {
await command.execute(this.context);
this.past.push(command);
}
}
public canUndo(): boolean {
return this.past.length > 0;
}
public canRedo(): boolean {
return this.future.length > 0;
}
public clear(): void {
this.past = [];
this.future = [];
}
}

View File

@ -0,0 +1,246 @@
import { Command, CommandContext } from './types';
import { WidgetInstance, PageLayout, LayoutContainer } from '@/lib/unifiedLayoutManager';
// Helper to find a container by ID in the layout tree
const findContainer = (containers: LayoutContainer[], id: string): LayoutContainer | null => {
for (const container of containers) {
if (container.id === id) return container;
if (container.children) {
const found = findContainer(container.children, id);
if (found) return found;
}
}
return null;
};
// Helper to finding parent container
const findParentContainer = (containers: LayoutContainer[], childId: string): LayoutContainer | null => {
for (const container of containers) {
if (container.children.some(c => c.id === childId)) return container;
const found = findParentContainer(container.children, childId);
if (found) return found;
}
return null;
}
// Helper to find widget location
const findWidgetLocation = (containers: LayoutContainer[], widgetId: string): { container: LayoutContainer, index: number, widget: WidgetInstance } | null => {
for (const container of containers) {
const idx = container.widgets.findIndex(w => w.id === widgetId);
if (idx !== -1) {
return { container, index: idx, widget: container.widgets[idx] };
}
if (container.children) {
const found = findWidgetLocation(container.children, widgetId);
if (found) return found;
}
}
return null;
};
// --- Add Widget Command ---
export class AddWidgetCommand implements Command {
id: string;
type = 'ADD_WIDGET';
timestamp: number;
private pageId: string;
private containerId: string;
private widget: WidgetInstance;
private index: number;
constructor(pageId: string, containerId: string, widget: WidgetInstance, index: number = -1) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.containerId = containerId;
this.widget = widget;
this.index = index;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
// Clone layout to avoid mutation
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}`);
if (this.index === -1) {
container.widgets.push(this.widget);
} else {
container.widgets.splice(this.index, 0, this.widget);
}
context.updateLayout(this.pageId, newLayout);
}
async undo(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
// Find widget anywhere (robust against moves)
const location = findWidgetLocation(newLayout.containers, this.widget.id);
if (location) {
location.container.widgets.splice(location.index, 1);
context.updateLayout(this.pageId, newLayout);
} else {
console.warn(`Widget ${this.widget.id} not found for undo add`);
}
}
}
// --- Remove Widget Command ---
export class RemoveWidgetCommand implements Command {
id: string;
type = 'REMOVE_WIDGET';
timestamp: number;
private pageId: string;
private widgetId: string;
// State capture for undo
private containerId: string | null = null;
private index: number = -1;
private widget: WidgetInstance | null = null;
constructor(pageId: string, widgetId: string) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.widgetId = widgetId;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
// 1. Capture state BEFORE modification
const location = findWidgetLocation(layout.containers, this.widgetId);
if (!location) {
console.warn(`Widget ${this.widgetId} not found for removal`);
return;
}
this.containerId = location.container.id;
this.index = location.index;
this.widget = location.widget;
// 2. Perform Removal
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const newLocation = findWidgetLocation(newLayout.containers, this.widgetId);
if (newLocation) {
newLocation.container.widgets.splice(newLocation.index, 1);
context.updateLayout(this.pageId, newLayout);
}
}
async undo(context: CommandContext): Promise<void> {
if (!this.containerId || !this.widget || this.index === -1) {
throw new Error("Cannot undo remove: State was not captured correctly");
}
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(`Original container ${this.containerId} not found`);
// Restore widget at original index
// Ensure index is valid
if (this.index > container.widgets.length) {
container.widgets.push(this.widget);
} else {
container.widgets.splice(this.index, 0, this.widget);
}
context.updateLayout(this.pageId, newLayout);
}
}
// --- Update Widget Settings Command ---
export class UpdateWidgetSettingsCommand implements Command {
id: string;
type = 'UPDATE_WIDGET_SETTINGS';
timestamp: number;
private pageId: string;
private widgetId: string;
private newSettings: Record<string, any>; // Store full new settings or partial? Partial is what comes in.
// State capture for undo
private oldSettings: Record<string, any> | null = null;
constructor(pageId: string, widgetId: string, newSettings: Record<string, any>) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.widgetId = widgetId;
this.newSettings = newSettings;
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
// 1. Capture state (snapshot of current props) BEFORE modification
// We need to find the widget in the CURRENT layout to get old props
const location = findWidgetLocation(layout.containers, this.widgetId);
if (!location) {
console.warn(`Widget ${this.widgetId} not found for update`);
return;
}
// Only capture oldSettings the FIRST time execute is called (or if we want to support re-execution)
// Since we create a NEW command instance for every user action, this is fine.
// But for Redo, we don't want to recapture 'oldSettings' from the *already updated* state if we were in a weird state.
// Actually, Redo implies we are going from Undo -> Redo.
// Undo restored oldSettings. So Redo will see oldSettings.
// So capturing it again is fine, OR we check if it's null.
if (!this.oldSettings) {
this.oldSettings = JSON.parse(JSON.stringify(location.widget.props || {}));
}
// 2. Perform Update
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const newLocation = findWidgetLocation(newLayout.containers, this.widgetId);
if (newLocation) {
// merge
newLocation.widget.props = { ...newLocation.widget.props, ...this.newSettings };
context.updateLayout(this.pageId, newLayout);
}
}
async undo(context: CommandContext): Promise<void> {
if (!this.oldSettings) {
console.warn("Cannot undo update: State was not captured");
return;
}
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 location = findWidgetLocation(newLayout.containers, this.widgetId);
if (location) {
// Restore exact old props
location.widget.props = this.oldSettings;
context.updateLayout(this.pageId, newLayout);
} else {
console.warn(`Widget ${this.widgetId} not found for undo update`);
}
}
}

View File

@ -0,0 +1,21 @@
import { PageLayout, WidgetInstance, LayoutContainer } from '@/lib/unifiedLayoutManager';
export interface CommandContext {
pageId: string;
layouts: Map<string, PageLayout>;
updateLayout: (pageId: string, layout: PageLayout) => void;
}
export interface Command {
id: string;
type: string;
timestamp: number;
execute(context: CommandContext): Promise<void>;
undo(context: CommandContext): Promise<void>;
}
export interface CommandFactory {
createAddWidgetCommand(pageId: string, containerId: string, widget: WidgetInstance, index?: number): Command;
createRemoveWidgetCommand(pageId: string, containerId: string, widget: WidgetInstance, index: number): Command;
createMoveWidgetCommand(pageId: string, sourceContainerId: string, targetContainerId: string, widgetId: string, oldIndex: number, newIndex: number): Command;
}

View File

@ -56,6 +56,35 @@ export class UnifiedLayoutManager {
return `widget-${this.generateId()}`;
}
// Create a standalone widget instance (for Command pattern)
static createWidgetInstance(widgetId: string): WidgetInstance {
let defaultProps = {};
const widgetDef = widgetRegistry.get(widgetId);
if (widgetDef && widgetDef.metadata.defaultProps) {
defaultProps = { ...widgetDef.metadata.defaultProps };
}
return {
id: this.generateWidgetId(),
widgetId,
props: defaultProps,
order: 0
};
}
// Get all widgets in a layout (recursive)
static getAllWidgets(layout: PageLayout): WidgetInstance[] {
const widgets: WidgetInstance[] = [];
const collect = (containers: LayoutContainer[]) => {
containers.forEach(c => {
widgets.push(...c.widgets);
collect(c.children);
});
};
collect(layout.containers);
return widgets;
}
// Load root data from storage (database-only, no localStorage)
static async loadRootData(pageId?: string): Promise<RootLayoutData> {
try {
@ -160,6 +189,29 @@ export class UnifiedLayoutManager {
return null;
}
// Calculate insertion index based on column target
static calculateWidgetInsertionIndex(container: LayoutContainer, targetColumn?: number): number {
let order = container.widgets.length;
if (targetColumn !== undefined && targetColumn >= 0 && targetColumn < container.columns) {
const occupiedPositionsInColumn = container.widgets
.map((_, index) => index)
.filter(index => index % container.columns === targetColumn);
let targetRow = 0;
while (occupiedPositionsInColumn.includes(targetRow * container.columns + targetColumn)) {
targetRow++;
}
order = targetRow * container.columns + targetColumn;
if (order > container.widgets.length) {
order = container.widgets.length;
}
}
return order;
}
// Add widget to specific container
static addWidgetToContainer(
layout: PageLayout,

View File

@ -1,5 +1,6 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { invalidateServerCache } from '@/lib/db';
import { toast } from "sonner";
import { supabase } from "@/integrations/supabase/client";
import { MediaItem, PostItem, User } from "@/types";
@ -47,6 +48,7 @@ export const usePostActions = ({
if (error) throw error;
toast.success(translate('Post deleted'));
await invalidateServerCache(['posts']);
navigate('/');
} catch (error) {
console.error('Error deleting post:', error);
@ -163,6 +165,7 @@ export const usePostActions = ({
toast.success(translate('Categories updated'));
// Trigger parent refresh
await invalidateServerCache(['posts']);
fetchMedia();
} catch (error) {
console.error('Failed to update post meta:', error);

View File

@ -1,17 +1,17 @@
import { useState, useEffect } from "react";
import { useState, useEffect, Suspense, lazy } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ArrowLeft, FileText, Calendar, Eye, EyeOff, Edit, Edit3, Check, X, Plus, PanelLeftClose, PanelLeftOpen, FolderTree } from "lucide-react";
import { ArrowLeft, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { T, translate } from "@/i18n";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { GenericCanvas } from "@/components/hmi/GenericCanvas";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { PageActions } from "@/components/PageActions";
import { WidgetPropertyPanel } from "@/components/widgets/WidgetPropertyPanel";
import MarkdownRenderer from "@/components/MarkdownRenderer";
import { Sidebar } from "@/components/sidebar/Sidebar";
import { TableOfContents } from "@/components/sidebar/TableOfContents";
@ -19,6 +19,14 @@ import { MobileTOC } from "@/components/sidebar/MobileTOC";
import { extractHeadings, extractHeadingsFromLayout, MarkdownHeading } from "@/lib/toc";
import { useLayout } from "@/contexts/LayoutContext";
import { fetchUserPage, invalidateUserPageCache } from "@/lib/db";
import { UserPageTopBar } from "@/components/user-page/UserPageTopBar";
import { UserPageDetails } from "@/components/user-page/UserPageDetails";
import { useLayouts } from "@/hooks/useLayouts";
import { Database } from "@/integrations/supabase/types";
const PageRibbonBar = lazy(() => import("@/components/user-page/ribbons/PageRibbonBar"));
type Layout = Database['public']['Tables']['layouts']['Row'];
interface Page {
id: string;
@ -82,18 +90,12 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
const [loading, setLoading] = useState(true);
const [isEditMode, setIsEditMode] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [selectedWidgetId, setSelectedWidgetId] = useState<string | null>(null);
// Inline editing states
const [editingTitle, setEditingTitle] = useState(false);
const [editingSlug, setEditingSlug] = useState(false);
const [editingTags, setEditingTags] = useState(false);
const [titleValue, setTitleValue] = useState("");
const [slugValue, setSlugValue] = useState("");
const [tagsValue, setTagsValue] = useState("");
const [slugError, setSlugError] = useState<string | null>(null);
const [savingField, setSavingField] = useState<string | null>(null);
// TOC State
const [headings, setHeadings] = useState<MarkdownHeading[]>([]);
@ -107,6 +109,111 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
const isOwner = currentUser?.id === userId;
// Template State
const [templates, setTemplates] = useState<Layout[]>([]);
const { getLayouts } = useLayouts();
const { importPageLayout, addWidgetToPage, addPageContainer, undo, redo, canUndo, canRedo } = useLayout();
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(null);
const [editingWidgetId, setEditingWidgetId] = useState<string | null>(null);
const [newlyAddedWidgetId, setNewlyAddedWidgetId] = useState<string | null>(null);
useEffect(() => {
if (isOwner && isEditMode) {
loadTemplates();
}
}, [isOwner, isEditMode]);
const loadTemplates = async () => {
const { data, error } = await getLayouts({ type: 'canvas' });
if (data) {
setTemplates(data);
}
};
const handleAddWidget = async (widgetId: string) => {
if (!page) return;
const pageId = `page-${page.id}`;
// Determine target container
let targetContainerId = selectedContainerId;
if (!targetContainerId) {
// Find first container in current page (not ideal but fallback)
const layout = getLoadedPageLayout(pageId);
if (layout && layout.containers.length > 0) {
targetContainerId = layout.containers[0].id;
toast("Added to first container", {
description: "Select a container to add to a specific location",
action: {
label: "Undo",
onClick: undo
}
});
} else {
// Create new container if none exists
try {
const newContainer = await addPageContainer(pageId);
targetContainerId = newContainer.id;
setSelectedContainerId(newContainer.id);
} catch (e) {
console.error("Failed to create container for widget", e);
return;
}
}
}
try {
const newWidget = await addWidgetToPage(pageId, targetContainerId, widgetId);
toast.success(translate("Widget added"));
// Automatically open the settings modal for the new widget
setEditingWidgetId(newWidget.id);
setNewlyAddedWidgetId(newWidget.id);
// Clear selection so side panel doesn't open simultaneously (optional preference)
setSelectedWidgetId(null);
} catch (e) {
console.error("Failed to add widget", e);
toast.error(translate("Failed to add widget"));
}
};
const handleEditWidget = (widgetId: string | null) => {
// If closing, clear the newlyAddedWidgetId flag regardless of cause
// Logic for removal on cancel is handled in WidgetItem
if (widgetId === null) {
setNewlyAddedWidgetId(null);
}
setEditingWidgetId(widgetId);
};
const handleAddContainer = async () => {
if (!page) return;
const pageId = `page-${page.id}`;
try {
const newContainer = await addPageContainer(pageId);
setSelectedContainerId(newContainer.id);
toast.success(translate("Container added"));
} catch (e) {
console.error("Failed to add container", e);
toast.error(translate("Failed to add container"));
}
};
const handleLoadTemplate = async (template: Layout) => {
if (!page) return;
try {
const layoutJsonString = JSON.stringify(template.layout_json);
const pageId = `page-${page.id}`;
await importPageLayout(pageId, layoutJsonString);
toast.success(`Loaded layout: ${template.name}`);
// Refresh page content locally to reflect changes immediately if needed,
// effectively handled by context but we might want to ensure page state updates
// The GenericCanvas listens to the layout context, so it should auto-update.
} catch (e) {
console.error("Failed to load layout", e);
toast.error("Failed to load layout");
}
};
useEffect(() => {
if (initialPage) {
setLoading(false);
@ -152,6 +259,33 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
// 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]);
// Reactive Heading Extraction
// This ensures we extract headings whenever the page loads OR when specific layouts are loaded into context
const { loadedPages } = useLayout();
@ -218,152 +352,7 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
}
};
const checkSlugCollision = async (newSlug: string): Promise<boolean> => {
if (newSlug === page?.slug) return false; // Same slug, no collision
try {
const { data, error } = await supabase
.from('pages')
.select('id')
.eq('slug', newSlug)
.eq('owner', userId)
.maybeSingle();
if (error) throw error;
return !!data; // Returns true if slug already exists
} catch (error) {
console.error('Error checking slug collision:', error);
return false;
}
};
const handleSaveTitle = async () => {
if (!page || !titleValue.trim()) {
toast.error(translate('Title cannot be empty'));
return;
}
setSavingField('title');
try {
const { error } = await supabase
.from('pages')
.update({ title: titleValue.trim(), updated_at: new Date().toISOString() })
.eq('id', page.id)
.eq('owner', currentUser?.id);
if (error) throw error;
setPage({ ...page, title: titleValue.trim() });
setEditingTitle(false);
// Invalidate cache for this page
if (userId && page.slug) invalidateUserPageCache(userId, page.slug);
toast.success(translate('Title updated'));
} catch (error) {
console.error('Error updating title:', error);
toast.error(translate('Failed to update title'));
} finally {
setSavingField(null);
}
};
const handleSaveSlug = async () => {
if (!page || !slugValue.trim()) {
toast.error(translate('Slug cannot be empty'));
return;
}
// Validate slug format (lowercase, hyphens, alphanumeric)
const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
if (!slugRegex.test(slugValue)) {
setSlugError('Slug must be lowercase, alphanumeric, and use hyphens only');
return;
}
setSavingField('slug');
setSlugError(null);
// Check for collisions
const hasCollision = await checkSlugCollision(slugValue);
if (hasCollision) {
setSlugError('This slug is already used by another page');
setSavingField(null);
return;
}
try {
const { error } = await supabase
.from('pages')
.update({ slug: slugValue.trim(), updated_at: new Date().toISOString() })
.eq('id', page.id)
.eq('owner', currentUser?.id);
if (error) throw error;
setPage({ ...page, slug: slugValue.trim() });
setEditingSlug(false);
toast.success(translate('Slug updated'));
// Update URL to reflect new slug
const newPath = orgSlug
? `/org/${orgSlug}/user/${userId}/pages/${slugValue}`
: `/user/${userId}/pages/${slugValue}`;
navigate(newPath, { replace: true });
} catch (error) {
console.error('Error updating slug:', error);
toast.error(translate('Failed to update slug'));
} finally {
if (userId && page?.slug) invalidateUserPageCache(userId, page.slug); // Invalidate old slug
if (userId) invalidateUserPageCache(userId, slugValue.trim()); // Invalidate new slug to be safe
setSavingField(null);
}
};
const handleSaveTags = async () => {
if (!page) return;
setSavingField('tags');
try {
// Parse tags from comma-separated string
const newTags = tagsValue
.split(',')
.map(tag => tag.trim())
.filter(tag => tag.length > 0);
const { error } = await supabase
.from('pages')
.update({ tags: newTags.length > 0 ? newTags : null, updated_at: new Date().toISOString() })
.eq('id', page.id)
.eq('owner', currentUser?.id);
if (error) throw error;
setPage({ ...page, tags: newTags.length > 0 ? newTags : null });
setEditingTags(false);
toast.success(translate('Tags updated'));
} catch (error) {
console.error('Error updating tags:', error);
toast.error(translate('Failed to update tags'));
} finally {
if (userId && page?.slug) invalidateUserPageCache(userId, page.slug);
setSavingField(null);
}
};
const handleStartEditTitle = () => {
setTitleValue(page?.title || '');
setEditingTitle(true);
};
const handleStartEditSlug = () => {
setSlugValue(page?.slug || '');
setSlugError(null);
setEditingSlug(true);
};
const handleStartEditTags = () => {
setTagsValue(page?.tags?.join(', ') || '');
setEditingTags(true);
};
if (loading) {
return (
@ -389,20 +378,44 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
return (
<div className={`${embedded ? 'h-full' : 'h-[calc(100vh-3.5rem)]'} bg-background flex flex-col overflow-hidden`}>
{/* Top Header (Back button) - Fixed if not embedded */}
{/* Top Header (Back button) - Fixed if not embedded */}
{/* Top Header (Back button) or Ribbon Bar - Fixed if not embedded */}
{!embedded && (
<div className="border-b bg-background/95 backdrop-blur z-10 shrink-0">
<div className="container mx-auto py-2">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(orgSlug ? `/org/${orgSlug}/user/${userId}` : `/user/${userId}`)}
className="text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"><T>Back to profile</T></span>
</Button>
</div>
</div>
isEditMode && isOwner ? (
<Suspense fallback={<div className="h-24 bg-muted/30 animate-pulse w-full border-b" />}>
<PageRibbonBar
page={page}
isOwner={isOwner}
onToggleEditMode={() => {
setIsEditMode(false);
setSelectedWidgetId(null);
setSelectedContainerId(null);
}}
onPageUpdate={handlePageUpdate}
onDelete={() => {
// Reuse delete logic if available or hoist it.
}}
onMetaUpdated={() => {
if (userId && page.slug) invalidateUserPageCache(userId, page.slug);
}}
templates={templates}
onLoadTemplate={handleLoadTemplate}
onAddWidget={handleAddWidget}
onAddContainer={handleAddContainer}
onUndo={undo}
onRedo={redo}
canUndo={canUndo}
canRedo={canRedo}
/>
</Suspense>
) : (
<UserPageTopBar
embedded={embedded}
orgSlug={orgSlug}
userId={userId || ''}
isOwner={isOwner}
/>
)
)}
{/* Main Split Layout */}
@ -411,6 +424,7 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
{/* Sidebar Left - Fixed width, independent scroll */}
{(headings.length > 0 || childPages.length > 0) && (
<Sidebar className={`${isSidebarCollapsed ? 'w-12' : 'w-[300px]'} border-r bg-background/50 h-full hidden lg:flex flex-col ${embedded ? '' : 'lg:static lg:max-h-none'} shrink-0 transition-all duration-300`}>
{/* ... Sidebar content ... */}
<div className={`flex items-center ${isSidebarCollapsed ? 'justify-center' : 'justify-end'} p-2 sticky top-0 bg-background/50 z-10`}>
<Button
variant="ghost"
@ -457,260 +471,97 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
)}
{/* Right Content - Independent scroll */}
<div className="flex-1 overflow-y-auto scrollbar-custom min-w-0 h-full">
<div className="container mx-auto p-4 md:p-8 max-w-5xl">
<ResizablePanelGroup direction="horizontal" className="flex-1 h-full min-w-0">
<ResizablePanel defaultSize={75} minSize={30} order={1}>
<div className="h-full overflow-y-auto scrollbar-custom">
<div className="container mx-auto p-4 md:p-8 max-w-5xl">
{/* Mobile TOC */}
<div className="lg:hidden mb-6">
{headings.length > 0 && <MobileTOC headings={headings} />}
</div>
{/* Page Header */}
<div className="mb-8">
<div className="flex items-start gap-4 mb-4">
<div className="flex-1">
{/* Parent Page Eyebrow */}
{page.parent_page && (
<div className="flex items-center gap-1 text-sm text-muted-foreground mb-2">
<Link
to={orgSlug ? `/org/${orgSlug}/user/${userId}/pages/${page.parent_page.slug}` : `/user/${userId}/pages/${page.parent_page.slug}`}
className="hover:text-primary transition-colors flex items-center gap-1"
>
<FileText className="h-3 w-3" />
<span>{page.parent_page.title}</span>
</Link>
</div>
)}
{/* Editable Title */}
{editingTitle && isOwner && isEditMode ? (
<div className="flex items-center gap-2 mb-2">
<Input
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
className="text-3xl font-bold h-12"
placeholder="Page title..."
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveTitle();
if (e.key === 'Escape') setEditingTitle(false);
}}
autoFocus
disabled={savingField === 'title'}
/>
<Button
size="sm"
variant="ghost"
onClick={handleSaveTitle}
disabled={savingField === 'title'}
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setEditingTitle(false)}
disabled={savingField === 'title'}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<h1
className={`text-3xl font-bold mb-2 ${isOwner && isEditMode ? 'cursor-pointer hover:text-primary transition-colors' : ''
}`}
onClick={() => isOwner && isEditMode && handleStartEditTitle()}
title={isOwner && isEditMode ? 'Click to edit title' : ''}
>
{page.title}
</h1>
)}
<div className="flex flex-col gap-2 mt-2">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
{userProfile && (
<Link
to={orgSlug ? `/org/${orgSlug}/user/${userId}` : `/user/${userId}`}
className="hover:text-primary transition-colors"
>
{userProfile.display_name || userProfile.username || `User ${userId?.slice(0, 8)}`}
</Link>
)}
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{new Date(page.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
</div>
{(() => {
// Normalize paths from either new category_paths or legacy categories field (for cache compat)
const displayPaths = page.category_paths ||
(page.categories?.map(c => [c]) || []);
if (displayPaths.length === 0) return null;
return (
<div className="flex flex-col gap-1 mt-1">
{displayPaths.map((path, pathIdx) => (
<div key={pathIdx} className="flex items-center gap-2 text-sm text-muted-foreground">
<FolderTree className="h-4 w-4 shrink-0" />
{path.map((cat, idx) => (
<span key={cat.id} className="flex items-center">
{idx > 0 && <span className="mx-1 text-muted-foreground/50">/</span>}
<Link to={`/categories/${cat.slug}`} className="hover:text-primary transition-colors hover:underline">
{cat.name}
</Link>
</span>
))}
</div>
))}
</div>
);
})()}
</div>
{/* Mobile TOC */}
<div className="lg:hidden mb-6">
{headings.length > 0 && <MobileTOC headings={headings} />}
</div>
</div>
</div>
<Separator className="my-6" />
{/* Tags and Type */}
<div className="space-y-3 mb-8">
<div className="flex items-center gap-2 flex-wrap w-full">
{!page.visible && isOwner && (
<Badge variant="destructive" className="flex items-center gap-1">
<EyeOff className="h-3 w-3" />
<T>Hidden</T>
</Badge>
)}
{!page.is_public && (
<Badge variant="secondary" className="bg-blue-500/10 text-blue-500">
<T>Private</T>
</Badge>
)}
<PageActions
<UserPageDetails
page={page}
userProfile={userProfile}
isOwner={isOwner}
isEditMode={isEditMode}
onToggleEditMode={() => setIsEditMode(!isEditMode)}
userId={userId || ''} // Fallback if undefined, though it should be defined if loaded
orgSlug={orgSlug}
onPageUpdate={handlePageUpdate}
onMetaUpdated={() => userId && page.slug && fetchUserPageData(userId, page.slug)}
onToggleEditMode={() => setIsEditMode(!isEditMode)}
onWidgetRename={setSelectedWidgetId}
templates={templates}
onLoadTemplate={handleLoadTemplate}
/>
</div>
<Separator className="my-6" />
{/* Editable Tags */}
{editingTags && isOwner && isEditMode ? (
<div className="flex items-start gap-2">
<div className="flex-1">
<Input
value={tagsValue}
onChange={(e) => setTagsValue(e.target.value)}
className="text-sm"
placeholder="tag1, tag2, tag3..."
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveTags();
if (e.key === 'Escape') setEditingTags(false);
}}
autoFocus
disabled={savingField === 'tags'}
{/* Content Body */}
<div>
{page.content && typeof page.content === 'string' ? (
<div className="prose prose-lg dark:prose-invert max-w-none pb-12">
<MarkdownRenderer content={page.content} />
</div>
) : (
<GenericCanvas
pageId={`page-${page.id}`}
pageName={page.title}
isEditMode={isEditMode && isOwner}
showControls={isEditMode && isOwner}
initialLayout={page.content}
selectedWidgetId={selectedWidgetId}
onSelectWidget={setSelectedWidgetId}
selectedContainerId={selectedContainerId}
onSelectContainer={setSelectedContainerId}
editingWidgetId={editingWidgetId}
onEditWidget={handleEditWidget}
newlyAddedWidgetId={newlyAddedWidgetId}
/>
<p className="text-xs text-muted-foreground mt-1">
Separate tags with commas
</p>
</div>
<Button
size="sm"
variant="ghost"
onClick={handleSaveTags}
disabled={savingField === 'tags'}
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setEditingTags(false)}
disabled={savingField === 'tags'}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<div className="flex items-center gap-2 flex-wrap w-full">
{page.tags && page.tags.map((tag, index) => (
<Badge
key={index}
variant="secondary"
className={isOwner && isEditMode ? 'cursor-pointer hover:bg-secondary/80' : ''}
onClick={() => isOwner && isEditMode && handleStartEditTags()}
>
#{tag}
</Badge>
))}
{isOwner && isEditMode && (
<Button
variant="ghost"
size="sm"
className="h-6 text-xs text-muted-foreground"
onClick={handleStartEditTags}
>
<Plus className="h-3 w-3 mr-1" />
<T>Edit Tags</T>
</Button>
)}
</div>
)}
</div>
{/* Content Body */}
<div>
{page.content && typeof page.content === 'string' ? (
<div className="prose prose-lg dark:prose-invert max-w-none pb-12">
<MarkdownRenderer content={page.content} />
{/* Footer */}
<div className="mt-8 pt-8 border-t text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<div>
<T>Last updated:</T> {new Date(page.updated_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</div>
{page.parent && (
<Link
to={`/pages/${page.parent}`}
className="text-primary hover:underline"
>
<T>View parent page</T>
</Link>
)}
</div>
</div>
) : (
<GenericCanvas
pageId={`page-${page.id}`}
pageName={page.title}
isEditMode={isEditMode && isOwner}
showControls={isEditMode && isOwner}
initialLayout={page.content}
/>
)}
</div>
{/* Footer */}
<div className="mt-8 pt-8 border-t text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<div>
<T>Last updated:</T> {new Date(page.updated_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</div>
{page.parent && (
<Link
to={`/pages/${page.parent}`}
className="text-primary hover:underline"
>
<T>View parent page</T>
</Link>
)}
</div>
</div>
</ResizablePanel>
</div>
</div>
{/* Right Sidebar - Property Panel */}
{isEditMode && isOwner && selectedWidgetId && (
<>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={25} minSize={20} maxSize={50} order={2} id="user-page-props">
<div className="h-full flex flex-col shrink-0 transition-all duration-300 overflow-hidden bg-background">
<WidgetPropertyPanel
pageId={`page-${page.id}`}
selectedWidgetId={selectedWidgetId}
onWidgetRenamed={setSelectedWidgetId}
/>
</div>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</div>
</div >);
};

View File

@ -168,5 +168,13 @@ export interface Author {
user_id: string
username: string
display_name: string
avatar_url?: string
export type EventType = 'category' | 'post' | 'page' | 'system' | string;
export interface AppEvent {
type: EventType;
kind: 'cache' | 'system' | 'chat' | 'other';
action: 'create' | 'update' | 'delete';
data: any;
timestamp: number;
}