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, createCategory, updateCategory, deleteCategory, Category } from "@/modules/categories/client-categories"; import { toast } from "sonner"; import { Plus, Edit2, Trash2, FolderTree, Link as LinkIcon, Check, X, Loader2, Hash, Languages, Shield } from "lucide-react"; import { ACLEditorContent } from "@/modules/acl/ACLEditorContent"; import CollapsibleSection from "@/components/CollapsibleSection"; import { cn } from "@/lib/utils"; import { Checkbox } from "@/components/ui/checkbox"; import { T, translate } from "@/i18n"; import { VariablesEditor } from '@/components/variables/VariablesEditor'; import { updatePageMeta } from "@/modules/pages/client-pages"; import { fetchTypes } from "@/modules/types/client-types"; import { CategoryTranslationDialog } from "@/modules/i18n/CategoryTranslationDialog"; import { useAppConfig } from '@/hooks/useSystemInfo'; import { useAuth } from "@/hooks/useAuth"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; interface CategoryManagerProps { isOpen: boolean; onClose: () => void; currentPageId?: string; // If provided, allows linking page to category currentPageMeta?: any; onPageMetaUpdate?: (newMeta: any, newCategories?: Category[]) => 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 /** Picker mode: simplified view for selecting categories */ mode?: 'manage' | 'pick'; /** Called in pick mode when user confirms selection */ onPick?: (categoryIds: string[], categories?: Category[]) => void; /** Pre-selected category IDs for pick mode */ selectedCategoryIds?: string[]; /** If true, renders as a standalone view rather than a Dialog */ asView?: boolean; } export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMeta, onPageMetaUpdate, filterByType, defaultMetaType, mode = 'manage', onPick, selectedCategoryIds: externalSelectedIds, asView }: CategoryManagerProps) => { const [actionLoading, setActionLoading] = useState(false); const { user } = useAuth(); const systemUserId = import.meta.env.VITE_SYSTEM_USER_ID; const [ownershipTab, setOwnershipTab] = useState<'system'|'own'>('system'); // Entity Types selector state const envTypes = import.meta.env.VITE_ENTITY_TYPES; const ENTITY_TYPES = envTypes ? ['all', ...envTypes.split(',').map(s => s.trim())] : ['all', 'pages', 'posts', 'pictures', 'types', 'products']; const [activeFilter, setActiveFilter] = useState(filterByType || 'all'); // Selection state const [selectedCategoryId, setSelectedCategoryId] = useState(null); // Picker mode state const [pickerSelectedIds, setPickerSelectedIds] = useState(externalSelectedIds || []); // Editing/Creating state const [editingCategory, setEditingCategory] = useState | null>(null); const [isCreating, setIsCreating] = useState(false); const [creationParentId, setCreationParentId] = useState(null); const [showVariablesEditor, setShowVariablesEditor] = useState(false); const [showTranslationDialog, setShowTranslationDialog] = useState(false); const appConfig = useAppConfig(); const srcLang = appConfig?.i18n?.source_language; // 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, sourceLang: srcLang }); // Only show root-level categories (those without a parent) return data.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(translate("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 or derived from activeFilter const targetType = defaultMetaType || (activeFilter !== 'all' ? activeFilter : undefined); if (targetType) { categoryData.meta = { ...(categoryData.meta || {}), type: targetType }; } await createCategory(categoryData); toast.success(translate("Category created")); } else if (editingCategory.id) { await updateCategory(editingCategory.id, editingCategory); toast.success(translate("Category updated")); } setEditingCategory(null); queryClient.invalidateQueries({ queryKey: ['categories'] }); } catch (error) { console.error(error); toast.error(isCreating ? translate("Failed to create category") : translate("Failed to update category")); } finally { setActionLoading(false); } }; const handleDelete = async (id: string, e: React.MouseEvent) => { e.stopPropagation(); if (!confirm(translate("Are you sure you want to delete this category?"))) return; setActionLoading(true); try { await deleteCategory(id); toast.success(translate("Category deleted")); queryClient.invalidateQueries({ queryKey: ['categories'] }); if (selectedCategoryId === id) setSelectedCategoryId(null); } catch (error) { console.error(error); toast.error(translate("Failed to delete category")); } finally { setActionLoading(false); } }; // Helper to find category in tree const findCategory = (id: string, cats: Category[]): Category | null => { for (const cat of cats) { if (cat.id === id) return cat; if (cat.children) { const found = cat.children.find(rel => rel.child.id === id)?.child; if (found) return found; // Direct child match // Deep search in children's children not strictly needed if flattened? // Using recursive search on children const deep = cat.children.map(c => findCategory(id, [c.child])).find(c => c); if (deep) return deep; } } return null; }; // Flatten categories for easy lookup const getAllCategoriesFlattened = (cats: Category[]): Category[] => { let flat: Category[] = []; cats.forEach(c => { flat.push(c); if (c.children) { c.children.forEach(childRel => { flat = [...flat, ...getAllCategoriesFlattened([childRel.child])]; }); } }); return flat; }; 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 }; // Resolve category objects const allCats = getAllCategoriesFlattened(categories); const resolvedCategories = newIds.map(id => findCategory(id, categories) || allCats.find(c => c.id === id)).filter(Boolean) as Category[]; // Use callback if provided, otherwise fall back to updatePageMeta if (onPageMetaUpdate) { await onPageMetaUpdate(newMeta, resolvedCategories); } else { await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null }); } toast.success(translate("Added to category")); } catch (error) { console.error(error); toast.error(translate("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 }; // Resolve category objects const allCats = getAllCategoriesFlattened(categories); const resolvedCategories = newIds.map(id => findCategory(id, categories) || allCats.find(c => c.id === id)).filter(Boolean) as Category[]; // Use callback if provided, otherwise fall back to updatePageMeta if (onPageMetaUpdate) { await onPageMetaUpdate(newMeta, resolvedCategories); } else { await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null }); } toast.success(translate("Removed from category")); } catch (error) { console.error(error); toast.error(translate("Failed to unlink")); } finally { setActionLoading(false); } }; // Picker mode: toggle category in selection const togglePickerCategory = (catId: string) => { setPickerSelectedIds(prev => prev.includes(catId) ? prev.filter(id => id !== catId) : [...prev, catId] ); }; // Render Logic const renderPickerItem = (cat: Category, level: number = 0): React.ReactNode => { const isPicked = pickerSelectedIds.includes(cat.id); return (
togglePickerCategory(cat.id)} > togglePickerCategory(cat.id)} /> {cat.name}
{cat.children ?.filter(childRel => activeFilter === 'all' || (childRel.child as any).meta?.type === activeFilter) .map(childRel => renderPickerItem(childRel.child, level + 1)) }
); }; const renderCategoryItem = (cat: Category, level: number = 0) => { const isSelected = selectedCategoryId === cat.id; const isLinked = linkedCategoryIds.includes(cat.id); return (
{ setSelectedCategoryId(cat.id); }} >
{isLinked ? ( ) : ( )} {cat.name} ({cat.slug})
{cat.children ?.filter(childRel => activeFilter === 'all' || (childRel.child as any).meta?.type === activeFilter) .map(childRel => renderCategoryItem(childRel.child, level + 1)) }
); }; // Picker mode: simplified dialog if (mode === 'pick') { return ( Select Categories Choose one or more categories for your page.
setOwnershipTab(val)} className="w-full"> System Own
{loading ? (
) : (
{categories .filter(cat => ownershipTab === 'system' ? cat.owner_id === systemUserId : cat.owner_id === user?.id) .map(cat => renderPickerItem(cat))} {categories.filter(cat => ownershipTab === 'system' ? cat.owner_id === systemUserId : cat.owner_id === user?.id).length === 0 &&
No categories found.
}
)}
); } const innerContent = ( <>
{asView ? (

Category Manager

Manage categories and organize your content structure.

) : ( Category Manager Manage categories and organize your content structure. )}
{/* Left: Category Tree */}
setOwnershipTab(val)} className="-ml-2"> System Own
{(!filterByType) && ( )}
{loading ? (
) : (
{categories .filter(cat => activeFilter === 'all' || (cat as any).meta?.type === activeFilter) .filter(cat => ownershipTab === 'system' ? cat.owner_id === systemUserId : cat.owner_id === user?.id) .map(cat => renderCategoryItem(cat))} {categories .filter(cat => activeFilter === 'all' || (cat as any).meta?.type === activeFilter) .filter(cat => ownershipTab === 'system' ? cat.owner_id === systemUserId : cat.owner_id === user?.id) .length === 0 &&
No categories found.
}
)}
{/* Right: Editor or Actions */}
{editingCategory ? (

{isCreating ? translate("New Category") : translate("Edit Category")}

{ const name = e.target.value; const slug = isCreating ? name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') : editingCategory.slug; setEditingCategory({ ...editingCategory, name, slug }) }} />
setEditingCategory({ ...editingCategory, slug: e.target.value })} />
setEditingCategory({ ...editingCategory, description: e.target.value })} />
{types.length === 0 &&
No assignable types found.
} {types.map(type => (
{ 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 } }); }} />
))}
Assign types to allow using them in this category.
{/* Translate button — only for existing categories */} {!isCreating && editingCategory.id && (
)}
) : selectedCategoryId ? (

{categories.find(c => c.id === selectedCategoryId)?.name || 'Selected'}

{selectedCategoryId}

{categories.find(c => c.id === selectedCategoryId)?.description && (

{categories.find(c => c.id === selectedCategoryId)?.description}

)}
{currentPageId && (
{linkedCategoryIds.includes(selectedCategoryId) ? (
Page linked to this category
) : ( )}
)}
Select a category to see actions or click edit/add icons in the tree.
{/* Category ACL / Permissions */} Permissions} initiallyOpen={false} storageKey="cat-acl-open" minimal > c.id === selectedCategoryId)?.name || ''} availablePermissions={['read', 'list', 'write', 'delete']} />
) : (
Select a category to manage or link.
)}
{!asView && ( )} {/* Nested Dialog for Variables */} Category Variables Define variables available to pages in this category.
{editingCategory && ( { return (editingCategory.meta?.variables as Record) || {}; }} onSave={async (newVars) => { setEditingCategory({ ...editingCategory, meta: { ...(editingCategory.meta || {}), variables: newVars } }); setShowVariablesEditor(false); }} /> )}
{/* Category Translation Dialog */} {showTranslationDialog && editingCategory?.id && ( )} ); if (asView) { return
{innerContent}
; } return ( {innerContent} ); };