diff options
| -rw-r--r-- | apps/web/app/dashboard/tags/page.tsx | 75 | ||||
| -rw-r--r-- | packages/trpc/routers/tags.ts | 77 |
2 files changed, 112 insertions, 40 deletions
diff --git a/apps/web/app/dashboard/tags/page.tsx b/apps/web/app/dashboard/tags/page.tsx index f87b0fcc..2ba1074c 100644 --- a/apps/web/app/dashboard/tags/page.tsx +++ b/apps/web/app/dashboard/tags/page.tsx @@ -1,8 +1,13 @@ import Link from "next/link"; -import { redirect } from "next/navigation"; import { Separator } from "@/components/ui/separator"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { api } from "@/server/api/client"; -import { getServerAuthSession } from "@/server/auth"; +import { Info } from "lucide-react"; function TagPill({ name, count }: { name: string; count: number }) { return ( @@ -16,30 +21,62 @@ function TagPill({ name, count }: { name: string; count: number }) { } export default async function TagsPage() { - const session = await getServerAuthSession(); - if (!session) { - redirect("/"); - } - - let tags = (await api.tags.list()).tags; + let allTags = (await api.tags.list()).tags; // Sort tags by usage desc - tags = tags.sort((a, b) => b.count - a.count); + allTags = allTags.sort((a, b) => b.count - a.count); + + const humanTags = allTags.filter((t) => (t.countAttachedBy.human ?? 0) > 0); + const aiTags = allTags.filter((t) => (t.countAttachedBy.human ?? 0) == 0); - let tagPill; - if (tags.length) { - tagPill = tags.map((t) => ( - <TagPill key={t.id} name={t.name} count={t.count} /> - )); - } else { - tagPill = "No Tags"; - } + const tagsToPill = (tags: typeof allTags) => { + let tagPill; + if (tags.length) { + tagPill = tags.map((t) => ( + <TagPill key={t.id} name={t.name} count={t.count} /> + )); + } else { + tagPill = "No Tags"; + } + return tagPill; + }; return ( - <div className="space-y-3"> + <div className="space-y-3 rounded-md border bg-background p-4"> <span className="text-2xl">All Tags</span> <Separator /> - <div className="flex flex-wrap gap-3">{tagPill}</div> + + <span className="flex items-center gap-2"> + <p className="text-lg">Your Tags</p> + <TooltipProvider delayDuration={0}> + <Tooltip> + <TooltipTrigger asChild> + <Info size={20} /> + </TooltipTrigger> + <TooltipContent> + <p>Tags that were attached at least once by you</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </span> + <div className="flex flex-wrap gap-3">{tagsToPill(humanTags)}</div> + + <Separator /> + + <span className="flex items-center gap-2"> + <p className="text-lg">AI Tags</p> + <TooltipProvider delayDuration={0}> + <Tooltip> + <TooltipTrigger asChild> + <Info className="text-gray-100" size={20} /> + </TooltipTrigger> + <TooltipContent> + <p>Tags that were only attached automatically (by AI)</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </span> + <div className="flex flex-wrap gap-3">{tagsToPill(aiTags)}</div> </div> ); } diff --git a/packages/trpc/routers/tags.ts b/packages/trpc/routers/tags.ts index f6ce752d..e84f52ac 100644 --- a/packages/trpc/routers/tags.ts +++ b/packages/trpc/routers/tags.ts @@ -5,7 +5,9 @@ import { z } from "zod"; import { bookmarks, bookmarkTags, tagsOnBookmarks } from "@hoarder/db/schema"; import type { Context } from "../index"; +import type { ZAttachedByEnum } from "../types/tags"; import { authedProcedure, router } from "../index"; +import { zAttachedByEnumSchema } from "../types/tags"; function conditionFromInput( input: { tagName: string } | { tagId: string }, @@ -55,6 +57,13 @@ const ensureTagOwnership = experimental_trpcMiddleware<{ return opts.next(); }); +const zTagSchema = z.object({ + id: z.string(), + name: z.string(), + count: z.number(), + countAttachedBy: z.record(zAttachedByEnumSchema, z.number()), +}); + export const tagsAppRouter = router({ get: authedProcedure .input( @@ -68,20 +77,14 @@ export const tagsAppRouter = router({ }), ), ) - .output( - z.object({ - id: z.string(), - name: z.string(), - bookmarks: z.array(z.string()), - }), - ) + .output(zTagSchema) .use(ensureTagOwnership) .query(async ({ input, ctx }) => { const res = await ctx.db .select({ id: bookmarkTags.id, name: bookmarkTags.name, - bookmarkId: bookmarks.id, + attachedBy: tagsOnBookmarks.attachedBy, }) .from(bookmarkTags) .leftJoin(tagsOnBookmarks, eq(bookmarkTags.id, tagsOnBookmarks.tagId)) @@ -98,10 +101,21 @@ export const tagsAppRouter = router({ throw new TRPCError({ code: "NOT_FOUND" }); } + const countAttachedBy = res.reduce<Record<ZAttachedByEnum, number>>( + (acc, curr) => { + if (curr.attachedBy) { + acc[curr.attachedBy]++; + } + return acc; + }, + { ai: 0, human: 0 }, + ); + return { id: res[0].id, name: res[0].name, - bookmarks: res.flatMap((t) => (t.bookmarkId ? [t.bookmarkId] : [])), + count: Object.values(countAttachedBy).reduce((s, a) => s + a, 0), + countAttachedBy, }; }), delete: authedProcedure @@ -133,26 +147,47 @@ export const tagsAppRouter = router({ list: authedProcedure .output( z.object({ - tags: z.array( - z.object({ - id: z.string(), - name: z.string(), - count: z.number(), - }), - ), + tags: z.array(zTagSchema), }), ) .query(async ({ ctx }) => { - const tags = await ctx.db + const res = await ctx.db .select({ id: tagsOnBookmarks.tagId, name: bookmarkTags.name, + attachedBy: tagsOnBookmarks.attachedBy, count: count(), }) .from(tagsOnBookmarks) - .where(eq(bookmarkTags.userId, ctx.user.id)) - .groupBy(tagsOnBookmarks.tagId) - .innerJoin(bookmarkTags, eq(bookmarkTags.id, tagsOnBookmarks.tagId)); - return { tags }; + .groupBy(tagsOnBookmarks.tagId, tagsOnBookmarks.attachedBy) + .innerJoin(bookmarkTags, eq(bookmarkTags.id, tagsOnBookmarks.tagId)) + .leftJoin(bookmarks, eq(tagsOnBookmarks.bookmarkId, bookmarks.id)) + .where( + and( + eq(bookmarkTags.userId, ctx.user.id), + eq(bookmarks.archived, false), + ), + ); + + const tags = res.reduce<Record<string, z.infer<typeof zTagSchema>>>( + (acc, row) => { + if (!(row.id in acc)) { + acc[row.id] = { + id: row.id, + name: row.name, + count: 0, + countAttachedBy: { + ai: 0, + human: 0, + }, + }; + } + acc[row.id].count++; + acc[row.id].countAttachedBy[row.attachedBy]!++; + return acc; + }, + {}, + ); + return { tags: Object.values(tags) }; }), }); |
