canvas layout / widgets / commands
This commit is contained in:
parent
444f78ceb7
commit
504a4028e7
@ -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}>
|
||||
<StreamProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
|
||||
<StreamInvalidator />
|
||||
<FeedCacheProvider>
|
||||
<AppWrapper />
|
||||
</FeedCacheProvider>
|
||||
</StreamProvider>
|
||||
</WebSocketProvider>
|
||||
</ProfilesProvider>
|
||||
</OrganizationProvider>
|
||||
|
||||
@ -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"
|
||||
|
||||
40
packages/ui/src/components/StreamInvalidator.tsx
Normal file
40
packages/ui/src/components/StreamInvalidator.tsx
Normal 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
|
||||
};
|
||||
@ -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);
|
||||
|
||||
@ -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}
|
||||
|
||||
438
packages/ui/src/components/user-page/UserPageDetails.tsx
Normal file
438
packages/ui/src/components/user-page/UserPageDetails.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
39
packages/ui/src/components/user-page/UserPageTopBar.tsx
Normal file
39
packages/ui/src/components/user-page/UserPageTopBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
491
packages/ui/src/components/user-page/ribbons/PageRibbonBar.tsx
Normal file
491
packages/ui/src/components/user-page/ribbons/PageRibbonBar.tsx
Normal 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;
|
||||
@ -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);
|
||||
|
||||
181
packages/ui/src/components/widgets/TailwindClassPicker.tsx
Normal file
181
packages/ui/src/components/widgets/TailwindClassPicker.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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,10 +234,18 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
|
||||
{/* 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-1 pb-4 border-b border-border">
|
||||
<Label htmlFor="widget-id-editor" className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
<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">
|
||||
@ -266,6 +275,39 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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]) =>
|
||||
renderField(key, config)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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`);
|
||||
}
|
||||
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,87 +337,75 @@ 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 success = UnifiedLayoutManager.renameWidget(currentLayout, widgetInstanceId, newId);
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
const newLayout = JSON.parse(JSON.stringify(currentLayout)) as PageLayout;
|
||||
const success = UnifiedLayoutManager.renameWidget(newLayout, widgetInstanceId, newId);
|
||||
|
||||
currentLayout.updatedAt = Date.now();
|
||||
|
||||
await saveLayoutToCache(pageId);
|
||||
if (success) {
|
||||
setLoadedPages(prev => new Map(prev).set(pageId, newLayout));
|
||||
await saveLayoutToCache(pageId, newLayout);
|
||||
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 = {
|
||||
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={{
|
||||
loadPageLayout,
|
||||
getLoadedPageLayout,
|
||||
clearPageLayout,
|
||||
@ -424,17 +418,18 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
|
||||
removePageContainer,
|
||||
movePageContainer,
|
||||
updateWidgetProps,
|
||||
renameWidget,
|
||||
saveToApi,
|
||||
exportPageLayout,
|
||||
importPageLayout,
|
||||
hydratePageLayout,
|
||||
saveToApi,
|
||||
isLoading,
|
||||
loadedPages,
|
||||
renameWidget,
|
||||
};
|
||||
|
||||
return (
|
||||
<LayoutContext.Provider value={value}>
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo
|
||||
}}>
|
||||
{children}
|
||||
</LayoutContext.Provider>
|
||||
);
|
||||
|
||||
122
packages/ui/src/contexts/StreamContext.tsx
Normal file
122
packages/ui/src/contexts/StreamContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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) => {
|
||||
|
||||
55
packages/ui/src/lib/page-commands/HistoryManager.ts
Normal file
55
packages/ui/src/lib/page-commands/HistoryManager.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
246
packages/ui/src/lib/page-commands/commands.ts
Normal file
246
packages/ui/src/lib/page-commands/commands.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
21
packages/ui/src/lib/page-commands/types.ts
Normal file
21
packages/ui/src/lib/page-commands/types.ts
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,7 +471,9 @@ 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">
|
||||
<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 */}
|
||||
@ -465,209 +481,19 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
|
||||
{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>
|
||||
</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'}
|
||||
/>
|
||||
<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>
|
||||
@ -682,6 +508,13 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
|
||||
isEditMode={isEditMode && isOwner}
|
||||
showControls={isEditMode && isOwner}
|
||||
initialLayout={page.content}
|
||||
selectedWidgetId={selectedWidgetId}
|
||||
onSelectWidget={setSelectedWidgetId}
|
||||
selectedContainerId={selectedContainerId}
|
||||
onSelectContainer={setSelectedContainerId}
|
||||
editingWidgetId={editingWidgetId}
|
||||
onEditWidget={handleEditWidget}
|
||||
newlyAddedWidgetId={newlyAddedWidgetId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -711,6 +544,24 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
{/* 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 >);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user