421 lines
22 KiB
TypeScript
421 lines
22 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { fetchCategories, fetchTypes, createCategory, updateCategory, deleteCategory, updatePageMeta, Category } from "@/lib/db";
|
|
import { toast } from "sonner";
|
|
import { Plus, Edit2, Trash2, FolderTree, Link as LinkIcon, Check, X, Loader2 } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { T } from "@/i18n";
|
|
|
|
interface CategoryManagerProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
currentPageId?: string; // If provided, allows linking page to category
|
|
currentPageMeta?: any;
|
|
onPageMetaUpdate?: (newMeta: any) => void;
|
|
filterByType?: string; // Filter categories by meta.type (e.g., 'layout', 'page', 'email')
|
|
defaultMetaType?: string; // Default type to set in meta when creating new categories
|
|
}
|
|
|
|
export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMeta, onPageMetaUpdate, filterByType, defaultMetaType }: CategoryManagerProps) => {
|
|
// const [categories, setCategories] = useState<Category[]>([]); // Replaced by useQuery
|
|
// const [loading, setLoading] = useState(false); // Replaced by useQuery
|
|
const [actionLoading, setActionLoading] = useState(false);
|
|
|
|
// Selection state
|
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
|
|
|
// Editing/Creating state
|
|
const [editingCategory, setEditingCategory] = useState<Partial<Category> | null>(null);
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
const [creationParentId, setCreationParentId] = useState<string | null>(null);
|
|
|
|
|
|
|
|
// Initial linked category from page meta
|
|
const getLinkedCategoryIds = (): string[] => {
|
|
if (!currentPageMeta) return [];
|
|
if (Array.isArray(currentPageMeta.categoryIds)) return currentPageMeta.categoryIds;
|
|
if (currentPageMeta.categoryId) return [currentPageMeta.categoryId];
|
|
return [];
|
|
};
|
|
|
|
const linkedCategoryIds = getLinkedCategoryIds();
|
|
|
|
// React Query Integration
|
|
const { data: categories = [], isLoading: loading } = useQuery({
|
|
queryKey: ['categories'],
|
|
queryFn: async () => {
|
|
const data = await fetchCategories({ includeChildren: true });
|
|
// Filter by type if specified
|
|
let filtered = filterByType
|
|
? data.filter(cat => (cat as any).meta?.type === filterByType)
|
|
: data;
|
|
|
|
// Only show root-level categories (those without a parent)
|
|
return filtered.filter(cat => !cat.parent_category_id);
|
|
}
|
|
});
|
|
|
|
const { data: types = [] } = useQuery({
|
|
queryKey: ['types', 'assignable'],
|
|
queryFn: async () => {
|
|
const allTypes = await fetchTypes();
|
|
// Filter for structures and aliases as requested
|
|
return allTypes.filter(t => t.kind === 'structure' || t.kind === 'alias');
|
|
}
|
|
});
|
|
|
|
const handleCreateStart = (parentId: string | null = null) => {
|
|
setIsCreating(true);
|
|
setCreationParentId(parentId);
|
|
setEditingCategory({
|
|
name: "",
|
|
slug: "",
|
|
description: "",
|
|
visibility: "public"
|
|
});
|
|
};
|
|
|
|
const handleEditStart = (category: Category) => {
|
|
setIsCreating(false);
|
|
setEditingCategory({ ...category });
|
|
};
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
const handleSave = async () => {
|
|
if (!editingCategory || !editingCategory.name || !editingCategory.slug) {
|
|
toast.error("Name and Slug are required");
|
|
return;
|
|
}
|
|
|
|
setActionLoading(true);
|
|
try {
|
|
if (isCreating) {
|
|
// Apply default meta type if provided
|
|
const categoryData: any = {
|
|
...editingCategory,
|
|
parentId: creationParentId || undefined,
|
|
relationType: 'generalization'
|
|
};
|
|
|
|
// Set meta.type if defaultMetaType is provided
|
|
if (defaultMetaType) {
|
|
categoryData.meta = {
|
|
...(categoryData.meta || {}),
|
|
type: defaultMetaType
|
|
};
|
|
}
|
|
|
|
await createCategory(categoryData);
|
|
toast.success("Category created");
|
|
} else if (editingCategory.id) {
|
|
await updateCategory(editingCategory.id, editingCategory);
|
|
toast.success("Category updated");
|
|
}
|
|
setEditingCategory(null);
|
|
queryClient.invalidateQueries({ queryKey: ['categories'] });
|
|
} catch (error) {
|
|
console.error(error);
|
|
toast.error(isCreating ? "Failed to create category" : "Failed to update category");
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (!confirm("Are you sure you want to delete this category?")) return;
|
|
|
|
setActionLoading(true);
|
|
try {
|
|
await deleteCategory(id);
|
|
toast.success("Category deleted");
|
|
queryClient.invalidateQueries({ queryKey: ['categories'] });
|
|
if (selectedCategoryId === id) setSelectedCategoryId(null);
|
|
} catch (error) {
|
|
console.error(error);
|
|
toast.error("Failed to delete category");
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleLinkPage = async () => {
|
|
if (!currentPageId || !selectedCategoryId) return;
|
|
|
|
const currentIds = getLinkedCategoryIds();
|
|
if (currentIds.includes(selectedCategoryId)) return;
|
|
|
|
setActionLoading(true);
|
|
try {
|
|
const newIds = [...currentIds, selectedCategoryId];
|
|
const newMeta = { ...currentPageMeta, categoryIds: newIds, categoryId: null };
|
|
|
|
// Use callback if provided, otherwise fall back to updatePageMeta
|
|
if (onPageMetaUpdate) {
|
|
await onPageMetaUpdate(newMeta);
|
|
} else {
|
|
await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null });
|
|
}
|
|
|
|
toast.success("Added to category");
|
|
} catch (error) {
|
|
console.error(error);
|
|
toast.error("Failed to link");
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleUnlinkPage = async () => {
|
|
if (!currentPageId || !selectedCategoryId) return;
|
|
|
|
const currentIds = getLinkedCategoryIds();
|
|
if (!currentIds.includes(selectedCategoryId)) return;
|
|
|
|
setActionLoading(true);
|
|
try {
|
|
const newIds = currentIds.filter(id => id !== selectedCategoryId);
|
|
const newMeta = { ...currentPageMeta, categoryIds: newIds, categoryId: null };
|
|
|
|
// Use callback if provided, otherwise fall back to updatePageMeta
|
|
if (onPageMetaUpdate) {
|
|
await onPageMetaUpdate(newMeta);
|
|
} else {
|
|
await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null });
|
|
}
|
|
|
|
toast.success("Removed from category");
|
|
} catch (error) {
|
|
console.error(error);
|
|
toast.error("Failed to unlink");
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
};
|
|
|
|
// Render Logic
|
|
const renderCategoryItem = (cat: Category, level: number = 0) => {
|
|
const isSelected = selectedCategoryId === cat.id;
|
|
const isLinked = linkedCategoryIds.includes(cat.id);
|
|
|
|
return (
|
|
<div key={cat.id}>
|
|
<div
|
|
className={cn(
|
|
"flex items-center justify-between p-2 rounded hover:bg-muted/50 cursor-pointer group",
|
|
isSelected && "bg-muted",
|
|
isLinked && !isSelected && "bg-primary/5" // Slight highlight for linked items not selected
|
|
)}
|
|
style={{ marginLeft: `${level * 16}px` }}
|
|
onClick={() => {
|
|
setSelectedCategoryId(cat.id);
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{isLinked ? (
|
|
<Check className="h-4 w-4 text-primary" />
|
|
) : (
|
|
<FolderTree className="h-3 w-3 text-muted-foreground opacity-50" />
|
|
)}
|
|
<span className={cn("text-sm", isLinked && "font-semibold text-primary")}>{cat.name}</span>
|
|
<span className="text-xs text-muted-foreground">({cat.slug})</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); handleCreateStart(cat.id); }}>
|
|
<Plus className="h-3 w-3" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); handleEditStart(cat); }}>
|
|
<Edit2 className="h-3 w-3" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive" onClick={(e) => handleDelete(cat.id, e)}>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{cat.children
|
|
?.filter(childRel => !filterByType || (childRel.child as any).meta?.type === filterByType)
|
|
.map(childRel => renderCategoryItem(childRel.child, level + 1))
|
|
}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
<DialogContent className="max-w-2xl h-[80vh] flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle><T>Category Manager</T></DialogTitle>
|
|
<DialogDescription>
|
|
Manage categories and organize your content structure.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex-1 flex flex-col md:flex-row gap-4 min-h-0">
|
|
{/* Left: Category Tree */}
|
|
<div className="flex-1 border rounded-md p-2 overflow-y-auto min-h-0 basis-[40%] md:basis-auto">
|
|
<div className="flex justify-between items-center mb-2 px-2">
|
|
<span className="text-sm font-semibold text-muted-foreground">Category Hierarchy</span>
|
|
<Button variant="ghost" size="sm" onClick={() => handleCreateStart(null)}>
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
<T>Root Category</T>
|
|
</Button>
|
|
</div>
|
|
{loading ? (
|
|
<div className="flex justify-center p-4"><Loader2 className="h-6 w-6 animate-spin" /></div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{categories.map(cat => renderCategoryItem(cat))}
|
|
{categories.length === 0 && <div className="text-center text-sm text-muted-foreground py-8">No categories found.</div>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: Editor or Actions */}
|
|
<div className="border rounded-md p-4 flex flex-col gap-4 overflow-y-auto bg-muted/10 w-full md:w-[300px] border-t md:border-l-0 md:border-l min-h-[50%] md:min-h-0">
|
|
{editingCategory ? (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="font-semibold text-sm">{isCreating ? "New Category" : "Edit Category"}</h3>
|
|
<Button variant="ghost" size="sm" onClick={() => setEditingCategory(null)}><X className="h-4 w-4" /></Button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Name</Label>
|
|
<Input
|
|
value={editingCategory.name}
|
|
onChange={(e) => {
|
|
const name = e.target.value;
|
|
const slug = isCreating ? name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') : editingCategory.slug;
|
|
setEditingCategory({ ...editingCategory, name, slug })
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Slug</Label>
|
|
<Input
|
|
value={editingCategory.slug}
|
|
onChange={(e) => setEditingCategory({ ...editingCategory, slug: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Visibility</Label>
|
|
<Select
|
|
value={editingCategory.visibility}
|
|
onValueChange={(val: any) => setEditingCategory({ ...editingCategory, visibility: val })}
|
|
>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="public">Public</SelectItem>
|
|
<SelectItem value="unlisted">Unlisted</SelectItem>
|
|
<SelectItem value="private">Private</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Input
|
|
value={editingCategory.description || ''}
|
|
onChange={(e) => setEditingCategory({ ...editingCategory, description: e.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Assigned Types</Label>
|
|
<div className="border rounded-md p-2 max-h-40 overflow-y-auto space-y-2">
|
|
{types.length === 0 && <div className="text-xs text-muted-foreground p-1">No assignable types found.</div>}
|
|
{types.map(type => (
|
|
<div key={type.id} className="flex items-center gap-2">
|
|
<Checkbox
|
|
id={`type-${type.id}`}
|
|
checked={(editingCategory.meta?.assignedTypes || []).includes(type.id)}
|
|
onCheckedChange={(checked) => {
|
|
const current = editingCategory.meta?.assignedTypes || [];
|
|
const newTypes = checked
|
|
? [...current, type.id]
|
|
: current.filter((id: string) => id !== type.id);
|
|
|
|
setEditingCategory({
|
|
...editingCategory,
|
|
meta: {
|
|
...(editingCategory.meta || {}),
|
|
assignedTypes: newTypes
|
|
}
|
|
});
|
|
}}
|
|
/>
|
|
<Label htmlFor={`type-${type.id}`} className="text-sm font-normal cursor-pointer">
|
|
{type.name} <span className="text-xs text-muted-foreground">({type.kind})</span>
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="text-[10px] text-muted-foreground">
|
|
Assign types to allow using them in this category.
|
|
</div>
|
|
</div>
|
|
<Button className="w-full" onClick={handleSave} disabled={actionLoading}>
|
|
{actionLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Save
|
|
</Button>
|
|
</div>
|
|
) : selectedCategoryId ? (
|
|
<div className="space-y-4">
|
|
<div className="border-b pb-2">
|
|
<h3 className="font-semibold text-lg">{categories.find(c => c.id === selectedCategoryId)?.name || 'Selected'}</h3>
|
|
<p className="text-xs text-muted-foreground mb-1">{selectedCategoryId}</p>
|
|
{categories.find(c => c.id === selectedCategoryId)?.description && (
|
|
<p className="text-sm text-foreground/80 italic">
|
|
{categories.find(c => c.id === selectedCategoryId)?.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{currentPageId && (
|
|
<div className="bg-background rounded p-3 border space-y-2">
|
|
<Label className="text-xs uppercase text-muted-foreground">Current Page Link</Label>
|
|
{linkedCategoryIds.includes(selectedCategoryId) ? (
|
|
<div className="text-sm">
|
|
<div className="flex items-center gap-2 text-green-600 mb-2">
|
|
<Check className="h-4 w-4" />
|
|
Page linked to this category
|
|
</div>
|
|
<Button size="sm" variant="outline" className="w-full" onClick={handleUnlinkPage} disabled={actionLoading}>
|
|
Remove from Category
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button size="sm" className="w-full" onClick={handleLinkPage} disabled={actionLoading}>
|
|
<LinkIcon className="mr-2 h-4 w-4" />
|
|
Add to Category
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="text-xs text-muted-foreground">
|
|
Select a category to see actions or click edit/add icons in the tree.
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm text-center">
|
|
Select a category to manage or link.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={onClose}>Close</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|