diff --git a/Client/app/routes/tags.tsx b/Client/app/routes/tags.tsx new file mode 100644 index 0000000..399e17d --- /dev/null +++ b/Client/app/routes/tags.tsx @@ -0,0 +1,243 @@ +import RootLayout from "~/layout/RootLayout"; +import type { Route } from "../+types/root"; +import type { PaginatedResponse } from "~/types/PaginatedResponse"; +import { TagType, type Tag } from "~/types/tag"; +import { useEffect, useState, type FormEventHandler } from "react"; +import { API } from "~/api/api"; +import Icon, { ICONS } from "~/components/Icon"; +import Loading from "~/components/Loading"; +import { useAuth } from "~/context/AuthContext"; +import Modal from "~/components/Modal"; + +export function meta({}: Route.MetaArgs) { + return [ + { title: "ImgBoard - Tag List" }, + ]; +} + +export default function Tags() { + + const { user } = useAuth(); + + const [paginatedData, setTags] = useState | null>(null); + const [isEditTagModalOpen, setIsEditTagModalOpen] = useState(false); + const [isCreateTagModalOpen, setIsCreateTagModalOpen] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + + const [tagToEdit, setTagToEdit] = useState(null); + + useEffect(() => { + async function fetchData() { + const tags = await API.fetchTags(currentPage); + setTags(tags); + } + fetchData(); + }, [currentPage]); + + if (!paginatedData) { + return + + ; + } + + const Pagination = ({ + itemsPerPage, + totalItems, + paginate, + currentPage, + }: { + itemsPerPage: number; + totalItems: number; + paginate: (pageNumber: number) => void; + currentPage: number; + }) => { + const pageNumbers = []; + for (let i = 1; i <= Math.ceil(totalItems / itemsPerPage); i++) { pageNumbers.push(i); } + if(pageNumbers.length <= 1) return null; + return ( + + ); + }; + + const CreateTagModal = ({ isOpen, onClose, onSave }: { isOpen: boolean; onClose: () => void; onSave: (name: string, type: TagType) => void }) => { + const [newTagName, setNewTagName] = useState(""); + const [newTagType, setNewTagType] = useState(TagType.General); + + const handleSubmit: FormEventHandler = (e) => { + e.preventDefault(); + if (newTagName) { + onSave(newTagName, newTagType); + setNewTagName(""); + setNewTagType(TagType.General); + } + }; + + return ( + +

Create Tag

+
+
+ + setNewTagName(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" required placeholder="e.g., game" /> +
+
+ + +
+
+ +
+
+
+ ); + }; + + const EditTagModal = ({ isOpen, onClose, tag, onSave }: { isOpen: boolean; onClose: () => void; tag: Tag | null; onSave: (tag: Tag, newType: TagType) => void }) => { + const [newTagType, setNewTagType] = useState(tag?.type || TagType.General); + + useEffect(() => { + if (tag) { + setNewTagType(tag.type); + } + }, [tag]); + + const handleSubmit: FormEventHandler = (e) => { + e.preventDefault(); + if (tag) { + onSave(tag, newTagType); + } + }; + + if (!tag) return null; + + return ( + +

Edit Tag

+
+
+ +

{tag.name}

+
+
+ + +
+
+ +
+
+
+ ); + }; + + function onEditTag(tag: Tag) { + setTagToEdit(tag); + setIsEditTagModalOpen(true); + } + + async function onUpdateTag(tag: Tag, newType: TagType) { + if (!user || newType === tag.type) { + setIsEditTagModalOpen(false); + return; + }; + const res = await API.updateTag(tag.name, newType, user); + if (!res.ok) { + alert(`Failed to update tag: ${await res.text()}`); + return; + } + const updatedTag = await res.json(); + setTags(prev => prev ? { ...prev, items: prev.items.map(t => t.name === updatedTag.name ? updatedTag : t) } : null); + setIsEditTagModalOpen(false); + } + + async function onCreateTag(name: string, type: TagType) { + if (!user) return; + const res = await API.createTag(name, type, user); + if (!res.ok) { + alert(`Failed to create tag: ${await res.text()}`); + return; + } + const newTag = await res.json(); + // Reset to first page to see the new tag + if (currentPage !== 1) { + setCurrentPage(1); + } else { + setTags(prev => prev ? { ...prev, items: [newTag, ...prev.items] } : { items: [newTag], totalCount: 1, currentPage: 1, pageSize: 20 }); + } + setIsCreateTagModalOpen(false); + } + + async function onDeleteTag(tag: Tag) { + if (!user) return + const res = await API.deleteTag(tag.name, user); + if (!res.ok) { + alert("Failed to delete tag"); + return; + } + setTags(prev => { + if (!prev) return null; + const newItems = prev.items.filter(t => t.name !== tag.name); + // If the page is now empty and it's not the first page, go to the previous page + if (newItems.length === 0 && currentPage > 1) { + setCurrentPage(currentPage - 1); + } + return { ...prev, items: newItems, total: prev.totalCount - 1 }; + }); + } + + const paginate = (pageNumber: number) => setCurrentPage(pageNumber); + + return ( + +
+
+
+

Tag List

+ {user && user.isAdmin && ( + + )} +
+
+ {paginatedData.items.map(tag => ( +
+ {tag.name} + {user && user.isAdmin &&
+ + +
} +
+ ))} +
+ +
+
+ setIsCreateTagModalOpen(false)} onSave={onCreateTag} /> + setIsEditTagModalOpen(false)} tag={tagToEdit} onSave={onUpdateTag} /> +
+ ); +}