more ui shit

This commit is contained in:
lovebird 2026-02-20 13:37:11 +01:00
parent 2cf5af613d
commit 08656eddcb
17 changed files with 514 additions and 331 deletions

View File

@ -2,19 +2,43 @@ import { useQuery } from "@tanstack/react-query";
import { fetchCategories, type Category } from "@/modules/categories/client-categories";
import { useNavigate, useParams } from "react-router-dom";
import { cn } from "@/lib/utils";
import { useState, useCallback } from "react";
import { useState, useCallback, useMemo } from "react";
import { FolderTree, ChevronRight, ChevronDown, Home, Loader2 } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
interface CategoryTreeViewProps {
/** Called after a category is selected (useful for closing mobile sheet) */
onNavigate?: () => void;
/** Only show categories whose meta.type matches this value */
filterType?: string;
}
const CategoryTreeView = ({ onNavigate }: CategoryTreeViewProps) => {
const COLLAPSED_KEY = 'categoryTreeCollapsed';
/** Recursively filter a category tree by meta.type */
const filterByType = (cats: Category[], type: string): Category[] =>
cats.reduce<Category[]>((acc, cat) => {
if (cat.meta?.type === type) {
// Category matches — include it, but also filter its children
const filteredChildren = cat.children
? cat.children.filter(rel => !rel.child.meta?.type || rel.child.meta.type === type)
: undefined;
acc.push({ ...cat, children: filteredChildren });
}
return acc;
}, []);
const CategoryTreeView = ({ onNavigate, filterType }: CategoryTreeViewProps) => {
const { slug: activeSlug } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
// Track explicitly collapsed nodes (everything else is expanded by default)
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(() => {
try {
const stored = localStorage.getItem(COLLAPSED_KEY);
return stored ? new Set(JSON.parse(stored)) : new Set();
} catch { return new Set(); }
});
const { data: categories = [], isLoading } = useQuery({
queryKey: ['categories'],
@ -22,11 +46,17 @@ const CategoryTreeView = ({ onNavigate }: CategoryTreeViewProps) => {
staleTime: 1000 * 60 * 5,
});
const filteredCategories = useMemo(
() => filterType ? filterByType(categories, filterType) : categories,
[categories, filterType]
);
const toggleExpand = useCallback((id: string) => {
setExpandedIds(prev => {
setCollapsedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...next]));
return next;
});
}, []);
@ -43,7 +73,7 @@ const CategoryTreeView = ({ onNavigate }: CategoryTreeViewProps) => {
const renderNode = (cat: Category, depth: number = 0) => {
const hasChildren = cat.children && cat.children.length > 0;
const isActive = cat.slug === activeSlug;
const isExpanded = expandedIds.has(cat.id);
const isExpanded = hasChildren && !collapsedIds.has(cat.id);
return (
<div key={cat.id}>
@ -117,7 +147,7 @@ const CategoryTreeView = ({ onNavigate }: CategoryTreeViewProps) => {
</button>
{/* Category tree */}
{categories.map(cat => renderNode(cat))}
{filteredCategories.map(cat => renderNode(cat))}
</nav>
);
};

View File

@ -62,7 +62,6 @@ const Comments = ({ pictureId, initialComments }: CommentsProps) => {
}, [pictureId, initialComments]);
const fetchComments = async () => {
console.log('Fetching comments for picture:', pictureId, 'Initial:', !!initialComments);
try {
let data: Comment[] = [];

View File

@ -1488,7 +1488,7 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
/>
)}
<div className="container mx-auto p-3 md:p-6 space-y-4 md:space-y-6">
<div className="space-y-4 p-2 md:space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 md:gap-3">

View File

@ -67,78 +67,93 @@ const ImageItem = ({
onMove: (index: number, direction: 'up' | 'down') => void;
}) => {
return (
<div className="flex gap-4 p-4 bg-muted/40 rounded-lg border group animate-in fade-in slide-in-from-bottom-2 duration-300">
{/* Reorder Controls */}
<div className="flex flex-col gap-1 justify-center">
<Button
variant="ghost"
size="icon"
onClick={() => onMove(index, 'up')}
disabled={index === 0}
className="h-8 w-8 text-muted-foreground hover:text-foreground disabled:opacity-30"
title="Move Up"
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onMove(index, 'down')}
disabled={index === total - 1}
className="h-8 w-8 text-muted-foreground hover:text-foreground disabled:opacity-30"
title="Move Down"
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-col sm:flex-row gap-4 p-4 bg-muted/40 rounded-lg border group animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className="flex items-start gap-4">
{/* Reorder Controls */}
<div className="flex flex-col gap-1 justify-center h-32">
<Button
variant="ghost"
size="icon"
onClick={() => onMove(index, 'up')}
disabled={index === 0}
className="h-8 w-8 text-muted-foreground hover:text-foreground disabled:opacity-30"
title="Move Up"
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onMove(index, 'down')}
disabled={index === total - 1}
className="h-8 w-8 text-muted-foreground hover:text-foreground disabled:opacity-30"
title="Move Down"
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
{/* Thumbnail */}
<div className="w-32 h-32 flex-shrink-0 bg-background rounded-md overflow-hidden border relative">
{image.type === 'video' ? (
image.uploadStatus === 'ready' ? (
<>
<img
src={image.src}
alt={image.title}
className="w-full h-full object-cover"
draggable={false}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<div className="w-8 h-8 rounded-full bg-white/90 flex items-center justify-center">
<svg className="w-4 h-4 text-black ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z" /></svg>
{/* Thumbnail */}
<div className="w-32 h-32 flex-shrink-0 bg-background rounded-md overflow-hidden border relative">
{image.type === 'video' ? (
image.uploadStatus === 'ready' ? (
<>
<img
src={image.src}
alt={image.title}
className="w-full h-full object-cover"
draggable={false}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<div className="w-8 h-8 rounded-full bg-white/90 flex items-center justify-center">
<svg className="w-4 h-4 text-black ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z" /></svg>
</div>
</div>
</>
) : (
<div className="w-full h-full flex flex-col items-center justify-center bg-muted">
{image.uploadStatus === 'error' ? (
<span className="text-destructive text-xs text-center p-1"><T>Upload Failed</T></span>
) : (
<>
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin mb-2" />
<span className="text-xs text-muted-foreground">{image.uploadProgress}%</span>
</>
)}
</div>
</>
)
) : (
<div className="w-full h-full flex flex-col items-center justify-center bg-muted">
{image.uploadStatus === 'error' ? (
<span className="text-destructive text-xs text-center p-1"><T>Upload Failed</T></span>
) : (
<>
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin mb-2" />
<span className="text-xs text-muted-foreground">{image.uploadProgress}%</span>
</>
)}
</div>
)
) : (
<img
src={image.src}
alt={image.title}
className="w-full h-full object-cover"
draggable={false}
/>
)}
<img
src={image.src}
alt={image.title}
className="w-full h-full object-cover"
draggable={false}
/>
)}
</div>
{/* Actions - Mobile */}
<div className="sm:hidden flex flex-col gap-2 ml-auto">
<Button
variant="ghost"
size="icon"
onClick={() => onRemove(image.id)}
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Remove"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* Inputs */}
<div className="flex-1 space-y-3">
<div className="flex-1 space-y-3 w-full">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground"><T>Title</T></label>
<Input
value={image.title}
onChange={(e) => onUpdate(image.id, 'title', e.target.value)}
className="h-9"
className="h-9 w-full"
placeholder="Image title..."
/>
</div>
@ -147,14 +162,14 @@ const ImageItem = ({
<Textarea
value={image.description || ''}
onChange={(e) => onUpdate(image.id, 'description', e.target.value)}
className="min-h-[60px] resize-none text-sm"
className="min-h-[60px] resize-none text-sm w-full"
placeholder="Add a caption..."
/>
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-2">
{/* Actions - Desktop */}
<div className="hidden sm:flex flex-col gap-2">
<Button
variant="ghost"
size="icon"
@ -225,14 +240,10 @@ export const PostComposer: React.FC<PostComposerProps> = ({
return (
<div className="h-full flex flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between pb-2 border-b">
<div>
<h2 className="text-lg font-semibold"><T>{isEditing ? "Update Post" : "Create Post"}</T></h2>
<p className="text-sm text-muted-foreground"><T>Arrange your images and add captions</T></p>
</div>
<div className="flex gap-2">
<Button onClick={() => fileInputRef.current?.click()} size="sm" variant="outline" disabled={isPublishing}>
<Plus className="h-4 w-4 mr-2" />
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between pb-3 border-b gap-3">
<div className="flex items-center gap-2 w-full sm:w-auto">
<Button onClick={() => fileInputRef.current?.click()} size="sm" variant="outline" disabled={isPublishing} className="flex-1 sm:flex-none">
<Plus className="h-4 w-4 mr-2 shrink-0" />
<T>Add Images</T>
</Button>
{isEditing && postId && (
@ -240,53 +251,53 @@ export const PostComposer: React.FC<PostComposerProps> = ({
onClick={() => setShowCollectionModal(true)}
size="sm"
variant="outline"
className="text-muted-foreground hover:text-primary"
className="text-muted-foreground hover:text-primary flex-1 sm:flex-none"
>
<Bookmark className="h-4 w-4 mr-2" />
<Bookmark className="h-4 w-4 mr-2 shrink-0" />
<T>Collections</T>
</Button>
)}
<div className="flex rounded-md shadow-sm">
<Button
onClick={onPublish}
size="sm"
disabled={isPublishing || images.length === 0}
className="rounded-r-none"
>
{isPublishing ? (
<>
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" />
<T>{isEditing ? "Updating..." : "Publishing..."}</T>
</>
) : (
<>
<T>{isEditing ? "Update Post" : "Quick Publish (Default)"}</T>
</>
)}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" className="rounded-l-none border-l border-primary-foreground/20 px-2" disabled={isPublishing || images.length === 0}>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onPublish}>
<T>Quick Publish (Default)</T>
</div>
<div className="flex rounded-md shadow-sm w-full sm:w-auto">
<Button
onClick={onPublish}
size="sm"
disabled={isPublishing || images.length === 0}
className="rounded-r-none flex-1 sm:flex-none"
>
{isPublishing ? (
<>
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" />
<T>{isEditing ? "Updating..." : "Publishing..."}</T>
</>
) : (
<>
<T>{isEditing ? "Update Post" : "Quick Publish (Default)"}</T>
</>
)}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" className="rounded-l-none border-l border-primary-foreground/20 px-2" disabled={isPublishing || images.length === 0}>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onPublish}>
<T>Quick Publish (Default)</T>
</DropdownMenuItem>
{onPublishToGallery && (
<DropdownMenuItem onClick={onPublishToGallery}>
<T>Publish as Picture</T>
</DropdownMenuItem>
{onPublishToGallery && (
<DropdownMenuItem onClick={onPublishToGallery}>
<T>Publish as Picture</T>
</DropdownMenuItem>
)}
{onAppendToPost && !isEditing && (
<DropdownMenuItem onClick={onAppendToPost}>
<T>Append to Existing Post</T>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{onAppendToPost && !isEditing && (
<DropdownMenuItem onClick={onAppendToPost}>
<T>Append to Existing Post</T>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
@ -375,10 +386,10 @@ export const PostComposer: React.FC<PostComposerProps> = ({
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={`flex-1 overflow-y-auto space-y-4 pr-2 min-h-[200px] transition-colors rounded-lg ${isDragging ? 'bg-primary/5 ring-2 ring-primary ring-inset' : ''}`}
className={`space-y-4 pr-2 min-h-[200px] transition-colors rounded-lg ${isDragging ? 'bg-primary/5 ring-2 ring-primary ring-inset' : ''}`}
>
{images.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center p-8 border-2 border-dashed rounded-lg text-muted-foreground">
<div className="flex flex-col items-center justify-center text-center p-8 border-2 border-dashed rounded-lg text-muted-foreground min-h-[200px]">
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
<Plus className="h-8 w-8 opacity-50" />
</div>
@ -414,13 +425,15 @@ export const PostComposer: React.FC<PostComposerProps> = ({
onChange={onFileUpload}
/>
{isEditing && postId && (
<AddToCollectionModal
isOpen={showCollectionModal}
onClose={() => setShowCollectionModal(false)}
postId={postId}
/>
)}
</div>
{
isEditing && postId && (
<AddToCollectionModal
isOpen={showCollectionModal}
onClose={() => setShowCollectionModal(false)}
postId={postId}
/>
)
}
</div >
);
};

View File

@ -117,7 +117,13 @@ export const ListLayout = ({
const handleItemClick = (item: any) => {
if (isMobile) {
navigate(`/post/${item.id}`);
const slug = item.meta?.slug || item.cover?.meta?.slug || item.pictures?.[0]?.meta?.slug;
if (item.type === 'page-intern' && slug) {
const usernameOrId = item.author?.username || item.user_id;
navigate(`/user/${usernameOrId}/pages/${slug}`);
} else {
navigate(`/post/${item.id}`);
}
} else {
setSelectedId(item.id);
}

View File

@ -5,23 +5,18 @@ import { UserPicker } from "./UserPicker";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Trash2, AlertCircle, Check, Loader2, Shield } from "lucide-react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { fetchAclSettings, grantAclPermission, revokeAclPermission, type AclEntry } from "@/modules/user/client-acl";
interface AclEditorProps {
/** Resource type — e.g. 'vfs', 'layout', 'page' */
resourceType?: string;
mount: string;
path: string;
}
interface AclEntry {
path?: string;
userId?: string;
group?: string;
permissions: string[];
}
export function AclEditor({ mount, path }: AclEditorProps) {
export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps) {
const { session, user } = useAuth();
const [entries, setEntries] = useState<AclEntry[]>([]);
const [loading, setLoading] = useState(false);
@ -65,30 +60,11 @@ export function AclEditor({ mount, path }: AclEditorProps) {
const fetchAcl = async () => {
try {
setLoading(true);
// Fetch ALL ACLs for this mount first (to filter client side or server side?)
// The API /api/vfs/acl/:mount returns ALL acls for that mount.
// We should filter for relevant ones or show all?
// "permissions on any folder" -> usually means recursive or specific.
// Let's show all ACLs for this mount, but maybe highlight ones affecting current path?
// Or simpler: Just list all grants for the mount, since VFS ACLs are per-mount settings list.
const res = await fetch(`${import.meta.env.VITE_SERVER_IMAGE_API_URL}/api/vfs/acl/${encodeURIComponent(mount)}`, {
headers: { 'Authorization': `Bearer ${session?.access_token}` }
});
if (res.ok) {
const data = await res.json();
setEntries(data.acl || []);
} else {
// Squelch 404 if no settings yet, or handle error
if (res.status !== 404) {
console.error("Failed to load ACLs");
} else {
setEntries([]);
}
}
const settings = await fetchAclSettings(resourceType, mount);
setEntries(settings.acl || []);
} catch (e) {
console.error(e);
setEntries([]);
} finally {
setLoading(false);
}
@ -98,27 +74,13 @@ export function AclEditor({ mount, path }: AclEditorProps) {
if (!selectedUser) return;
setGranting(true);
try {
// Grant READ access to the current path
const res = await fetch(`${import.meta.env.VITE_SERVER_IMAGE_API_URL}/api/vfs/acl/grant/${encodeURIComponent(mount)}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${session?.access_token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
path: path, // Grant specifically on this folder/file
userId: selectedUser,
permissions: ['read', 'list'] // Default permissions
})
await grantAclPermission(resourceType, mount, {
path,
userId: selectedUser,
permissions: ['read', 'list'],
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to grant');
}
toast.success("Access granted");
fetchAcl(); // Reload
fetchAcl();
setSelectedUser("");
} catch (e: any) {
toast.error(e.message);
@ -130,24 +92,11 @@ export function AclEditor({ mount, path }: AclEditorProps) {
const handleRevoke = async (entry: AclEntry) => {
if (!confirm("Are you sure you want to revoke this permission?")) return;
try {
const res = await fetch(`${import.meta.env.VITE_SERVER_IMAGE_API_URL}/api/vfs/acl/revoke/${encodeURIComponent(mount)}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${session?.access_token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
path: entry.path,
userId: entry.userId,
group: entry.group
})
await revokeAclPermission(resourceType, mount, {
path: entry.path,
userId: entry.userId,
group: entry.group,
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to revoke');
}
toast.success("Access revoked");
fetchAcl();
} catch (e: any) {

View File

@ -207,8 +207,12 @@ const TabsWidget: React.FC<TabsWidgetProps> = ({
initialLayout={currentTab.layoutData} // Hydrate from embedded data
className="p-4"
selectedWidgetId={selectedWidgetId}
onSelectWidget={onSelectWidget}
onSelectContainer={onSelectContainer}
onSelectWidget={(id, pId) => {
onSelectWidget?.(id, pId);
}}
onSelectContainer={(id, pId) => {
onSelectContainer?.(id, pId);
}}
editingWidgetId={editingWidgetId}
onEditWidget={onEditWidget}
contextVariables={contextVariables}

View File

@ -36,6 +36,7 @@ const KEY_PREFIXES: [RegExp, (...groups: string[]) => string[]][] = [
[/^type-(.+)$/, (_, id) => ['types', id]],
[/^types-(.+)$/, (_, rest) => ['types', rest]],
[/^i18n-(.+)$/, (_, rest) => ['i18n', rest]],
[/^acl-(.+?)-(.+)$/, (_, type, id) => ['acl', type, id]],
];
export const parseQueryKey = (key: string): string[] => {

View File

@ -74,6 +74,7 @@ interface GenericCanvasProps {
newlyAddedWidgetId?: string | null;
contextVariables?: Record<string, any>;
onSave?: () => Promise<void | boolean>;
selectionBreadcrumb?: React.ReactNode;
}
const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
@ -92,7 +93,8 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
onEditWidget,
newlyAddedWidgetId,
contextVariables,
onSave
onSave,
selectionBreadcrumb
}) => {
const {
loadedPages,
@ -152,8 +154,8 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
);
}
const handleSelectContainer = (containerId: string, pageId?: string) => {
setSelectedContainer(containerId, pageId);
const handleSelectContainer = (containerId: string, innerPageId?: string) => {
setSelectedContainer(containerId, innerPageId);
};
const handleAddWidget = (containerId: string, columnIndex?: number) => {
@ -295,22 +297,12 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
{/* Header with Controls */}
{showControls && (
<div className="">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold glass-text">
{layout.name}
</h2>
</div>
{/* Edit Mode Controls */}
</div>
{/* Layout Info */}
<div className="mt-3 pt-3 border-t border-slate-300/30 dark:border-white/10">
<p className="text-xs text-slate-500 dark:text-slate-400">
<T>Containers</T>: {layout.containers.length} | <T>Widgets</T>: {totalWidgets} | <T>Last updated</T>: {new Date(layout.updatedAt).toLocaleString()}
</p>
{selectionBreadcrumb}
</div>
</div>
)}
@ -408,9 +400,6 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
</>
) : (
<>
<p className="text-lg font-medium mb-2">
<T>Empty Layout</T>
</p>
<p className="text-sm">
<T>Switch to edit mode to add containers</T>
</p>

View File

@ -583,8 +583,6 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
const handleSettingsCancel = () => {
if (isNew) {
// If it's a new widget and the user cancels settings, remove it
console.log('Removing cancelled new widget:', widget.id);
onRemove?.(widget.id);
}
};
@ -700,7 +698,7 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
onEditWidget={onEditWidget}
contextVariables={contextVariables}
selectedContainerId={selectedContainerId}
onSelectContainer={onSelect} />
onSelectContainer={onSelectContainer} />
</div>
{/* Generic Settings Modal */}

View File

@ -5,7 +5,6 @@ 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";
const PageActions = React.lazy(() => import("../PageActions").then(module => ({ default: module.PageActions })));
@ -18,7 +17,7 @@ import { UpdatePageMetaCommand } from "@/modules/layout/commands";
import { Page, UserProfile } from "../types";
import { Database } from '@/integrations/supabase/types';
import { updatePage, updatePageMeta } from '../client-pages';
import { updatePageMeta } from '../client-pages';
type Layout = Database['public']['Tables']['layouts']['Row'];
@ -53,16 +52,11 @@ export const UserPageDetails: React.FC<UserPageDetailsProps> = ({
onLoadTemplate,
showActions = true,
}) => {
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 { executeCommand } = useLayout();
@ -113,7 +107,6 @@ export const UserPageDetails: React.FC<UserPageDetailsProps> = ({
.filter(tag => tag.length > 0);
await updatePageMeta(page.id, { tags: newTags.length > 0 ? newTags : null });
onPageUpdate({ ...page, tags: newTags.length > 0 ? newTags : null });
setEditingTags(false);
toast.success(translate('Tags updated'));
@ -245,7 +238,7 @@ export const UserPageDetails: React.FC<UserPageDetailsProps> = ({
<Separator className="my-2" />
{/* Tags and Type */}
<div className="space-y-3 mb-8">
<div className="space-y-2 mt-8">
<div className="flex items-center gap-2 flex-wrap w-full">
{!page.visible && isOwner && (
<Badge variant="destructive" className="flex items-center gap-1">

View File

@ -1,5 +1,5 @@
import { useState, useEffect, lazy, Suspense, useRef, useCallback } from "react";
import React, { useState, useEffect, lazy, Suspense, useRef, useCallback, useMemo } from "react";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import { T, translate } from "@/i18n";
@ -24,6 +24,7 @@ import { deletePage, updatePage } from "../client-pages";
import { updateLayout } from "@/modules/layout/client-layouts";
import { PagePickerDialog } from "../PagePickerDialog";
import { CategoryManager } from "@/components/widgets/CategoryManager";
import { widgetRegistry } from "@/lib/widgetRegistry";
// Shared types
import { Page, UserProfile } from "../types";
@ -172,6 +173,40 @@ const UserPageEditInner = ({
},
});
// --- Selection breadcrumb ---
const selectionBreadcrumb = useMemo(() => {
if (isPreview || (!selectedContainerId && !selectedWidgetId)) return null;
const effectivePageId = selectedPageId || pageId;
const layout = getLoadedPageLayout(effectivePageId);
if (!layout) return null;
const crumbs: string[] = [];
const findPath = (containers: typeof layout.containers, targetCId: string | null, targetWId: string | null): boolean => {
for (const c of containers) {
const label = c.settings?.title || `Container (${c.columns} col${c.columns !== 1 ? 's' : ''})`;
if (targetCId && c.id === targetCId) { crumbs.push(label); return true; }
if (targetWId) {
const w = c.widgets.find(w => w.id === targetWId);
if (w) { crumbs.push(label); crumbs.push(widgetRegistry.get(w.widgetId)?.metadata?.name || w.widgetId); return true; }
}
if (c.children) { crumbs.push(label); if (findPath(c.children, targetCId, targetWId)) return true; crumbs.pop(); }
}
return false;
};
findPath(layout.containers, selectedContainerId, selectedWidgetId);
if (crumbs.length === 0) return null;
return (
<p className="text-xs text-blue-500 dark:text-blue-400 mt-1 flex items-center gap-1 flex-wrap">
<span className="opacity-60"></span>
{crumbs.map((crumb, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="opacity-40"></span>}
<span className={i === crumbs.length - 1 ? "font-semibold" : "opacity-75"}>{crumb}</span>
</React.Fragment>
))}
</p>
);
}, [isPreview, selectedContainerId, selectedWidgetId, selectedPageId, pageId, getLoadedPageLayout]);
// --- Auto-collapse sidebar if no TOC headings ---
useEffect(() => {
if (headings.length === 0) {
@ -244,10 +279,29 @@ const UserPageEditInner = ({
const effectivePageId = selectedPageId || pageId;
let targetContainerId = selectedContainerId;
// If no container selected but a widget is, find the widget's parent container
if (!targetContainerId && selectedWidgetId) {
const layout = getLoadedPageLayout(effectivePageId);
if (layout) {
const findWidgetParent = (containers: typeof layout.containers): string | null => {
for (const c of containers) {
if (c.widgets.some(w => w.id === selectedWidgetId)) return c.id;
if (c.children) {
const found = findWidgetParent(c.children);
if (found) return found;
}
}
return null;
};
targetContainerId = findWidgetParent(layout.containers);
}
}
// Fallback: no selection at all — use first container or create one
if (!targetContainerId) {
const layout = getLoadedPageLayout(effectivePageId);
if (layout && layout.containers.length > 0) {
targetContainerId = layout.containers[0].id;
targetContainerId = layout.containers[layout.containers.length - 1].id;
} else {
try {
const newContainer = await addPageContainer(effectivePageId);
@ -582,6 +636,7 @@ const UserPageEditInner = ({
newlyAddedWidgetId={isPreview ? null : newlyAddedWidgetId}
contextVariables={contextVariables}
onSave={handleSave}
selectionBreadcrumb={selectionBreadcrumb}
/>
)}

View File

@ -95,8 +95,26 @@ export function useClipboardActions({
toast.success(translate(`Pasted ${clipboard.containers.length} container(s)`));
} else if (clipboard.widgets.length > 0) {
let targetContainerId = selectedContainerId;
// If no container selected but a widget is, find the widget's parent container
if (!targetContainerId && selectedWidgetIds.size > 0) {
const firstSelectedId = Array.from(selectedWidgetIds)[0];
const findWidgetParent = (containers: LayoutContainerType[]): string | null => {
for (const c of containers) {
if (c.widgets.some(w => w.id === firstSelectedId)) return c.id;
if (c.children) {
const found = findWidgetParent(c.children);
if (found) return found;
}
}
return null;
};
targetContainerId = findWidgetParent(layout.containers);
}
// Fallback: no selection — use first container
if (!targetContainerId && layout.containers.length > 0) {
targetContainerId = layout.containers[0].id;
targetContainerId = layout.containers[layout.containers.length - 1].id;
}
if (!targetContainerId) {
toast.error(translate('No container to paste into'));

View File

@ -383,7 +383,6 @@ export const PageRibbonBar = ({
useEffect(() => {
if (activeTab === 'advanced') {
console.log('PageRibbonBar Debug:', { selectedContainerId, selectedWidgetId, selectedWidgetCount: selectedWidgetIds?.size ?? 0 });
}
}, [activeTab, selectedContainerId, selectedWidgetId]);

View File

@ -0,0 +1,132 @@
/**
* client-acl.ts Client-side ACL API wrappers
*
* Resource-agnostic: works with any resourceType (vfs, layout, page, etc.)
* Follows the pattern in client-user.ts (fetchWithDeduplication, getAuthToken).
*/
import { supabase as defaultSupabase } from "@/integrations/supabase/client";
import { fetchWithDeduplication, parseQueryKey } from "@/lib/db";
import { queryClient } from "@/lib/queryClient";
// =============================================
// Types (mirrors server-side AclEntry / AclSettings)
// =============================================
export interface AclEntry {
userId?: string;
group?: string;
path?: string;
permissions: string[];
}
export interface AclSettings {
owner: string;
groups?: { name: string; members: string[] }[];
acl: AclEntry[];
}
// =============================================
// Helpers
// =============================================
const getAuthToken = async (): Promise<string> => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
if (!token) throw new Error('Not authenticated');
return token;
};
const serverUrl = (): string =>
(import.meta as any).env?.VITE_SERVER_IMAGE_API_URL || '';
// =============================================
// Read
// =============================================
/** Fetch ACL settings for a resource. Cached via React Query / fetchWithDeduplication. */
export const fetchAclSettings = async (
resourceType: string,
resourceId: string,
): Promise<AclSettings> => {
return fetchWithDeduplication(
`acl-${resourceType}-${resourceId}`,
async () => {
const token = await getAuthToken();
const res = await fetch(
`${serverUrl()}/api/acl/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!res.ok) {
if (res.status === 404) return { owner: '', groups: [], acl: [] };
throw new Error(`Failed to fetch ACL: ${res.statusText}`);
}
return await res.json();
},
30000, // 30s stale time
);
};
/** Convenience: fetch just the entries array. */
export const fetchAclEntries = async (
resourceType: string,
resourceId: string,
): Promise<AclEntry[]> => {
const settings = await fetchAclSettings(resourceType, resourceId);
return settings.acl;
};
// =============================================
// Write
// =============================================
/** Grant permissions on a resource. Invalidates local cache. */
export const grantAclPermission = async (
resourceType: string,
resourceId: string,
grant: { userId?: string; group?: string; path?: string; permissions: string[] },
): Promise<{ success: boolean; settings: AclSettings }> => {
const token = await getAuthToken();
const res = await fetch(
`${serverUrl()}/api/acl/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/grant`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(grant),
},
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as any).error || `Failed to grant: ${res.statusText}`);
}
queryClient.invalidateQueries({ queryKey: parseQueryKey(`acl-${resourceType}-${resourceId}`) });
return await res.json();
};
/** Revoke permissions on a resource. Invalidates local cache. */
export const revokeAclPermission = async (
resourceType: string,
resourceId: string,
target: { userId?: string; group?: string; path?: string },
): Promise<{ success: boolean; settings: AclSettings }> => {
const token = await getAuthToken();
const res = await fetch(
`${serverUrl()}/api/acl/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/revoke`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(target),
},
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as any).error || `Failed to revoke: ${res.statusText}`);
}
queryClient.invalidateQueries({ queryKey: parseQueryKey(`acl-${resourceType}-${resourceId}`) });
return await res.json();
};

View File

@ -41,14 +41,10 @@ const Index = () => {
localStorage.setItem('feedViewMode', viewMode);
}, [viewMode]);
// Mobile sheet state (auto-open when navigating to /categories on mobile)
// Mobile sheet state — only opened by explicit user tap, never by route effects
const [sheetOpen, setSheetOpen] = useState(false);
const closeSheet = useCallback(() => setSheetOpen(false), []);
useEffect(() => {
if (isCategoriesRoute && isMobile) setSheetOpen(true);
}, [isCategoriesRoute, isMobile]);
// Persist sidebar size
const [sidebarSize, setSidebarSize] = useState(() => {
const stored = localStorage.getItem(SIDEBAR_KEY);
@ -69,12 +65,13 @@ const Index = () => {
const handleCategoriesToggle = useCallback(() => {
if (isCategoriesRoute) {
// Un-toggle: go back to /latest or /top based on nothing
navigate('/latest');
} else {
navigate('/categories');
// On mobile, open the category sheet directly from the user's tap
if (isMobile) setSheetOpen(true);
}
}, [isCategoriesRoute, navigate]);
}, [isCategoriesRoute, isMobile, navigate]);
const renderCategoryBreadcrumb = () => {
if (!slug) return null;
@ -146,7 +143,7 @@ const Index = () => {
<SheetDescription className="sr-only">Browse categories</SheetDescription>
</SheetHeader>
<div className="overflow-y-auto flex-1">
<CategoryTreeView onNavigate={closeSheet} />
<CategoryTreeView onNavigate={closeSheet} filterType="pages" />
</div>
</SheetContent>
</Sheet>
@ -202,7 +199,7 @@ const Index = () => {
<div className="sticky top-0 bg-background/95 backdrop-blur-sm pb-1 pt-1 px-1 border-b mb-1">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Categories</span>
</div>
<CategoryTreeView />
<CategoryTreeView filterType="pages" />
</div>
</ResizablePanel>
<ResizableHandle withHandle />

View File

@ -104,95 +104,95 @@ export const CompactPostHeader: React.FC<CompactPostHeaderProps> = ({
)
)}
<div className="p-3 border-b">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<UserAvatarBlock
userId={mediaItem.user_id}
avatarUrl={authorProfile?.avatar_url}
displayName={authorProfile?.display_name}
createdAt={mediaItem.created_at}
className="w-8 h-8"
/>
</div>
<div className="flex items-center gap-1">
<div className="flex items-center bg-muted/50 rounded-lg p-1 mr-2 border">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground"
onClick={() => onViewModeChange('thumbs')}
title="Thumbs View"
>
<Grid className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => onViewModeChange('compact')}
title="Compact View"
>
<LayoutGrid className="h-4 w-4" />
</Button>
</div>
{/* Open Standalone - break out of embedded view */}
{embedded && post?.id && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-primary"
onClick={() => window.open(`/post/${post.id}`, '_blank')}
title="Open in full page"
>
<ExternalLink className="h-4 w-4" />
</Button>
)}
<ExportDropdown
post={isEditMode ? localPost || null : post || null}
mediaItems={isEditMode ? (localMediaItems as any) || [] : mediaItems}
authorProfile={authorProfile}
onExportMarkdown={() => onExportMarkdown('raw')}
className="mr-1"
/>
{isOwner && (
<>
{isEditMode ? (
<>
<Button variant="ghost" size="sm" onClick={onSaveChanges} className="h-8 w-8 p-0 text-green-600 hover:text-green-700" title="Save changes">
<Save className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={onEditModeToggle} className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive" title="Cancel edit">
<X className="h-4 w-4" />
</Button>
</>
) : (
<Button variant="ghost" size="sm" onClick={onEditModeToggle} className="h-8 w-8 p-0 text-muted-foreground hover:text-primary" title="Inline Edit">
<Edit3 className="h-4 w-4" />
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEditPost}><Edit3 className="mr-2 h-4 w-4" /><span>Edit Post Wizard</span></DropdownMenuItem>
{onCategoryManagerOpen && <DropdownMenuItem onClick={onCategoryManagerOpen}><FolderTree className="mr-2 h-4 w-4" /><span>Manage Categories</span></DropdownMenuItem>}
<DropdownMenuItem onClick={onDeletePicture} className="text-destructive"><Trash2 className="mr-2 h-4 w-4" /><span>Delete this picture</span></DropdownMenuItem>
<DropdownMenuItem onClick={onDeletePost} className="text-destructive"><Trash2 className="mr-2 h-4 w-4" /><span>Delete whole post</span></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
<div className="p-3 border-b space-y-2">
{/* Author / Date Row */}
<div className="flex items-center">
<UserAvatarBlock
userId={mediaItem.user_id}
avatarUrl={authorProfile?.avatar_url}
displayName={authorProfile?.display_name}
createdAt={mediaItem.created_at}
className="w-8 h-8"
/>
</div>
</div >
{/* Actions Row */}
<div className="flex items-center gap-1">
<div className="flex items-center bg-muted/50 rounded-lg p-1 mr-2 border">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground"
onClick={() => onViewModeChange('thumbs')}
title="Thumbs View"
>
<Grid className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => onViewModeChange('compact')}
title="Compact View"
>
<LayoutGrid className="h-4 w-4" />
</Button>
</div>
{/* Open Standalone - break out of embedded view */}
{embedded && post?.id && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-primary"
onClick={() => window.open(`/post/${post.id}`, '_blank')}
title="Open in full page"
>
<ExternalLink className="h-4 w-4" />
</Button>
)}
<ExportDropdown
post={isEditMode ? localPost || null : post || null}
mediaItems={isEditMode ? (localMediaItems as any) || [] : mediaItems}
authorProfile={authorProfile}
onExportMarkdown={() => onExportMarkdown('raw')}
className="mr-1"
/>
{isOwner && (
<>
{isEditMode ? (
<>
<Button variant="ghost" size="sm" onClick={onSaveChanges} className="h-8 w-8 p-0 text-green-600 hover:text-green-700" title="Save changes">
<Save className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={onEditModeToggle} className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive" title="Cancel edit">
<X className="h-4 w-4" />
</Button>
</>
) : (
<Button variant="ghost" size="sm" onClick={onEditModeToggle} className="h-8 w-8 p-0 text-muted-foreground hover:text-primary" title="Inline Edit">
<Edit3 className="h-4 w-4" />
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEditPost}><Edit3 className="mr-2 h-4 w-4" /><span>Edit Post Wizard</span></DropdownMenuItem>
{onCategoryManagerOpen && <DropdownMenuItem onClick={onCategoryManagerOpen}><FolderTree className="mr-2 h-4 w-4" /><span>Manage Categories</span></DropdownMenuItem>}
<DropdownMenuItem onClick={onDeletePicture} className="text-destructive"><Trash2 className="mr-2 h-4 w-4" /><span>Delete this picture</span></DropdownMenuItem>
<DropdownMenuItem onClick={onDeletePost} className="text-destructive"><Trash2 className="mr-2 h-4 w-4" /><span>Delete whole post</span></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
</div>
</>
);
};