From 04c45a410e5ed17aba5293bee959b816dc53cf4a Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Sat, 10 Jan 2026 22:30:22 +0000 Subject: [PATCH 01/13] feat: unified tag system with Medium-style UX and admin dashboard - Extend tag schema with slug, description, and cached postCount - Add tag_merge_suggestions table for AI-powered duplicate detection - Enhance TagInput with autocomplete showing post counts - Add PopularTagsSidebar component for feed filtering - Implement tag filtering in feed via URL params (?tag=javascript) - Create admin dashboard at /admin/tags with: - Searchable/sortable tag table - Tag editing (title, slug, description) - Merge mode for consolidating duplicate tags - AI merge suggestions panel (ready for integration) - Add migration to convert RSS source categories to tags - Add tag.search, tag.getPopular, tag.getOrCreate API endpoints - Add admin endpoints: getAdminStats, update, mergeTags, recalculateCounts --- app/(app)/admin/_client.tsx | 16 + app/(app)/admin/tags/_client.tsx | 670 +++ app/(app)/admin/tags/page.tsx | 18 + app/(app)/feed/_client.tsx | 66 +- components/Feed/PopularTagsSidebar.tsx | 79 + components/Feed/index.ts | 1 + components/PostEditor/components/TagInput.tsx | 177 +- drizzle/0018_blue_bloodaxe.sql | 25 + drizzle/0019_migrate_categories_to_tags.sql | 56 + drizzle/meta/0018_snapshot.json | 5074 +++++++++++++++++ drizzle/meta/_journal.json | 9 +- scripts/populate-tag-metadata.ts | 193 + server/api/router/content.ts | 43 +- server/api/router/tag.ts | 567 +- server/db/schema.ts | 88 +- 15 files changed, 7021 insertions(+), 61 deletions(-) create mode 100644 app/(app)/admin/tags/_client.tsx create mode 100644 app/(app)/admin/tags/page.tsx create mode 100644 components/Feed/PopularTagsSidebar.tsx create mode 100644 drizzle/0018_blue_bloodaxe.sql create mode 100644 drizzle/0019_migrate_categories_to_tags.sql create mode 100644 drizzle/meta/0018_snapshot.json create mode 100644 scripts/populate-tag-metadata.ts diff --git a/app/(app)/admin/_client.tsx b/app/(app)/admin/_client.tsx index b1824840..a91fbece 100644 --- a/app/(app)/admin/_client.tsx +++ b/app/(app)/admin/_client.tsx @@ -8,6 +8,7 @@ import { RssIcon, ShieldExclamationIcon, NewspaperIcon, + TagIcon, } from "@heroicons/react/24/outline"; import { api } from "@/server/trpc/react"; @@ -205,6 +206,21 @@ const AdminDashboard = () => {

+ + + +
+

+ Tag Management +

+

+ Merge, curate, and manage tags +

+
+ diff --git a/app/(app)/admin/tags/_client.tsx b/app/(app)/admin/tags/_client.tsx new file mode 100644 index 00000000..77263c59 --- /dev/null +++ b/app/(app)/admin/tags/_client.tsx @@ -0,0 +1,670 @@ +"use client"; + +import { useState } from "react"; +import { api } from "@/server/trpc/react"; +import { toast } from "sonner"; +import { + ArrowPathIcon, + PencilSquareIcon, + XMarkIcon, + ArrowsRightLeftIcon, + MagnifyingGlassIcon, + ChevronUpDownIcon, + TagIcon, + ExclamationTriangleIcon, + CheckIcon, + XCircleIcon, +} from "@heroicons/react/20/solid"; + +type SortField = "postCount" | "title" | "createdAt"; +type SortOrder = "asc" | "desc"; + +const TagsAdmin = () => { + const [searchQuery, setSearchQuery] = useState(""); + const [sortField, setSortField] = useState("postCount"); + const [sortOrder, setSortOrder] = useState("desc"); + const [editingTag, setEditingTag] = useState<{ + id: number; + title: string; + slug: string; + description: string; + postCount: number; + } | null>(null); + const [mergeSource, setMergeSource] = useState<{ + id: number; + title: string; + postCount: number; + } | null>(null); + const [mergeTarget, setMergeTarget] = useState<{ + id: number; + title: string; + postCount: number; + } | null>(null); + const [showMergePanel, setShowMergePanel] = useState(false); + + const utils = api.useUtils(); + + // Fetch all tags with admin stats + const { data, status, refetch } = api.tag.getAdminStats.useQuery(); + + // Fetch merge suggestions + const { data: mergeSuggestions, refetch: refetchSuggestions } = + api.tag.getMergeSuggestions.useQuery(); + + // Mutations + const updateTag = api.tag.update.useMutation({ + onSuccess: () => { + toast.success("Tag updated successfully"); + setEditingTag(null); + refetch(); + }, + onError: (error) => { + toast.error(error.message || "Failed to update tag"); + }, + }); + + const mergeTags = api.tag.mergeTags.useMutation({ + onSuccess: (result) => { + toast.success(result.message); + setMergeSource(null); + setMergeTarget(null); + setShowMergePanel(false); + refetch(); + }, + onError: (error) => { + toast.error(error.message || "Failed to merge tags"); + }, + }); + + const reviewSuggestion = api.tag.reviewMergeSuggestion.useMutation({ + onSuccess: () => { + toast.success("Suggestion reviewed"); + refetchSuggestions(); + }, + onError: (error) => { + toast.error(error.message || "Failed to review suggestion"); + }, + }); + + const recalculateCounts = api.tag.recalculateCounts.useMutation({ + onSuccess: (result) => { + toast.success(`Recalculated counts for ${result.updated} tags`); + refetch(); + }, + onError: (error) => { + toast.error(error.message || "Failed to recalculate counts"); + }, + }); + + // Filter and sort tags + const filteredTags = data?.data + ?.filter((tag) => { + if (!searchQuery) return true; + const query = searchQuery.toLowerCase(); + return ( + tag.title.toLowerCase().includes(query) || + tag.slug?.toLowerCase().includes(query) + ); + }) + .sort((a, b) => { + let comparison = 0; + switch (sortField) { + case "postCount": + comparison = a.postCount - b.postCount; + break; + case "title": + comparison = a.title.localeCompare(b.title); + break; + case "createdAt": + comparison = + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + break; + } + return sortOrder === "desc" ? -comparison : comparison; + }); + + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortOrder(sortOrder === "desc" ? "asc" : "desc"); + } else { + setSortField(field); + setSortOrder("desc"); + } + }; + + const handleEditSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!editingTag) return; + updateTag.mutate({ + id: editingTag.id, + title: editingTag.title, + slug: editingTag.slug, + description: editingTag.description || null, + }); + }; + + const handleMerge = () => { + if (!mergeSource || !mergeTarget) return; + if ( + !confirm( + `Are you sure you want to merge "${mergeSource.title}" into "${mergeTarget.title}"? This will move all ${mergeSource.postCount} posts to the target tag and delete "${mergeSource.title}".`, + ) + ) { + return; + } + mergeTags.mutate({ + sourceTagId: mergeSource.id, + targetTagId: mergeTarget.id, + }); + }; + + const handleApproveSuggestion = (suggestion: { + id: number; + sourceTagId: number; + targetTagId: number; + }) => { + // First approve, then merge + reviewSuggestion.mutate( + { suggestionId: suggestion.id, action: "approved" }, + { + onSuccess: () => { + mergeTags.mutate({ + sourceTagId: suggestion.sourceTagId, + targetTagId: suggestion.targetTagId, + }); + }, + }, + ); + }; + + const handleRejectSuggestion = (suggestionId: number) => { + reviewSuggestion.mutate({ suggestionId, action: "rejected" }); + }; + + const selectForMerge = ( + tag: { id: number; title: string; postCount: number }, + type: "source" | "target", + ) => { + if (type === "source") { + if (mergeTarget?.id === tag.id) setMergeTarget(null); + setMergeSource(tag); + } else { + if (mergeSource?.id === tag.id) setMergeSource(null); + setMergeTarget(tag); + } + setShowMergePanel(true); + }; + + const SortIcon = ({ field }: { field: SortField }) => ( + + ); + + return ( +
+ {/* Header */} +
+
+

+ Tag Management +

+

+ Manage, merge, and curate tags across the platform +

+
+ +
+ + {/* Stats Cards */} + {data?.stats && ( +
+
+

+ Total Tags +

+

+ {data.stats.totalTags} +

+
+
+

+ Total Tagged Posts +

+

+ {data.stats.totalPosts.toLocaleString()} +

+
+
+

+ Unused Tags +

+

+ {data.stats.tagsWithNoPosts} +

+
+
+ )} + + {/* Merge Suggestions */} + {mergeSuggestions?.data && mergeSuggestions.data.length > 0 && ( +
+
+ +

+ AI Merge Suggestions ({mergeSuggestions.data.length}) +

+
+
+ {mergeSuggestions.data.slice(0, 5).map((suggestion) => ( +
+
+ + {suggestion.sourceTag?.title} + + + ({suggestion.sourceTag?.postCount} posts) + + + + {suggestion.targetTag?.title} + + + ({suggestion.targetTag?.postCount} posts) + + {suggestion.reason && ( + + - {suggestion.reason} + + )} +
+
+ + +
+
+ ))} +
+
+ )} + + {/* Merge Panel */} + {showMergePanel && ( +
+
+

+ Merge Tags +

+ +
+
+
+

+ Source (will be deleted) +

+
+ {mergeSource ? ( +
+ {mergeSource.title} + + {mergeSource.postCount} posts + +
+ ) : ( + + Click a tag below to select + + )} +
+
+ +
+

+ Target (will keep) +

+
+ {mergeTarget ? ( +
+ {mergeTarget.title} + + {mergeTarget.postCount} posts + +
+ ) : ( + + Click a tag below to select + + )} +
+
+ +
+
+ )} + + {/* Search and Filters */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search tags..." + className="w-full rounded-lg border border-neutral-300 py-2 pl-10 pr-4 dark:border-neutral-600 dark:bg-neutral-800" + /> +
+ +
+ + {/* Edit Modal */} + {editingTag && ( +
+
+
+

+ Edit Tag +

+ +
+
+
+ + + setEditingTag({ ...editingTag, title: e.target.value }) + } + className="w-full rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-700" + /> +
+
+ + + setEditingTag({ ...editingTag, slug: e.target.value }) + } + className="w-full rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-700" + /> +
+
+ +