canvas layout / widgets / commands
This commit is contained in:
parent
444f78ceb7
commit
504a4028e7
@ -162,6 +162,8 @@ import CacheTest from "./pages/CacheTest";
|
|||||||
// ... (imports)
|
// ... (imports)
|
||||||
|
|
||||||
import { FeedCacheProvider } from "@/contexts/FeedCacheContext";
|
import { FeedCacheProvider } from "@/contexts/FeedCacheContext";
|
||||||
|
import { StreamProvider } from "@/contexts/StreamContext";
|
||||||
|
import { StreamInvalidator } from "@/components/StreamInvalidator";
|
||||||
|
|
||||||
// ... (imports)
|
// ... (imports)
|
||||||
|
|
||||||
@ -185,9 +187,12 @@ const App = () => {
|
|||||||
<OrganizationProvider>
|
<OrganizationProvider>
|
||||||
<ProfilesProvider>
|
<ProfilesProvider>
|
||||||
<WebSocketProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
|
<WebSocketProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
|
||||||
<FeedCacheProvider>
|
<StreamProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}>
|
||||||
<AppWrapper />
|
<StreamInvalidator />
|
||||||
</FeedCacheProvider>
|
<FeedCacheProvider>
|
||||||
|
<AppWrapper />
|
||||||
|
</FeedCacheProvider>
|
||||||
|
</StreamProvider>
|
||||||
</WebSocketProvider>
|
</WebSocketProvider>
|
||||||
</ProfilesProvider>
|
</ProfilesProvider>
|
||||||
</OrganizationProvider>
|
</OrganizationProvider>
|
||||||
|
|||||||
@ -2,20 +2,24 @@ import { useState } from "react";
|
|||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuLabel
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuGroup
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { T, translate } from "@/i18n";
|
import { T, translate } from "@/i18n";
|
||||||
import { PagePickerDialog } from "./widgets/PagePickerDialog";
|
import { PagePickerDialog } from "./widgets/PagePickerDialog";
|
||||||
import { PageCreationWizard } from "./widgets/PageCreationWizard";
|
import { PageCreationWizard } from "./widgets/PageCreationWizard";
|
||||||
import { CategoryManager } from "./widgets/CategoryManager";
|
import { CategoryManager } from "./widgets/CategoryManager";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Database } from '@/integrations/supabase/types';
|
||||||
|
|
||||||
|
type Layout = Database['public']['Tables']['layouts']['Row'];
|
||||||
|
|
||||||
interface Page {
|
interface Page {
|
||||||
id: string;
|
id: string;
|
||||||
@ -39,6 +43,8 @@ interface PageActionsProps {
|
|||||||
onMetaUpdated?: () => void;
|
onMetaUpdated?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
showLabels?: boolean;
|
showLabels?: boolean;
|
||||||
|
templates?: Layout[];
|
||||||
|
onLoadTemplate?: (template: Layout) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PageActions = ({
|
export const PageActions = ({
|
||||||
@ -50,7 +56,9 @@ export const PageActions = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onMetaUpdated,
|
onMetaUpdated,
|
||||||
className,
|
className,
|
||||||
showLabels = true
|
showLabels = true,
|
||||||
|
templates,
|
||||||
|
onLoadTemplate
|
||||||
}: PageActionsProps) => {
|
}: PageActionsProps) => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showPagePicker, setShowPagePicker] = useState(false);
|
const [showPagePicker, setShowPagePicker] = useState(false);
|
||||||
@ -78,7 +86,8 @@ export const PageActions = ({
|
|||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
paths: [apiPath, htmlPath]
|
paths: [apiPath, htmlPath],
|
||||||
|
types: ['pages']
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
console.log('Cache invalidated for:', page.slug);
|
console.log('Cache invalidated for:', page.slug);
|
||||||
@ -488,6 +497,33 @@ draft: ${!page.visible}
|
|||||||
</Button>
|
</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 */}
|
{/* Categorization - New All-in-One Component */}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
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;
|
className?: string;
|
||||||
selectedWidgetId?: string | null;
|
selectedWidgetId?: string | null;
|
||||||
onSelectWidget?: (widgetId: string) => void;
|
onSelectWidget?: (widgetId: string) => void;
|
||||||
|
selectedContainerId?: string | null;
|
||||||
|
onSelectContainer?: (containerId: string | null) => void;
|
||||||
initialLayout?: any;
|
initialLayout?: any;
|
||||||
|
editingWidgetId?: string | null;
|
||||||
|
onEditWidget?: (widgetId: string | null) => void;
|
||||||
|
newlyAddedWidgetId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
|
const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
|
||||||
@ -26,7 +31,12 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
|
|||||||
className = '',
|
className = '',
|
||||||
selectedWidgetId,
|
selectedWidgetId,
|
||||||
onSelectWidget,
|
onSelectWidget,
|
||||||
initialLayout
|
selectedContainerId: propSelectedContainerId,
|
||||||
|
onSelectContainer: propOnSelectContainer,
|
||||||
|
initialLayout,
|
||||||
|
editingWidgetId,
|
||||||
|
onEditWidget,
|
||||||
|
newlyAddedWidgetId
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
loadedPages,
|
loadedPages,
|
||||||
@ -47,7 +57,6 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
|
|||||||
} = useLayout();
|
} = useLayout();
|
||||||
const layout = loadedPages.get(pageId);
|
const layout = loadedPages.get(pageId);
|
||||||
|
|
||||||
// Load the page layout on mount
|
|
||||||
// Load the page layout on mount or hydrate from prop
|
// Load the page layout on mount or hydrate from prop
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialLayout && !layout) {
|
if (initialLayout && !layout) {
|
||||||
@ -60,7 +69,16 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
|
|||||||
}
|
}
|
||||||
}, [pageId, pageName, layout, loadPageLayout, hydratePageLayout, initialLayout]);
|
}, [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 [showWidgetPalette, setShowWidgetPalette] = useState(false);
|
||||||
const [targetContainerId, setTargetContainerId] = useState<string | null>(null);
|
const [targetContainerId, setTargetContainerId] = useState<string | null>(null);
|
||||||
const [targetColumn, setTargetColumn] = useState<number | undefined>(undefined);
|
const [targetColumn, setTargetColumn] = useState<number | undefined>(undefined);
|
||||||
@ -312,6 +330,9 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
|
|||||||
isCompactMode={className.includes('p-0')}
|
isCompactMode={className.includes('p-0')}
|
||||||
selectedWidgetId={selectedWidgetId}
|
selectedWidgetId={selectedWidgetId}
|
||||||
onSelectWidget={onSelectWidget}
|
onSelectWidget={onSelectWidget}
|
||||||
|
editingWidgetId={editingWidgetId}
|
||||||
|
onEditWidget={onEditWidget}
|
||||||
|
newlyAddedWidgetId={newlyAddedWidgetId}
|
||||||
onRemoveWidget={async (widgetId) => {
|
onRemoveWidget={async (widgetId) => {
|
||||||
try {
|
try {
|
||||||
await removeWidgetFromPage(pageId, widgetId);
|
await removeWidgetFromPage(pageId, widgetId);
|
||||||
|
|||||||
@ -30,6 +30,9 @@ interface LayoutContainerProps {
|
|||||||
onSelectWidget?: (widgetId: string) => void;
|
onSelectWidget?: (widgetId: string) => void;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
isCompactMode?: boolean;
|
isCompactMode?: boolean;
|
||||||
|
editingWidgetId?: string | null;
|
||||||
|
onEditWidget?: (widgetId: string | null) => void;
|
||||||
|
newlyAddedWidgetId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
|
const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
|
||||||
@ -52,6 +55,9 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
|
|||||||
onSelectWidget,
|
onSelectWidget,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
isCompactMode = false,
|
isCompactMode = false,
|
||||||
|
editingWidgetId,
|
||||||
|
onEditWidget,
|
||||||
|
newlyAddedWidgetId,
|
||||||
}) => {
|
}) => {
|
||||||
const maxDepth = 3; // Limit nesting depth
|
const maxDepth = 3; // Limit nesting depth
|
||||||
const canNest = depth < maxDepth;
|
const canNest = depth < maxDepth;
|
||||||
@ -115,6 +121,8 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
|
|||||||
canMoveDown={index < container.widgets.length - 1}
|
canMoveDown={index < container.widgets.length - 1}
|
||||||
onRemove={onRemoveWidget}
|
onRemove={onRemoveWidget}
|
||||||
onMove={onMoveWidget}
|
onMove={onMoveWidget}
|
||||||
|
isEditing={editingWidgetId === widget.id}
|
||||||
|
onEditWidget={onEditWidget}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@ -170,10 +178,14 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
|
|||||||
onSelectWidget={onSelectWidget}
|
onSelectWidget={onSelectWidget}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
isCompactMode={isCompactMode}
|
isCompactMode={isCompactMode}
|
||||||
|
editingWidgetId={editingWidgetId}
|
||||||
|
onEditWidget={onEditWidget}
|
||||||
|
newlyAddedWidgetId={newlyAddedWidgetId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
||||||
{/* Empty State - only show when not showing column indicators */}
|
{/* Empty State - only show when not showing column indicators */}
|
||||||
{container.widgets.length === 0 && container.children.length === 0 && !(isEditMode && isSelected) && (
|
{container.widgets.length === 0 && container.children.length === 0 && !(isEditMode && isSelected) && (
|
||||||
<div
|
<div
|
||||||
@ -447,6 +459,9 @@ interface WidgetItemProps {
|
|||||||
onMove?: (widgetInstanceId: string, direction: 'up' | 'down' | 'left' | 'right') => void;
|
onMove?: (widgetInstanceId: string, direction: 'up' | 'down' | 'left' | 'right') => void;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
onSelect?: () => void;
|
onSelect?: () => void;
|
||||||
|
isEditing?: boolean;
|
||||||
|
onEditWidget?: (widgetId: string | null) => void;
|
||||||
|
isNew?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WidgetItem: React.FC<WidgetItemProps> = ({
|
const WidgetItem: React.FC<WidgetItemProps> = ({
|
||||||
@ -458,11 +473,15 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
|
|||||||
onRemove,
|
onRemove,
|
||||||
onMove,
|
onMove,
|
||||||
isSelected,
|
isSelected,
|
||||||
onSelect
|
onSelect,
|
||||||
|
isEditing,
|
||||||
|
onEditWidget,
|
||||||
|
isNew
|
||||||
}) => {
|
}) => {
|
||||||
const widgetDefinition = widgetRegistry.get(widget.widgetId);
|
const widgetDefinition = widgetRegistry.get(widget.widgetId);
|
||||||
const { updateWidgetProps, renameWidget } = useLayout();
|
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
|
// 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 (
|
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 */}
|
{/* Edit Mode Controls */}
|
||||||
{isEditMode && (
|
{isEditMode && (
|
||||||
<>
|
<>
|
||||||
{/* Widget Info Overlay */}
|
{/* 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">
|
<div className="flex items-center justify-between">
|
||||||
<span>{widgetDefinition.metadata.name}</span>
|
<span>{widgetDefinition.metadata.name}</span>
|
||||||
<div className="flex items-center gap-1">
|
<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"
|
className="h-4 w-4 text-white hover:bg-white/20"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setShowSettingsModal(true);
|
// Open settings modal via prop
|
||||||
|
onEditWidget?.(widget.id);
|
||||||
}}
|
}}
|
||||||
title="Widget settings"
|
title="Widget settings"
|
||||||
>
|
>
|
||||||
@ -564,6 +608,8 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full bg-white dark:bg-slate-800 overflow-hidden rounded-lg transition-all duration-200",
|
"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
|
// Selection Visuals & Margins
|
||||||
isEditMode && "border-2",
|
isEditMode && "border-2",
|
||||||
isEditMode && isSelected ? "border-blue-500 ring-4 ring-blue-500/10 shadow-lg z-10" : "border-transparent",
|
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 */}
|
{/* Generic Settings Modal */}
|
||||||
{
|
{
|
||||||
widgetDefinition.metadata.configSchema && showSettingsModal && (
|
widgetDefinition.metadata.configSchema && isEditing && (
|
||||||
<WidgetSettingsManager
|
<WidgetSettingsManager
|
||||||
isOpen={showSettingsModal}
|
isOpen={!!isEditing} // coerce to boolean although it should be boolean | undefined from comparison
|
||||||
onClose={() => setShowSettingsModal(false)}
|
onClose={() => onEditWidget?.(null)}
|
||||||
widgetDefinition={widgetDefinition}
|
widgetDefinition={widgetDefinition}
|
||||||
currentProps={widget.props || {}}
|
currentProps={widget.props || {}}
|
||||||
onSave={handleSettingsSave}
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
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 { Plus, Edit2, Trash2, FolderTree, Link as LinkIcon, Check, X, Loader2 } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { T } from "@/i18n";
|
import { T } from "@/i18n";
|
||||||
|
|
||||||
interface CategoryManagerProps {
|
interface CategoryManagerProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@ -20,8 +22,8 @@ interface CategoryManagerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMeta, onPageMetaUpdate, filterByType, defaultMetaType }: CategoryManagerProps) => {
|
export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMeta, onPageMetaUpdate, filterByType, defaultMetaType }: CategoryManagerProps) => {
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
// const [categories, setCategories] = useState<Category[]>([]); // Replaced by useQuery
|
||||||
const [loading, setLoading] = useState(false);
|
// const [loading, setLoading] = useState(false); // Replaced by useQuery
|
||||||
const [actionLoading, setActionLoading] = useState(false);
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
|
||||||
// Selection state
|
// Selection state
|
||||||
@ -44,15 +46,10 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
|
|||||||
|
|
||||||
const linkedCategoryIds = getLinkedCategoryIds();
|
const linkedCategoryIds = getLinkedCategoryIds();
|
||||||
|
|
||||||
useEffect(() => {
|
// React Query Integration
|
||||||
if (isOpen) {
|
const { data: categories = [], isLoading: loading } = useQuery({
|
||||||
loadCategories();
|
queryKey: ['categories'],
|
||||||
}
|
queryFn: async () => {
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const loadCategories = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await fetchCategories({ includeChildren: true });
|
const data = await fetchCategories({ includeChildren: true });
|
||||||
// Filter by type if specified
|
// Filter by type if specified
|
||||||
let filtered = filterByType
|
let filtered = filterByType
|
||||||
@ -60,17 +57,9 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
|
|||||||
: data;
|
: data;
|
||||||
|
|
||||||
// Only show root-level categories (those without a parent)
|
// Only show root-level categories (those without a parent)
|
||||||
// Children will be rendered recursively via the children property
|
return filtered.filter(cat => !cat.parent_category_id);
|
||||||
filtered = filtered.filter(cat => !cat.parent_category_id);
|
|
||||||
|
|
||||||
setCategories(filtered);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
toast.error("Failed to load categories");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
const handleCreateStart = (parentId: string | null = null) => {
|
const handleCreateStart = (parentId: string | null = null) => {
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
@ -88,6 +77,8 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
|
|||||||
setEditingCategory({ ...category });
|
setEditingCategory({ ...category });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!editingCategory || !editingCategory.name || !editingCategory.slug) {
|
if (!editingCategory || !editingCategory.name || !editingCategory.slug) {
|
||||||
toast.error("Name and Slug are required");
|
toast.error("Name and Slug are required");
|
||||||
@ -119,7 +110,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
|
|||||||
toast.success("Category updated");
|
toast.success("Category updated");
|
||||||
}
|
}
|
||||||
setEditingCategory(null);
|
setEditingCategory(null);
|
||||||
loadCategories();
|
queryClient.invalidateQueries({ queryKey: ['categories'] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(isCreating ? "Failed to create category" : "Failed to update category");
|
toast.error(isCreating ? "Failed to create category" : "Failed to update category");
|
||||||
@ -136,7 +127,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
|
|||||||
try {
|
try {
|
||||||
await deleteCategory(id);
|
await deleteCategory(id);
|
||||||
toast.success("Category deleted");
|
toast.success("Category deleted");
|
||||||
loadCategories();
|
queryClient.invalidateQueries({ queryKey: ['categories'] });
|
||||||
if (selectedCategoryId === id) setSelectedCategoryId(null);
|
if (selectedCategoryId === id) setSelectedCategoryId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(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 { Textarea } from "@/components/ui/textarea";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import MarkdownEditor from '@/components/MarkdownEditorEx';
|
import MarkdownEditor from '@/components/MarkdownEditorEx';
|
||||||
|
import { TailwindClassPicker } from './TailwindClassPicker';
|
||||||
|
|
||||||
export interface WidgetPropertiesFormProps {
|
export interface WidgetPropertiesFormProps {
|
||||||
widgetDefinition: WidgetDefinition;
|
widgetDefinition: WidgetDefinition;
|
||||||
@ -233,38 +234,79 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Widget ID Helper (Editable) */}
|
|
||||||
{widgetInstanceId && onRename && (
|
|
||||||
<div className="space-y-1 pb-4 border-b border-border">
|
{/* General Properties */}
|
||||||
<Label htmlFor="widget-id-editor" className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
<div className="space-y-4 pb-4 border-b border-border">
|
||||||
Widget ID (Variable Name)
|
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
</Label>
|
General
|
||||||
<div className="flex gap-2">
|
</Label>
|
||||||
<Input
|
|
||||||
key={widgetInstanceId} // Force remount when widget changes to update defaultValue
|
{/* Widget ID Helper (Editable) */}
|
||||||
id="widget-id-editor"
|
{widgetInstanceId && onRename && (
|
||||||
defaultValue={widgetInstanceId}
|
<div className="space-y-2">
|
||||||
className="h-8 font-mono text-xs bg-muted/50"
|
<Label htmlFor="widget-id-editor" className="text-xs font-medium text-slate-500 dark:text-slate-400">
|
||||||
onBlur={(e) => {
|
Widget ID (Variable Name)
|
||||||
const val = e.target.value.trim();
|
</Label>
|
||||||
if (val && val !== widgetInstanceId) {
|
<div className="flex gap-2">
|
||||||
onRename(val);
|
<Input
|
||||||
} else {
|
key={widgetInstanceId} // Force remount when widget changes to update defaultValue
|
||||||
e.target.value = widgetInstanceId; // Reset if empty or same
|
id="widget-id-editor"
|
||||||
}
|
defaultValue={widgetInstanceId}
|
||||||
}}
|
className="h-8 font-mono text-xs bg-muted/50"
|
||||||
onKeyDown={(e) => {
|
onBlur={(e) => {
|
||||||
if (e.key === 'Enter') {
|
const val = e.target.value.trim();
|
||||||
e.currentTarget.blur();
|
if (val && val !== widgetInstanceId) {
|
||||||
}
|
onRename(val);
|
||||||
}}
|
} else {
|
||||||
/>
|
e.target.value = widgetInstanceId; // Reset if empty or same
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
Unique identifier used for templating key reference.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
)}
|
||||||
Unique identifier used for templating key reference.
|
|
||||||
</p>
|
{/* Enabled Toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="widget-enabled" className="text-xs font-medium">Enabled</Label>
|
||||||
|
<p className="text-[10px] text-muted-foreground">Turn off to hide this widget.</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="widget-enabled"
|
||||||
|
checked={settings.enabled !== false} // Default to true
|
||||||
|
onCheckedChange={(checked) => updateSetting('enabled', checked)}
|
||||||
|
className="scale-90"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</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">
|
<div className="space-y-4">
|
||||||
{Object.entries(configSchema).map(([key, config]) =>
|
{Object.entries(configSchema).map(([key, config]) =>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ interface WidgetSettingsManagerProps {
|
|||||||
currentProps: Record<string, any>;
|
currentProps: Record<string, any>;
|
||||||
widgetInstanceId?: string;
|
widgetInstanceId?: string;
|
||||||
onRename?: (newId: string) => void;
|
onRename?: (newId: string) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WidgetSettingsManagerComponent: React.FC<WidgetSettingsManagerProps> = ({
|
const WidgetSettingsManagerComponent: React.FC<WidgetSettingsManagerProps> = ({
|
||||||
@ -22,7 +23,8 @@ const WidgetSettingsManagerComponent: React.FC<WidgetSettingsManagerProps> = ({
|
|||||||
widgetDefinition,
|
widgetDefinition,
|
||||||
currentProps,
|
currentProps,
|
||||||
widgetInstanceId,
|
widgetInstanceId,
|
||||||
onRename
|
onRename,
|
||||||
|
onCancel
|
||||||
}) => {
|
}) => {
|
||||||
const [settings, setSettings] = useState<Record<string, any>>(currentProps);
|
const [settings, setSettings] = useState<Record<string, any>>(currentProps);
|
||||||
|
|
||||||
@ -42,11 +44,22 @@ const WidgetSettingsManagerComponent: React.FC<WidgetSettingsManagerProps> = ({
|
|||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setSettings(currentProps);
|
setSettings(currentProps);
|
||||||
|
if (onCancel) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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]">
|
<DialogContent className="sm:max-w-lg max-w-[90vw]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<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 { UnifiedLayoutManager, PageLayout, WidgetInstance, LayoutContainer } from '@/lib/unifiedLayoutManager';
|
||||||
import { widgetRegistry } from '@/lib/widgetRegistry';
|
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 {
|
interface LayoutContextType {
|
||||||
// Generic page management
|
// Generic page management
|
||||||
@ -26,6 +29,12 @@ interface LayoutContextType {
|
|||||||
// Manual save
|
// Manual save
|
||||||
saveToApi: () => Promise<boolean>;
|
saveToApi: () => Promise<boolean>;
|
||||||
|
|
||||||
|
// History Actions
|
||||||
|
undo: () => Promise<void>;
|
||||||
|
redo: () => Promise<void>;
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
|
||||||
// State
|
// State
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
loadedPages: Map<string, PageLayout>;
|
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 [loadedPages, setLoadedPages] = useState<Map<string, PageLayout>>(new Map());
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
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
|
// Initialize layouts on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initializeLayouts = async () => {
|
const initializeLayouts = async () => {
|
||||||
@ -66,10 +114,6 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
|
|||||||
initializeLayouts();
|
initializeLayouts();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
|
|
||||||
}, [loadedPages]);
|
|
||||||
|
|
||||||
const loadPageLayout = async (pageId: string, defaultName?: string) => {
|
const loadPageLayout = async (pageId: string, defaultName?: string) => {
|
||||||
// Only load if not already cached
|
// Only load if not already cached
|
||||||
if (!loadedPages.has(pageId)) {
|
if (!loadedPages.has(pageId)) {
|
||||||
@ -99,103 +143,86 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
|
|||||||
name: currentLayout.name,
|
name: currentLayout.name,
|
||||||
containers: [
|
containers: [
|
||||||
{
|
{
|
||||||
id: UnifiedLayoutManager.generateContainerId(),
|
id: crypto.randomUUID(),
|
||||||
type: 'container',
|
type: 'container', // Using 'container' as per ULM default, was 'grid' in previous context but 'container' in ULM
|
||||||
columns: 1,
|
columns: 1,
|
||||||
gap: 16,
|
gap: 16, // ULM default
|
||||||
widgets: [],
|
widgets: [],
|
||||||
children: [],
|
children: [],
|
||||||
order: 0
|
order: 0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
// Preserve created if exists
|
||||||
createdAt: currentLayout.createdAt,
|
createdAt: currentLayout.createdAt,
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update the in-memory cache
|
|
||||||
setLoadedPages(prev => new Map(prev).set(pageId, clearedLayout));
|
setLoadedPages(prev => new Map(prev).set(pageId, clearedLayout));
|
||||||
|
|
||||||
// Save to localStorage cache
|
// Also clear persistence
|
||||||
await saveLayoutToCache(pageId);
|
await UnifiedLayoutManager.savePageLayout(clearedLayout);
|
||||||
|
|
||||||
|
// Clear history as this is a destructive reset
|
||||||
|
historyManager.clear();
|
||||||
|
updateHistoryState();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to clear page layout ${pageId}:`, error);
|
console.error('Failed to clear layout:', error);
|
||||||
throw 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> => {
|
const addWidgetToPage = async (pageId: string, containerId: string, widgetId: string, targetColumn?: number): Promise<WidgetInstance> => {
|
||||||
try {
|
const currentLayout = loadedPages.get(pageId);
|
||||||
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);
|
const container = UnifiedLayoutManager.findContainer(currentLayout.containers, containerId);
|
||||||
currentLayout.updatedAt = Date.now();
|
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;
|
// Command takes the resolved index
|
||||||
} catch (error) {
|
const command = new AddWidgetCommand(pageId, containerId, newWidget, index);
|
||||||
console.error(`Failed to add widget to page ${pageId}:`, error);
|
|
||||||
throw error;
|
await historyManager.execute(command, {
|
||||||
}
|
pageId,
|
||||||
|
layouts: loadedPages,
|
||||||
|
updateLayout: updateLayoutCallback
|
||||||
|
});
|
||||||
|
|
||||||
|
updateHistoryState();
|
||||||
|
|
||||||
|
return newWidget;
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeWidgetFromPage = async (pageId: string, widgetInstanceId: string) => {
|
const removeWidgetFromPage = async (pageId: string, widgetInstanceId: string) => {
|
||||||
try {
|
const command = new RemoveWidgetCommand(pageId, widgetInstanceId);
|
||||||
const currentLayout = loadedPages.get(pageId);
|
|
||||||
if (!currentLayout) {
|
|
||||||
throw new Error(`Layout for page ${pageId} not loaded`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const removed = UnifiedLayoutManager.removeWidgetFromContainer(currentLayout, widgetInstanceId);
|
await historyManager.execute(command, {
|
||||||
if (!removed) {
|
pageId,
|
||||||
throw new Error(`Widget ${widgetInstanceId} not found`);
|
layouts: loadedPages,
|
||||||
}
|
updateLayout: updateLayoutCallback
|
||||||
|
});
|
||||||
currentLayout.updatedAt = Date.now();
|
updateHistoryState();
|
||||||
|
|
||||||
await saveLayoutToCache(pageId);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to remove widget from page ${pageId}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Legacy / Non-Command Implementation for now (Todo: migrate) ---
|
||||||
|
|
||||||
const moveWidgetInPage = async (pageId: string, widgetInstanceId: string, direction: 'up' | 'down' | 'left' | 'right') => {
|
const moveWidgetInPage = async (pageId: string, widgetInstanceId: string, direction: 'up' | 'down' | 'left' | 'right') => {
|
||||||
try {
|
try {
|
||||||
const currentLayout = loadedPages.get(pageId);
|
const currentLayout = loadedPages.get(pageId);
|
||||||
if (!currentLayout) {
|
if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`);
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error(`Failed to move widget in page ${pageId}:`, error);
|
console.error('Failed to move widget:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -203,20 +230,17 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
|
|||||||
const updatePageContainerColumns = async (pageId: string, containerId: string, columns: number) => {
|
const updatePageContainerColumns = async (pageId: string, containerId: string, columns: number) => {
|
||||||
try {
|
try {
|
||||||
const currentLayout = loadedPages.get(pageId);
|
const currentLayout = loadedPages.get(pageId);
|
||||||
if (!currentLayout) {
|
if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`);
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error(`Failed to update container columns in page ${pageId}:`, error);
|
console.error('Failed to update columns:', error);
|
||||||
throw 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']>) => {
|
const updatePageContainerSettings = async (pageId: string, containerId: string, settings: Partial<LayoutContainer['settings']>) => {
|
||||||
try {
|
try {
|
||||||
const currentLayout = loadedPages.get(pageId);
|
const currentLayout = loadedPages.get(pageId);
|
||||||
if (!currentLayout) {
|
if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`);
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error(`Failed to update container settings in page ${pageId}:`, error);
|
console.error('Failed to update container settings:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addPageContainer = async (pageId: string, parentContainerId?: string): Promise<LayoutContainer> => {
|
const addPageContainer = async (pageId: string, parentContainerId?: string) => {
|
||||||
try {
|
try {
|
||||||
const currentLayout = loadedPages.get(pageId);
|
const currentLayout = loadedPages.get(pageId);
|
||||||
if (!currentLayout) {
|
if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`);
|
||||||
throw new Error(`Layout for page ${pageId} not loaded`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = UnifiedLayoutManager.addContainer(currentLayout, parentContainerId);
|
const newLayout = JSON.parse(JSON.stringify(currentLayout)) as PageLayout;
|
||||||
currentLayout.updatedAt = Date.now();
|
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) {
|
} catch (error) {
|
||||||
console.error(`Failed to add container to page ${pageId}:`, error);
|
console.error('Failed to add container:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -264,23 +284,17 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
|
|||||||
const removePageContainer = async (pageId: string, containerId: string) => {
|
const removePageContainer = async (pageId: string, containerId: string) => {
|
||||||
try {
|
try {
|
||||||
const currentLayout = loadedPages.get(pageId);
|
const currentLayout = loadedPages.get(pageId);
|
||||||
if (!currentLayout) {
|
if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`);
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error(`Failed to remove container from page ${pageId}:`, error);
|
console.error('Failed to remove container:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -288,42 +302,34 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
|
|||||||
const movePageContainer = async (pageId: string, containerId: string, direction: 'up' | 'down') => {
|
const movePageContainer = async (pageId: string, containerId: string, direction: 'up' | 'down') => {
|
||||||
try {
|
try {
|
||||||
const currentLayout = loadedPages.get(pageId);
|
const currentLayout = loadedPages.get(pageId);
|
||||||
if (!currentLayout) {
|
if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`);
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error(`Failed to move container in page ${pageId}:`, error);
|
console.error('Failed to move container:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateWidgetProps = async (pageId: string, widgetInstanceId: string, props: Record<string, any>) => {
|
const updateWidgetProps = async (pageId: string, widgetInstanceId: string, props: Record<string, any>) => {
|
||||||
try {
|
try {
|
||||||
const currentLayout = loadedPages.get(pageId);
|
const command = new UpdateWidgetSettingsCommand(pageId, widgetInstanceId, props);
|
||||||
if (!currentLayout) {
|
|
||||||
throw new Error(`Layout for page ${pageId} not loaded`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = UnifiedLayoutManager.updateWidgetProps(currentLayout, widgetInstanceId, props);
|
await historyManager.execute(command, {
|
||||||
if (!updated) {
|
pageId,
|
||||||
throw new Error(`Widget ${widgetInstanceId} not found`);
|
layouts: loadedPages,
|
||||||
}
|
updateLayout: updateLayoutCallback
|
||||||
|
});
|
||||||
|
|
||||||
currentLayout.updatedAt = Date.now();
|
updateHistoryState();
|
||||||
|
|
||||||
await saveLayoutToCache(pageId);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to update widget props in page ${pageId}:`, error);
|
console.error('Failed to update widget props:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -331,110 +337,99 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
|
|||||||
const renameWidget = async (pageId: string, widgetInstanceId: string, newId: string): Promise<boolean> => {
|
const renameWidget = async (pageId: string, widgetInstanceId: string, newId: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const currentLayout = loadedPages.get(pageId);
|
const currentLayout = loadedPages.get(pageId);
|
||||||
if (!currentLayout) {
|
if (!currentLayout) throw new Error(`Layout for page ${pageId} not loaded`);
|
||||||
throw new Error(`Layout for page ${pageId} not loaded`);
|
|
||||||
|
const newLayout = JSON.parse(JSON.stringify(currentLayout)) as PageLayout;
|
||||||
|
const success = UnifiedLayoutManager.renameWidget(newLayout, widgetInstanceId, newId);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
setLoadedPages(prev => new Map(prev).set(pageId, newLayout));
|
||||||
|
await saveLayoutToCache(pageId, newLayout);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
const success = UnifiedLayoutManager.renameWidget(currentLayout, widgetInstanceId, newId);
|
|
||||||
if (!success) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentLayout.updatedAt = Date.now();
|
|
||||||
|
|
||||||
await saveLayoutToCache(pageId);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to rename widget in page ${pageId}:`, error);
|
console.error('Failed to rename widget:', error);
|
||||||
throw 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> => {
|
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 {
|
try {
|
||||||
// Save all loaded pages to database
|
const importedLayout = JSON.parse(jsonData) as PageLayout;
|
||||||
let allSaved = true;
|
importedLayout.id = pageId;
|
||||||
for (const [pageId, layout] of loadedPages.entries()) {
|
|
||||||
try {
|
setLoadedPages(prev => new Map(prev).set(pageId, importedLayout));
|
||||||
await UnifiedLayoutManager.savePageLayout(layout);
|
await UnifiedLayoutManager.savePageLayout(importedLayout);
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to save page ${pageId}:`, error);
|
historyManager.clear();
|
||||||
allSaved = false;
|
updateHistoryState();
|
||||||
}
|
|
||||||
}
|
return importedLayout;
|
||||||
return allSaved;
|
} catch (e) {
|
||||||
} catch (error) {
|
console.error('Failed to import layout:', e);
|
||||||
console.error('Failed to save layouts to database:', error);
|
throw e;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const value: LayoutContextType = {
|
const hydratePageLayout = (pageId: string, layout: PageLayout) => {
|
||||||
loadPageLayout,
|
setLoadedPages(prev => new Map(prev).set(pageId, layout));
|
||||||
getLoadedPageLayout,
|
};
|
||||||
clearPageLayout,
|
|
||||||
addWidgetToPage,
|
const undo = async () => {
|
||||||
removeWidgetFromPage,
|
await historyManager.undo({
|
||||||
moveWidgetInPage,
|
pageId: '',
|
||||||
updatePageContainerColumns,
|
layouts: loadedPages,
|
||||||
updatePageContainerSettings,
|
updateLayout: updateLayoutCallback
|
||||||
addPageContainer,
|
});
|
||||||
removePageContainer,
|
updateHistoryState();
|
||||||
movePageContainer,
|
};
|
||||||
updateWidgetProps,
|
|
||||||
exportPageLayout,
|
const redo = async () => {
|
||||||
importPageLayout,
|
await historyManager.redo({
|
||||||
hydratePageLayout,
|
pageId: '',
|
||||||
saveToApi,
|
layouts: loadedPages,
|
||||||
isLoading,
|
updateLayout: updateLayoutCallback
|
||||||
loadedPages,
|
});
|
||||||
renameWidget,
|
updateHistoryState();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LayoutContext.Provider value={value}>
|
<LayoutContext.Provider value={{
|
||||||
|
loadPageLayout,
|
||||||
|
getLoadedPageLayout,
|
||||||
|
clearPageLayout,
|
||||||
|
addWidgetToPage,
|
||||||
|
removeWidgetFromPage,
|
||||||
|
moveWidgetInPage,
|
||||||
|
updatePageContainerColumns,
|
||||||
|
updatePageContainerSettings,
|
||||||
|
addPageContainer,
|
||||||
|
removePageContainer,
|
||||||
|
movePageContainer,
|
||||||
|
updateWidgetProps,
|
||||||
|
renameWidget,
|
||||||
|
saveToApi,
|
||||||
|
exportPageLayout,
|
||||||
|
importPageLayout,
|
||||||
|
hydratePageLayout,
|
||||||
|
isLoading,
|
||||||
|
loadedPages,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
canUndo,
|
||||||
|
canRedo
|
||||||
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</LayoutContext.Provider>
|
</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) => {
|
export const fetchPostById = async (id: string, client?: SupabaseClient) => {
|
||||||
// Use API-mediated fetching instead of direct Supabase calls
|
// Use API-mediated fetching instead of direct Supabase calls
|
||||||
// This returns enriched FeedPost data including category_paths, author info, etc.
|
// 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);
|
if (requestCache.has(cacheKey)) requestCache.delete(cacheKey);
|
||||||
const fullCacheKey = `full-post-${postId}`;
|
const fullCacheKey = `full-post-${postId}`;
|
||||||
if (requestCache.has(fullCacheKey)) requestCache.delete(fullCacheKey);
|
if (requestCache.has(fullCacheKey)) requestCache.delete(fullCacheKey);
|
||||||
|
|
||||||
|
// Invalidate Server Cache
|
||||||
|
await invalidateServerCache(['posts']);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const unlinkPictures = async (ids: string[], client?: SupabaseClient) => {
|
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()}`;
|
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)
|
// Load root data from storage (database-only, no localStorage)
|
||||||
static async loadRootData(pageId?: string): Promise<RootLayoutData> {
|
static async loadRootData(pageId?: string): Promise<RootLayoutData> {
|
||||||
try {
|
try {
|
||||||
@ -160,6 +189,29 @@ export class UnifiedLayoutManager {
|
|||||||
return null;
|
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
|
// Add widget to specific container
|
||||||
static addWidgetToContainer(
|
static addWidgetToContainer(
|
||||||
layout: PageLayout,
|
layout: PageLayout,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { invalidateServerCache } from '@/lib/db';
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { MediaItem, PostItem, User } from "@/types";
|
import { MediaItem, PostItem, User } from "@/types";
|
||||||
@ -47,6 +48,7 @@ export const usePostActions = ({
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
toast.success(translate('Post deleted'));
|
toast.success(translate('Post deleted'));
|
||||||
|
await invalidateServerCache(['posts']);
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting post:', error);
|
console.error('Error deleting post:', error);
|
||||||
@ -163,6 +165,7 @@ export const usePostActions = ({
|
|||||||
toast.success(translate('Categories updated'));
|
toast.success(translate('Categories updated'));
|
||||||
|
|
||||||
// Trigger parent refresh
|
// Trigger parent refresh
|
||||||
|
await invalidateServerCache(['posts']);
|
||||||
fetchMedia();
|
fetchMedia();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update post meta:', 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 { useParams, useNavigate, Link } from "react-router-dom";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { ArrowLeft, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||||
import { ArrowLeft, FileText, Calendar, Eye, EyeOff, Edit, Edit3, Check, X, Plus, PanelLeftClose, PanelLeftOpen, FolderTree } from "lucide-react";
|
|
||||||
import { T, translate } from "@/i18n";
|
import { T, translate } from "@/i18n";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { GenericCanvas } from "@/components/hmi/GenericCanvas";
|
import { GenericCanvas } from "@/components/hmi/GenericCanvas";
|
||||||
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||||
|
|
||||||
import { PageActions } from "@/components/PageActions";
|
import { PageActions } from "@/components/PageActions";
|
||||||
|
import { WidgetPropertyPanel } from "@/components/widgets/WidgetPropertyPanel";
|
||||||
import MarkdownRenderer from "@/components/MarkdownRenderer";
|
import MarkdownRenderer from "@/components/MarkdownRenderer";
|
||||||
import { Sidebar } from "@/components/sidebar/Sidebar";
|
import { Sidebar } from "@/components/sidebar/Sidebar";
|
||||||
import { TableOfContents } from "@/components/sidebar/TableOfContents";
|
import { TableOfContents } from "@/components/sidebar/TableOfContents";
|
||||||
@ -19,6 +19,14 @@ import { MobileTOC } from "@/components/sidebar/MobileTOC";
|
|||||||
import { extractHeadings, extractHeadingsFromLayout, MarkdownHeading } from "@/lib/toc";
|
import { extractHeadings, extractHeadingsFromLayout, MarkdownHeading } from "@/lib/toc";
|
||||||
import { useLayout } from "@/contexts/LayoutContext";
|
import { useLayout } from "@/contexts/LayoutContext";
|
||||||
import { fetchUserPage, invalidateUserPageCache } from "@/lib/db";
|
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 {
|
interface Page {
|
||||||
id: string;
|
id: string;
|
||||||
@ -82,18 +90,12 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isEditMode, setIsEditMode] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = 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
|
// TOC State
|
||||||
const [headings, setHeadings] = useState<MarkdownHeading[]>([]);
|
const [headings, setHeadings] = useState<MarkdownHeading[]>([]);
|
||||||
@ -107,6 +109,111 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
|
|||||||
|
|
||||||
const isOwner = currentUser?.id === userId;
|
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(() => {
|
useEffect(() => {
|
||||||
if (initialPage) {
|
if (initialPage) {
|
||||||
setLoading(false);
|
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
|
// Reactive Heading Extraction
|
||||||
// This ensures we extract headings whenever the page loads OR when specific layouts are loaded into context
|
// This ensures we extract headings whenever the page loads OR when specific layouts are loaded into context
|
||||||
const { loadedPages } = useLayout();
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@ -389,20 +378,44 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
|
|||||||
return (
|
return (
|
||||||
<div className={`${embedded ? 'h-full' : 'h-[calc(100vh-3.5rem)]'} bg-background flex flex-col overflow-hidden`}>
|
<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) - Fixed if not embedded */}
|
||||||
|
{/* Top Header (Back button) or Ribbon Bar - Fixed if not embedded */}
|
||||||
{!embedded && (
|
{!embedded && (
|
||||||
<div className="border-b bg-background/95 backdrop-blur z-10 shrink-0">
|
isEditMode && isOwner ? (
|
||||||
<div className="container mx-auto py-2">
|
<Suspense fallback={<div className="h-24 bg-muted/30 animate-pulse w-full border-b" />}>
|
||||||
<Button
|
<PageRibbonBar
|
||||||
variant="ghost"
|
page={page}
|
||||||
size="sm"
|
isOwner={isOwner}
|
||||||
onClick={() => navigate(orgSlug ? `/org/${orgSlug}/user/${userId}` : `/user/${userId}`)}
|
onToggleEditMode={() => {
|
||||||
className="text-muted-foreground hover:text-foreground"
|
setIsEditMode(false);
|
||||||
>
|
setSelectedWidgetId(null);
|
||||||
<ArrowLeft className="h-4 w-4 md:mr-2" />
|
setSelectedContainerId(null);
|
||||||
<span className="hidden md:inline"><T>Back to profile</T></span>
|
}}
|
||||||
</Button>
|
onPageUpdate={handlePageUpdate}
|
||||||
</div>
|
onDelete={() => {
|
||||||
</div>
|
// 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 */}
|
{/* Main Split Layout */}
|
||||||
@ -411,6 +424,7 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
|
|||||||
{/* Sidebar Left - Fixed width, independent scroll */}
|
{/* Sidebar Left - Fixed width, independent scroll */}
|
||||||
{(headings.length > 0 || childPages.length > 0) && (
|
{(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 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`}>
|
<div className={`flex items-center ${isSidebarCollapsed ? 'justify-center' : 'justify-end'} p-2 sticky top-0 bg-background/50 z-10`}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -457,260 +471,97 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Right Content - Independent scroll */}
|
{/* 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">
|
||||||
<div className="container mx-auto p-4 md:p-8 max-w-5xl">
|
<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 */}
|
{/* Mobile TOC */}
|
||||||
<div className="lg:hidden mb-6">
|
<div className="lg:hidden mb-6">
|
||||||
{headings.length > 0 && <MobileTOC headings={headings} />}
|
{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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className="my-6" />
|
<UserPageDetails
|
||||||
|
|
||||||
{/* 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
|
|
||||||
page={page}
|
page={page}
|
||||||
|
userProfile={userProfile}
|
||||||
isOwner={isOwner}
|
isOwner={isOwner}
|
||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
onToggleEditMode={() => setIsEditMode(!isEditMode)}
|
userId={userId || ''} // Fallback if undefined, though it should be defined if loaded
|
||||||
|
orgSlug={orgSlug}
|
||||||
onPageUpdate={handlePageUpdate}
|
onPageUpdate={handlePageUpdate}
|
||||||
onMetaUpdated={() => userId && page.slug && fetchUserPageData(userId, page.slug)}
|
onToggleEditMode={() => setIsEditMode(!isEditMode)}
|
||||||
|
onWidgetRename={setSelectedWidgetId}
|
||||||
|
templates={templates}
|
||||||
|
onLoadTemplate={handleLoadTemplate}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className="my-6" />
|
{/* Content Body */}
|
||||||
|
<div>
|
||||||
{/* Editable Tags */}
|
{page.content && typeof page.content === 'string' ? (
|
||||||
{editingTags && isOwner && isEditMode ? (
|
<div className="prose prose-lg dark:prose-invert max-w-none pb-12">
|
||||||
<div className="flex items-start gap-2">
|
<MarkdownRenderer content={page.content} />
|
||||||
<div className="flex-1">
|
</div>
|
||||||
<Input
|
) : (
|
||||||
value={tagsValue}
|
<GenericCanvas
|
||||||
onChange={(e) => setTagsValue(e.target.value)}
|
pageId={`page-${page.id}`}
|
||||||
className="text-sm"
|
pageName={page.title}
|
||||||
placeholder="tag1, tag2, tag3..."
|
isEditMode={isEditMode && isOwner}
|
||||||
onKeyDown={(e) => {
|
showControls={isEditMode && isOwner}
|
||||||
if (e.key === 'Enter') handleSaveTags();
|
initialLayout={page.content}
|
||||||
if (e.key === 'Escape') setEditingTags(false);
|
selectedWidgetId={selectedWidgetId}
|
||||||
}}
|
onSelectWidget={setSelectedWidgetId}
|
||||||
autoFocus
|
selectedContainerId={selectedContainerId}
|
||||||
disabled={savingField === 'tags'}
|
onSelectContainer={setSelectedContainerId}
|
||||||
|
editingWidgetId={editingWidgetId}
|
||||||
|
onEditWidget={handleEditWidget}
|
||||||
|
newlyAddedWidgetId={newlyAddedWidgetId}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Separate tags with commas
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={handleSaveTags}
|
|
||||||
disabled={savingField === 'tags'}
|
|
||||||
>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setEditingTags(false)}
|
|
||||||
disabled={savingField === 'tags'}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2 flex-wrap w-full">
|
|
||||||
{page.tags && page.tags.map((tag, index) => (
|
|
||||||
<Badge
|
|
||||||
key={index}
|
|
||||||
variant="secondary"
|
|
||||||
className={isOwner && isEditMode ? 'cursor-pointer hover:bg-secondary/80' : ''}
|
|
||||||
onClick={() => isOwner && isEditMode && handleStartEditTags()}
|
|
||||||
>
|
|
||||||
#{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{isOwner && isEditMode && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 text-xs text-muted-foreground"
|
|
||||||
onClick={handleStartEditTags}
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3 mr-1" />
|
|
||||||
<T>Edit Tags</T>
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content Body */}
|
{/* Footer */}
|
||||||
<div>
|
<div className="mt-8 pt-8 border-t text-sm text-muted-foreground">
|
||||||
{page.content && typeof page.content === 'string' ? (
|
<div className="flex items-center justify-between">
|
||||||
<div className="prose prose-lg dark:prose-invert max-w-none pb-12">
|
<div>
|
||||||
<MarkdownRenderer content={page.content} />
|
<T>Last updated:</T> {new Date(page.updated_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{page.parent && (
|
||||||
|
<Link
|
||||||
|
to={`/pages/${page.parent}`}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<T>View parent page</T>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<GenericCanvas
|
|
||||||
pageId={`page-${page.id}`}
|
|
||||||
pageName={page.title}
|
|
||||||
isEditMode={isEditMode && isOwner}
|
|
||||||
showControls={isEditMode && isOwner}
|
|
||||||
initialLayout={page.content}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="mt-8 pt-8 border-t text-sm text-muted-foreground">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<T>Last updated:</T> {new Date(page.updated_at).toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{page.parent && (
|
|
||||||
<Link
|
|
||||||
to={`/pages/${page.parent}`}
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
>
|
|
||||||
<T>View parent page</T>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
</div>
|
{/* Right Sidebar - Property Panel */}
|
||||||
</div>
|
{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>
|
||||||
</div >);
|
</div >);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -168,5 +168,13 @@ export interface Author {
|
|||||||
user_id: string
|
user_id: string
|
||||||
username: string
|
username: string
|
||||||
display_name: 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