more ui shit
This commit is contained in:
parent
2cf5af613d
commit
08656eddcb
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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[] = [];
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 >
|
||||
);
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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[] => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -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'));
|
||||
|
||||
@ -383,7 +383,6 @@ export const PageRibbonBar = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'advanced') {
|
||||
console.log('PageRibbonBar Debug:', { selectedContainerId, selectedWidgetId, selectedWidgetCount: selectedWidgetIds?.size ?? 0 });
|
||||
}
|
||||
}, [activeTab, selectedContainerId, selectedWidgetId]);
|
||||
|
||||
|
||||
132
packages/ui/src/modules/user/client-acl.ts
Normal file
132
packages/ui/src/modules/user/client-acl.ts
Normal 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();
|
||||
};
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user