diff --git a/frontend/src/pages/manage/tags/_index.tsx b/frontend/src/pages/manage/tags/_index.tsx index 362aa4d1..6ca331ae 100644 --- a/frontend/src/pages/manage/tags/_index.tsx +++ b/frontend/src/pages/manage/tags/_index.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type FC } from "reac import { useQueryClient } from "@tanstack/react-query"; import { apiClient, SKIP_ERROR_TOAST } from "@/lib/api"; import { guildKeys, useGuildPremium, useGuildTags } from "@/hooks/queries/useGuild"; -import { useParams } from "react-router"; +import { useParams, useSearchParams } from "react-router"; import { getGuildById } from "@/stores/auth"; import { toast } from "sonner"; import { MainLayout } from "@/pages/layout/Main"; @@ -21,12 +21,17 @@ import { faShareNodes, faTag, faTrash, + faXmark, } from "@fortawesome/free-solid-svg-icons"; import ActionDropdown from "@/components/ActionDropdown"; +import Checkbox from "@/components/Checkbox"; +import ColumnFilter from "@/components/ColumnFilter"; import EmptyState from "@/components/EmptyState"; import Table from "@/components/Table"; -import SortableHeaderCell from "@/components/SortableHeaderCell"; +import TextInput from "@/components/TextInput"; +import { SortTrigger, ariaSortFor } from "@/components/SortableHeaderCell"; import { useTableSort } from "@/hooks/useTableSort"; +import { matchesSearch } from "@/lib/search"; import type { SortColumn } from "@/lib/table-sort"; import TableSkeleton from "@/components/skeletons/TableSkeleton"; import GallerySubmitModal from "@/components/modals/GallerySubmitModal"; @@ -68,9 +73,29 @@ function resyncSummary(status: TagAliasResyncStatus): string { return `Command aliases resynced: ${parts.join(", ")}.${cause} Discord may take a few minutes to show the changes.`; } +const TAG_TYPES = ["text", "embed", "embed_text", "kb"] as const; +type TagType = (typeof TAG_TYPES)[number]; + +const TAG_TYPE_LABELS: Record = { + text: "Text", + embed: "Embed", + embed_text: "Embed + Text", + kb: "Knowledge Base", +}; + +function isTagType(value: string): value is TagType { + return (TAG_TYPES as readonly string[]).includes(value); +} + +function tagType(tag: Tag): TagType { + if (tag.kb_article_id != null) return "kb"; + if (!tag.use_embed) return "text"; + return tag.content ? "embed_text" : "embed"; +} + const TAG_SORT_COLUMNS: Record<"id" | "type", SortColumn> = { id: { value: (t) => t.id, defaultDir: "asc" }, - type: { value: (t) => (t.use_embed ? "Embed" : "Text"), defaultDir: "asc" }, + type: { value: (t) => TAG_TYPE_LABELS[tagType(t)], defaultDir: "asc" }, }; const TagsPage: FC = () => { @@ -97,20 +122,12 @@ const TagsPage: FC = () => { const [forcedLock, setForcedLock] = useState(false); const isLocked = forcedLock || polledLock === true; - // This page is a long-lived list rather than a form the user navigates away - // from after one submit, so a forced lock from a 503 must release once the - // poll confirms the flag is back on, otherwise the page stays locked forever - // after a single incident even though the flag was re-enabled. useEffect(() => { if (polledLock === false) { setForcedLock(false); } }, [polledLock]); - // Announce the lock lifting mid-session (e.g. a flag re-enabled while this page - // is open). The banner's own aria-live region only reliably announces the - // unlocked-to-locked transition (see FeatureLockBanner), so the reverse gets a - // toast instead. Guarded so it never fires on mount, only on a genuine flip. const previousLockRef = useRef(isLocked); useEffect(() => { if (previousLockRef.current && !isLocked) { @@ -137,7 +154,6 @@ const TagsPage: FC = () => { const isResyncRunning = resyncStatus?.status === "running"; const isResyncing = isResyncRunning || isStartingResync; - // Ticked locally so the button re-enables without polling const [cooldownLeft, setCooldownLeft] = useState(0); const cooldownUntil = resyncStatus?.cooldown_until; @@ -170,7 +186,6 @@ const TagsPage: FC = () => { } }, [guildId]); - // Re-attach to a job left running by an earlier visit useEffect(() => { if (!canResync) return; @@ -193,7 +208,6 @@ const TagsPage: FC = () => { return () => clearInterval(id); }, [isResyncRunning, pollResync]); - // Reusing the id replaces the toast in place useEffect(() => { const toastId = resyncToastRef.current; if (!resyncStatus || toastId === null) return; @@ -221,7 +235,6 @@ const TagsPage: FC = () => { } }, [resyncStatus]); - // Never leave a spinner behind on a page the user has left. useEffect( () => () => { if (resyncToastRef.current !== null) { @@ -266,7 +279,6 @@ const TagsPage: FC = () => { const handleSave = async (tag: Tag, originalId?: string) => { try { - // If the ID was renamed, delete the old one first if (originalId && originalId !== tag.id) { await apiClient.tags.delete(guildId, originalId, SKIP_ERROR_TOAST); } @@ -298,12 +310,61 @@ const TagsPage: FC = () => { setDeleteModal(null); }; + const [searchParams, setSearchParams] = useSearchParams(); + const [idFilter, setIdFilter] = useState(() => searchParams.get("id") ?? ""); + const [typeFilter, setTypeFilter] = useState( + () => searchParams.get("type")?.split(",").filter(isTagType) ?? [], + ); + const [aliasOnly, setAliasOnly] = useState(() => searchParams.get("alias") === "1"); + const idQuery = idFilter.trim(); + + useEffect(() => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (idQuery) next.set("id", idQuery); + else next.delete("id"); + if (typeFilter.length > 0) next.set("type", typeFilter.join(",")); + else next.delete("type"); + if (aliasOnly) next.set("alias", "1"); + else next.delete("alias"); + return next; + }, + { replace: true }, + ); + }, [idQuery, typeFilter, aliasOnly, setSearchParams]); + const tagList = useMemo(() => Object.values(tags), [tags]); - const sort = useTableSort(tagList, TAG_SORT_COLUMNS, { + const filteredTags = useMemo( + () => + tagList.filter( + (t) => + matchesSearch(idQuery, t.id) && + (typeFilter.length === 0 || typeFilter.includes(tagType(t))) && + (!aliasOnly || t.use_guild_command), + ), + [tagList, idQuery, typeFilter, aliasOnly], + ); + const sort = useTableSort(filteredTags, TAG_SORT_COLUMNS, { initialSort: { key: "id", dir: "asc" }, persistKey: "guild-tags", }); + const hasActiveFilters = !!idQuery || typeFilter.length > 0 || aliasOnly; + const hasAliases = tagList.some((t) => t.use_guild_command); + + const clearFilters = () => { + setIdFilter(""); + setTypeFilter([]); + setAliasOnly(false); + }; + + const toggleTypeFilter = (type: TagType) => { + setTypeFilter((prev) => + prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type], + ); + }; + if (loading) { return ( { existingLabel="tags" />
-
- - {cooldownLeft > 0 - ? "Discord is rate limiting this server's commands. Run this again once it clears and it picks up where it stopped." - : RESYNC_TOOLTIP} - {!canResync && " Requires Premium."} - - } - placement="bottom" - className="flex" - > +
+
+

+ {filteredTags.length} + {hasActiveFilters ? ` of ${tagList.length}` : ""} tag + {tagList.length !== 1 ? "s" : ""} +

+ {hasActiveFilters && ( + + )} +
+
+ + {cooldownLeft > 0 + ? "Discord is rate limiting this server's commands. Run this again once it clears and it picks up where it stopped." + : RESYNC_TOOLTIP} + {!canResync && " Requires Premium."} + + } + placement="bottom" + className="flex" + > + + - - +
@@ -385,9 +461,7 @@ const TagsPage: FC = () => { icon={faTag} title="No tags yet" description="Tags are reusable responses your staff can send in tickets." - // EmptyState has no lock affordance of its own, so when locked the - // action is omitted entirely rather than left clickable: the banner - // above is the only explanation available at this point. + // EmptyState can't show a disabled action, so it's omitted while locked action={ isLocked ? undefined @@ -398,64 +472,137 @@ const TagsPage: FC = () => { - - + + + } + > + + {(hasAliases || aliasOnly) && ( +
+ +
+ )} +
+
+ + 0} + labelSlot={ + + } + > +
+ {TAG_TYPES.map((type) => ( + toggleTypeFilter(type)} + label={TAG_TYPE_LABELS[type]} + className="text-sm text-gray-200 hover:text-white" + /> + ))} +
+ {typeFilter.length > 0 && ( + + )} +
+
Actions
- {sort.sortedRows.map((tag) => ( - - - {tag.id} - - - {tag.use_embed ? "Embed" : "Text"} - {tag.content && tag.use_embed ? " + Text" : ""} - - - { - setEditingTag(tag); - setEditorOpen(true); - }, - }, - { - label: "Clone", - icon: faCopy, - onClick: () => { - setEditingTag(tag); - setCloningTag(true); - setEditorOpen(true); - }, - }, - ...(canPublishToGallery - ? [ - { - label: "Publish to Gallery", - icon: faShareNodes, - onClick: () => setGallerySubmitTag(tag), - }, - ] - : []), - { - label: "Remove", - icon: faTrash, - variant: "danger", - disabled: isLocked, - onClick: () => setDeleteModal({ isOpen: true, tagId: tag.id }), - }, - ]} + {sort.sortedRows.length === 0 ? ( + + + - ))} + ) : ( + sort.sortedRows.map((tag) => ( + + + {tag.id} + + + {TAG_TYPE_LABELS[tagType(tag)]} + + + { + setEditingTag(tag); + setEditorOpen(true); + }, + }, + { + label: "Clone", + icon: faCopy, + onClick: () => { + setEditingTag(tag); + setCloningTag(true); + setEditorOpen(true); + }, + }, + ...(canPublishToGallery + ? [ + { + label: "Publish to Gallery", + icon: faShareNodes, + onClick: () => setGallerySubmitTag(tag), + }, + ] + : []), + { + label: "Remove", + icon: faTrash, + variant: "danger", + disabled: isLocked, + onClick: () => setDeleteModal({ isOpen: true, tagId: tag.id }), + }, + ]} + /> + + + )) + )}
)}