Skip to content

Commit 711494f

Browse files
authored
Feat/add admin page for collection (#427)
* created the admin page * created and implemted the hooks for collection * created and implemted the form to add, delete, update and assign for collections * created and added the interfaces for collections * created and implemeted the collection git service * created and implemeted the collection repository * created and implemeted the admin API for collection * deleted the fake collection data, apdate collection page to display the created data from the DB * replace the fake displaying data with the created data from the DB * added link to admin collections into menu-items profil * feat: add batch operations for collection assignment * feat(collections): implement batch item assignment with cache support * fix(security): prevent format string injection in console.warn * docs: add OpenAPI documentation for collections admin endpoints * Align collections paging cache with sibling route * Updated DELETE handler to align with GET: when collectionRepository.delete(id) reports not found, it now returns 404 instead of 500 in * Handled save errors visibly and keep the modal open. handleSave now toasts the error instead of silently closing, while still toggling saving in finally, and added toast import in assign-items-modal.tsx:5-42. * Reworked paging scroll to target the modal list instead of the page: added a ref to the scroll container and use it in onPageChange to scroll to top. See assign-items-modal.tsx:5-34. * Trim form data and normalize id/slug in collection form * Restore i18n pluralization for item count badge * Implemented tag normalization so collection items resolve string tag IDs to full tag objects before rendering, ensuring tags now display for assigned items. See * Reset sync state and timers after successful sync * fix: replace printf-style warning with safe logging * Added missing 404 response to the collection PUT endpoint so non-existent IDs are documented. See openapi.json:29952-29958. * Updated the DELETE collection spec to document soft vs permanent behavior, add the optional permanent query flag, clarify the success description, and include a 404 response * Replaced the console error with structured logging and added the logger import. * Enforce Zod schema validation on collection updates * fix: correct gradient class and button loading state * fix: validate slugs to prevent path traversal * fix the sudgestions from AI * Added a guard to the retry callback to avoid concurrent syncs and clear the timeout handle before retrying.(AI comment) * fix: add rollback for failed assign-items updates * Added the missing permanent query parameter to the delete collection endpoint in the OpenAPI spec, matching the documented behavior. * Restored i18n for the item count badge by using the translation helper with a count fallback to items?.length or item_count. * Adjusted rollback logic and comment: we now guard the collection rollback in a try/catch and log if it fails, while preserving the original error. * Handled deleted collections in pending-merge: now next is authoritative and we only merge pending entries for IDs that still exist, preventing deleted collections from reappearing. * Valid issue: optional isActive caused inconsistent handling. Reverted Collection.isActive to required boolean while services already default missing values. * Fix delete semantics in OpenAPI spec * fix the button style for best code practice * Fixed the merge logic so next (newer edits) stays authoritative and pending entries are only carried forward for ids not in next, avoiding newer edits being overwritten by older pending data. * Fix PUT collection spec parameters
1 parent 16a36cb commit 711494f

File tree

25 files changed

+3187
-304
lines changed

25 files changed

+3187
-304
lines changed
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
"use client";
2+
3+
import { useMemo, useState } from "react";
4+
import { Button, Card, CardBody, Chip, useDisclosure } from "@heroui/react";
5+
import { FolderPlus, Edit, Trash2, Layers, Link2, ListChecks } from "lucide-react";
6+
import { Collection } from "@/types/collection";
7+
import { useAdminCollections } from "@/hooks/use-admin-collections";
8+
import { UniversalPagination } from "@/components/universal-pagination";
9+
import { CollectionForm } from "@/components/admin/collections/collection-form";
10+
import { AssignItemsModal } from "@/components/admin/collections/assign-items-modal";
11+
12+
export default function AdminCollectionsPage() {
13+
const PageSize = 10;
14+
const [currentPage, setCurrentPage] = useState(1);
15+
const [selectedCollection, setSelectedCollection] = useState<Collection | null>(null);
16+
const [formMode, setFormMode] = useState<"create" | "edit">("create");
17+
const formDisclosure = useDisclosure();
18+
const assignDisclosure = useDisclosure();
19+
const [assignInitialIds, setAssignInitialIds] = useState<string[]>([]);
20+
21+
const {
22+
collections,
23+
total,
24+
totalPages,
25+
isLoading,
26+
isSubmitting,
27+
createCollection,
28+
updateCollection,
29+
deleteCollection,
30+
assignItems,
31+
fetchAssignedItems,
32+
} = useAdminCollections({ page: currentPage, limit: PageSize, sortBy: "name", includeInactive: true });
33+
34+
const activeCollections = useMemo(() => collections.filter((c) => c.isActive !== false).length, [collections]);
35+
const totalItemsInCollections = useMemo(
36+
() => collections.reduce((sum, col) => sum + (col.item_count || 0), 0),
37+
[collections]
38+
);
39+
40+
const openCreateForm = () => {
41+
setSelectedCollection(null);
42+
setFormMode("create");
43+
formDisclosure.onOpen();
44+
};
45+
46+
const openEditForm = (collection: Collection) => {
47+
setSelectedCollection(collection);
48+
setFormMode("edit");
49+
formDisclosure.onOpen();
50+
};
51+
52+
const handleFormSubmit = async (data: any) => {
53+
if (formMode === "create") {
54+
const success = await createCollection(data);
55+
if (success) {
56+
formDisclosure.onClose();
57+
setSelectedCollection(null);
58+
}
59+
} else if (selectedCollection) {
60+
const success = await updateCollection(selectedCollection.id, data);
61+
if (success) {
62+
formDisclosure.onClose();
63+
setSelectedCollection(null);
64+
}
65+
}
66+
};
67+
68+
const handleDelete = async (collection: Collection) => {
69+
const confirmDelete = confirm(`Delete collection "${collection.name}"? This will remove assignments from items.`);
70+
if (!confirmDelete) return;
71+
await deleteCollection(collection.id);
72+
};
73+
74+
const handleAssign = async (collection: Collection) => {
75+
// Prefer items already stored on the collection (from collections.yml)
76+
let assignedSlugs = Array.isArray(collection.items) && collection.items.length ? collection.items : [];
77+
78+
// Fallback to fetch if not present
79+
if (assignedSlugs.length === 0) {
80+
const assigned = await fetchAssignedItems(collection.id);
81+
assignedSlugs = assigned.map((item) => item.slug);
82+
}
83+
84+
setSelectedCollection(collection);
85+
setAssignInitialIds(assignedSlugs);
86+
assignDisclosure.onOpen();
87+
};
88+
89+
const handleAssignSave = async (itemSlugs: string[]) => {
90+
if (!selectedCollection) return;
91+
await assignItems(selectedCollection.id, itemSlugs);
92+
};
93+
94+
95+
96+
return (
97+
<div className="p-6 max-w-7xl mx-auto">
98+
<div className="mb-8">
99+
<div className="bg-linear-to-r from-white via-gray-50 to-white dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 rounded-2xl border border-gray-100 dark:border-gray-800 shadow-lg p-6">
100+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
101+
<div className="flex items-center space-x-4">
102+
<div className="w-12 h-12 bg-linear-to-br from-theme-primary to-theme-accent rounded-xl flex items-center justify-center shadow-lg">
103+
<Layers className="w-6 h-6 text-white" />
104+
</div>
105+
<div>
106+
<h1 className="text-2xl sm:text-3xl font-bold bg-linear-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-300 bg-clip-text text-transparent">
107+
Manage Collections
108+
</h1>
109+
<p className="text-gray-600 dark:text-gray-400 mt-1 flex items-center space-x-2">
110+
<span>Curate and group directory items.</span>
111+
<span className="hidden sm:inline"></span>
112+
<span className="text-sm px-2 py-1 bg-theme-primary/10 text-theme-primary rounded-full font-medium">
113+
{total} total
114+
</span>
115+
</p>
116+
</div>
117+
</div>
118+
<Button
119+
color="primary"
120+
size="lg"
121+
onPress={openCreateForm}
122+
startContent={<FolderPlus size={18} />}
123+
className="bg-linear-to-r from-theme-primary to-theme-accent hover:from-theme-primary/90 hover:to-theme-accent/90 shadow-lg shadow-theme-primary/25 hover:shadow-xl hover:shadow-theme-primary/40 transition-all duration-300 text-white font-medium"
124+
>
125+
Add collection
126+
</Button>
127+
</div>
128+
</div>
129+
</div>
130+
131+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
132+
<Card className="border-0 bg-linear-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 shadow-lg">
133+
<CardBody className="p-6">
134+
<div className="flex items-center justify-between">
135+
<div>
136+
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-1">Collections</p>
137+
<p className="text-3xl font-bold text-blue-700 dark:text-blue-300">{total}</p>
138+
</div>
139+
<div className="w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center shadow-lg">
140+
<Layers className="w-6 h-6 text-white" />
141+
</div>
142+
</div>
143+
</CardBody>
144+
</Card>
145+
146+
<Card className="border-0 bg-linear-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 shadow-lg">
147+
<CardBody className="p-6">
148+
<div className="flex items-center justify-between">
149+
<div>
150+
<p className="text-sm font-medium text-green-600 dark:text-green-400 mb-1">Active</p>
151+
<p className="text-3xl font-bold text-green-700 dark:text-green-300">{activeCollections}</p>
152+
</div>
153+
<div className="w-12 h-12 bg-green-500 rounded-xl flex items-center justify-center shadow-lg">
154+
<ListChecks className="w-6 h-6 text-white" />
155+
</div>
156+
</div>
157+
</CardBody>
158+
</Card>
159+
160+
<Card className="border-0 bg-linear-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 shadow-lg">
161+
<CardBody className="p-6">
162+
<div className="flex items-center justify-between">
163+
<div>
164+
<p className="text-sm font-medium text-purple-600 dark:text-purple-400 mb-1">Items assigned</p>
165+
<p className="text-3xl font-bold text-purple-700 dark:text-purple-300">{totalItemsInCollections}</p>
166+
</div>
167+
<div className="w-12 h-12 bg-purple-500 rounded-xl flex items-center justify-center shadow-lg">
168+
<Link2 className="w-6 h-6 text-white" />
169+
</div>
170+
</div>
171+
</CardBody>
172+
</Card>
173+
</div>
174+
175+
<Card className="border-0 shadow-lg bg-white/80 dark:bg-gray-900/80 backdrop-blur-xs">
176+
<CardBody className="p-0">
177+
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-800/50 flex items-center justify-between">
178+
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Collections</h3>
179+
<span className="text-sm text-gray-600 dark:text-gray-400">{collections.length} of {total}</span>
180+
</div>
181+
182+
<div className="divide-y divide-gray-100 dark:divide-gray-800">
183+
{isLoading ? (
184+
<div className="p-6 text-center text-gray-500">Loading collections…</div>
185+
) : collections.length === 0 ? (
186+
<div className="p-6 text-center text-gray-500">No collections yet.</div>
187+
) : (
188+
collections.map((collection) => (
189+
<div key={collection.id} className="group px-6 py-4 hover:bg-linear-to-r hover:from-theme-primary/5 hover:to-theme-accent/5 dark:hover:from-theme-primary/10 dark:hover:to-theme-accent/10 transition-all">
190+
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
191+
<div className="flex items-start gap-4 flex-1 min-w-0">
192+
<div className="w-10 h-10 border rounded-lg flex items-center justify-center text-white font-semibold shadow-md">
193+
{collection.icon_url || collection.name.charAt(0).toUpperCase()}
194+
</div>
195+
<div className="flex-1 min-w-0 space-y-1">
196+
<div className="flex items-center gap-2">
197+
<h4 className="font-medium text-gray-900 dark:text-white truncate">{collection.name}</h4>
198+
<Chip size="sm" variant="flat" color={collection.isActive ? "success" : "danger"}>
199+
{collection.isActive ? "Active" : "Inactive"}
200+
</Chip>
201+
<Chip size="sm" variant="flat" color="primary">
202+
{collection.item_count || 0} items
203+
</Chip>
204+
</div>
205+
<p className="text-xs text-gray-500">Slug: {collection.slug}</p>
206+
{collection.description && (
207+
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">{collection.description}</p>
208+
)}
209+
</div>
210+
</div>
211+
212+
<div className="flex items-center gap-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity">
213+
<Button
214+
size="sm"
215+
variant="flat"
216+
onPress={() => handleAssign(collection)}
217+
className="h-9 px-3"
218+
>
219+
<Link2 className="w-4 h-4 mr-1" /> Assign items
220+
</Button>
221+
<Button
222+
size="sm"
223+
variant="flat"
224+
onPress={() => openEditForm(collection)}
225+
className="h-9 px-3"
226+
>
227+
<Edit className="w-4 h-4 mr-1" /> Edit
228+
</Button>
229+
<Button
230+
size="sm"
231+
color="danger"
232+
variant="flat"
233+
onPress={() => handleDelete(collection)}
234+
className="h-9 px-3"
235+
isDisabled={isSubmitting}
236+
>
237+
<Trash2 className="w-4 h-4 mr-1" /> Delete
238+
</Button>
239+
</div>
240+
</div>
241+
</div>
242+
))
243+
)}
244+
</div>
245+
246+
{totalPages > 1 && (
247+
<div className="p-4 border-t border-gray-100 dark:border-gray-800">
248+
<UniversalPagination
249+
page={currentPage}
250+
totalPages={totalPages}
251+
onPageChange={(page) => {
252+
setCurrentPage(page);
253+
window.scrollTo({ top: 0, behavior: "smooth" });
254+
}}
255+
/>
256+
</div>
257+
)}
258+
</CardBody>
259+
</Card>
260+
261+
{formDisclosure.isOpen && (
262+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
263+
<div
264+
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
265+
onClick={!isSubmitting ? formDisclosure.onClose : undefined}
266+
/>
267+
<div className="relative bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden">
268+
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
269+
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
270+
{formMode === 'create' ? 'Create Collection' : 'Edit Collection'}
271+
</h2>
272+
{!isSubmitting && (
273+
<button
274+
onClick={formDisclosure.onClose}
275+
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm transition-colors"
276+
>
277+
<svg className="w-5 h-5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
278+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
279+
</svg>
280+
</button>
281+
)}
282+
</div>
283+
<div className="overflow-y-auto max-h-[calc(90vh-4rem)]">
284+
<CollectionForm
285+
collection={selectedCollection || undefined}
286+
mode={formMode}
287+
isLoading={isSubmitting}
288+
onSubmit={handleFormSubmit}
289+
onCancel={formDisclosure.onClose}
290+
/>
291+
</div>
292+
</div>
293+
</div>
294+
)}
295+
296+
<AssignItemsModal
297+
isOpen={assignDisclosure.isOpen}
298+
onClose={assignDisclosure.onClose}
299+
collectionName={selectedCollection?.name || "this collection"}
300+
initialSelected={assignInitialIds}
301+
onSave={handleAssignSave}
302+
/>
303+
</div>
304+
);
305+
}

0 commit comments

Comments
 (0)