diff options
| -rw-r--r-- | apps/web/app/dashboard/layout.tsx | 20 | ||||
| -rw-r--r-- | apps/web/app/dashboard/wrapped/page.tsx | 24 | ||||
| -rw-r--r-- | apps/web/app/settings/stats/page.tsx | 41 | ||||
| -rw-r--r-- | apps/web/components/wrapped/ShareButton.tsx | 92 | ||||
| -rw-r--r-- | apps/web/components/wrapped/WrappedContent.tsx | 390 | ||||
| -rw-r--r-- | apps/web/components/wrapped/WrappedModal.tsx | 85 | ||||
| -rw-r--r-- | apps/web/components/wrapped/index.ts | 3 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en/translation.json | 49 | ||||
| -rw-r--r-- | apps/web/package.json | 1 | ||||
| -rw-r--r-- | packages/shared/types/users.ts | 67 | ||||
| -rw-r--r-- | packages/trpc/models/users.ts | 305 | ||||
| -rw-r--r-- | packages/trpc/routers/users.ts | 11 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 50 |
13 files changed, 1107 insertions, 31 deletions
diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx index be65e66a..81b44a3c 100644 --- a/apps/web/app/dashboard/layout.tsx +++ b/apps/web/app/dashboard/layout.tsx @@ -16,6 +16,7 @@ import { Highlighter, Home, Search, + Sparkles, Tag, } from "lucide-react"; @@ -34,9 +35,10 @@ export default async function Dashboard({ redirect("/"); } - const [lists, userSettings] = await Promise.all([ + const [lists, userSettings, showWrapped] = await Promise.all([ tryCatch(api.lists.list()), tryCatch(api.users.settings()), + tryCatch(api.users.hasWrapped()), ]); if (userSettings.error) { @@ -55,6 +57,10 @@ export default async function Dashboard({ throw lists.error; } + if (showWrapped.error) { + throw showWrapped.error; + } + const items = (t: TFunction) => [ { @@ -86,10 +92,20 @@ export default async function Dashboard({ icon: <Archive size={18} />, path: "/dashboard/archive", }, + // Only show wrapped if user has at least 20 bookmarks + showWrapped.data + ? [ + { + name: t("wrapped.button"), + icon: <Sparkles size={18} />, + path: "/dashboard/wrapped", + }, + ] + : [], ].flat(); const mobileSidebar = (t: TFunction) => [ - ...items(t), + ...items(t).filter((item) => item.path !== "/dashboard/wrapped"), { name: t("lists.all_lists"), icon: <ClipboardList size={18} />, diff --git a/apps/web/app/dashboard/wrapped/page.tsx b/apps/web/app/dashboard/wrapped/page.tsx new file mode 100644 index 00000000..f479aca7 --- /dev/null +++ b/apps/web/app/dashboard/wrapped/page.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { WrappedModal } from "@/components/wrapped"; + +export default function WrappedPage() { + const router = useRouter(); + + const handleClose = () => { + router.push("/dashboard/bookmarks"); + }; + + // Always show the modal when this page is loaded + useEffect(() => { + // Prevent page from being scrollable when modal is open + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = ""; + }; + }, []); + + return <WrappedModal open={true} onClose={handleClose} />; +} diff --git a/apps/web/app/settings/stats/page.tsx b/apps/web/app/settings/stats/page.tsx index 944d1c59..6f9db115 100644 --- a/apps/web/app/settings/stats/page.tsx +++ b/apps/web/app/settings/stats/page.tsx @@ -1,10 +1,12 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { Skeleton } from "@/components/ui/skeleton"; +import { WrappedModal } from "@/components/wrapped"; import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; import { @@ -26,6 +28,7 @@ import { List, Rss, Smartphone, + Sparkles, TrendingUp, Upload, Zap, @@ -162,6 +165,8 @@ export default function StatsPage() { const { t } = useTranslation(); const { data: stats, isLoading } = api.users.stats.useQuery(); const { data: userSettings } = api.users.settings.useQuery(); + const { data: hasWrapped } = api.users.hasWrapped.useQuery(); + const [showWrapped, setShowWrapped] = useState(false); const maxHourlyActivity = useMemo(() => { if (!stats) return 0; @@ -222,20 +227,30 @@ export default function StatsPage() { return ( <div className="space-y-6"> - <div> - <h1 className="text-3xl font-bold"> - {t("settings.stats.usage_statistics")} - </h1> - <p className="text-muted-foreground"> - Insights into your bookmarking habits and collection - {userSettings?.timezone && userSettings.timezone !== "UTC" && ( - <span className="block text-sm"> - Times shown in {userSettings.timezone} timezone - </span> - )} - </p> + <div className="flex items-start justify-between"> + <div> + <h1 className="text-3xl font-bold"> + {t("settings.stats.usage_statistics")} + </h1> + <p className="text-muted-foreground"> + Insights into your bookmarking habits and collection + {userSettings?.timezone && userSettings.timezone !== "UTC" && ( + <span className="block text-sm"> + Times shown in {userSettings.timezone} timezone + </span> + )} + </p> + </div> + {hasWrapped && ( + <Button onClick={() => setShowWrapped(true)} className="gap-2"> + <Sparkles className="h-4 w-4" /> + View Your 2025 Wrapped + </Button> + )} </div> + <WrappedModal open={showWrapped} onClose={() => setShowWrapped(false)} /> + {/* Overview Stats */} <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <StatCard diff --git a/apps/web/components/wrapped/ShareButton.tsx b/apps/web/components/wrapped/ShareButton.tsx new file mode 100644 index 00000000..048cafea --- /dev/null +++ b/apps/web/components/wrapped/ShareButton.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { RefObject, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Download, Loader2, Share2 } from "lucide-react"; +import { domToPng } from "modern-screenshot"; + +interface ShareButtonProps { + contentRef: RefObject<HTMLDivElement | null>; + fileName?: string; +} + +export function ShareButton({ + contentRef, + fileName = "karakeep-wrapped-2025.png", +}: ShareButtonProps) { + const [isGenerating, setIsGenerating] = useState(false); + + const handleShare = async () => { + if (!contentRef.current) return; + + setIsGenerating(true); + + try { + // Capture the content as PNG data URL + const dataUrl = await domToPng(contentRef.current, { + scale: 2, // Higher resolution + quality: 1, + debug: false, + width: contentRef.current.scrollWidth, // Capture full width + height: contentRef.current.scrollHeight, // Capture full height including scrolled content + drawImageInterval: 100, // Add delay for rendering + }); + + // Convert data URL to blob + const response = await fetch(dataUrl); + const blob = await response.blob(); + + // Try native share API first (works well on mobile) + if ( + typeof navigator.share !== "undefined" && + typeof navigator.canShare !== "undefined" + ) { + const file = new File([blob], fileName, { type: "image/png" }); + if (navigator.canShare({ files: [file] })) { + await navigator.share({ + files: [file], + title: "My 2025 Karakeep Wrapped", + text: "Check out my 2025 Karakeep Wrapped!", + }); + return; + } + } + + // Fallback: download the image + const a = document.createElement("a"); + a.href = dataUrl; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } catch (error) { + console.error("Failed to capture or share image:", error); + } finally { + setIsGenerating(false); + } + }; + + const isNativeShareAvailable = + typeof navigator.share !== "undefined" && + typeof navigator.canShare !== "undefined"; + + return ( + <Button + onClick={handleShare} + disabled={isGenerating} + size="icon" + variant="ghost" + className="h-10 w-10 rounded-full bg-white/10 text-slate-100 hover:bg-white/20" + aria-label={isNativeShareAvailable ? "Share" : "Download"} + title={isNativeShareAvailable ? "Share" : "Download"} + > + {isGenerating ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : isNativeShareAvailable ? ( + <Share2 className="h-4 w-4" /> + ) : ( + <Download className="h-4 w-4" /> + )} + </Button> + ); +} diff --git a/apps/web/components/wrapped/WrappedContent.tsx b/apps/web/components/wrapped/WrappedContent.tsx new file mode 100644 index 00000000..261aadfd --- /dev/null +++ b/apps/web/components/wrapped/WrappedContent.tsx @@ -0,0 +1,390 @@ +"use client"; + +import { forwardRef } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { + BookOpen, + Calendar, + Chrome, + Clock, + Code, + FileText, + Globe, + Hash, + Heart, + Highlighter, + Link, + Rss, + Smartphone, + Upload, + Zap, +} from "lucide-react"; +import { z } from "zod"; + +import { zBookmarkSourceSchema } from "@karakeep/shared/types/bookmarks"; +import { zWrappedStatsResponseSchema } from "@karakeep/shared/types/users"; + +type WrappedStats = z.infer<typeof zWrappedStatsResponseSchema>; +type BookmarkSource = z.infer<typeof zBookmarkSourceSchema>; + +interface WrappedContentProps { + stats: WrappedStats; + userName?: string; +} + +const dayNames = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +]; +const monthNames = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +function formatSourceName(source: BookmarkSource | null): string { + if (!source) return "Unknown"; + const sourceMap: Record<BookmarkSource, string> = { + api: "API", + web: "Web", + extension: "Browser Extension", + cli: "CLI", + mobile: "Mobile App", + singlefile: "SingleFile", + rss: "RSS Feed", + import: "Import", + }; + return sourceMap[source]; +} + +function getSourceIcon(source: BookmarkSource | null, className = "h-5 w-5") { + const iconProps = { className }; + switch (source) { + case "api": + return <Zap {...iconProps} />; + case "web": + return <Globe {...iconProps} />; + case "extension": + return <Chrome {...iconProps} />; + case "cli": + return <Code {...iconProps} />; + case "mobile": + return <Smartphone {...iconProps} />; + case "singlefile": + return <FileText {...iconProps} />; + case "rss": + return <Rss {...iconProps} />; + case "import": + return <Upload {...iconProps} />; + default: + return <Globe {...iconProps} />; + } +} + +export const WrappedContent = forwardRef<HTMLDivElement, WrappedContentProps>( + ({ stats, userName }, ref) => { + const maxMonthlyCount = Math.max( + ...stats.monthlyActivity.map((m) => m.count), + ); + + return ( + <div + ref={ref} + className="min-h-screen w-full overflow-auto bg-slate-950 bg-[radial-gradient(1200px_600px_at_20%_-10%,rgba(16,185,129,0.18),transparent),radial-gradient(900px_500px_at_90%_10%,rgba(14,116,144,0.2),transparent)] p-6 text-slate-100 md:p-8" + > + <div className="mx-auto max-w-5xl space-y-4"> + {/* Header */} + <div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between"> + <div> + <h1 className="text-2xl font-semibold md:text-3xl"> + Your {stats.year} Wrapped + </h1> + <p className="mt-1 text-xs text-slate-300 md:text-sm"> + A Year in Karakeep + </p> + {userName && ( + <p className="mt-2 text-sm text-slate-400">{userName}</p> + )} + </div> + </div> + + <div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3"> + <Card className="flex flex-col items-center justify-center border border-white/10 bg-white/5 p-4 text-center text-slate-100 backdrop-blur-sm"> + <p className="text-xs text-slate-300">You saved</p> + <p className="my-2 text-3xl font-semibold md:text-4xl"> + {stats.totalBookmarks} + </p> + <p className="text-xs text-slate-300"> + {stats.totalBookmarks === 1 ? "item" : "items"} this year + </p> + </Card> + {/* First Bookmark */} + {stats.firstBookmark && ( + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm"> + <div className="flex h-full flex-col"> + <div className="mb-3 flex items-center gap-2"> + <Calendar className="h-4 w-4 flex-shrink-0 text-emerald-300" /> + <p className="text-[10px] uppercase tracking-wide text-slate-400"> + First Bookmark of {stats.year} + </p> + </div> + <div className="flex-1"> + <p className="text-2xl font-bold text-slate-100"> + {new Date( + stats.firstBookmark.createdAt, + ).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + })} + </p> + {stats.firstBookmark.title && ( + <p className="mt-2 line-clamp-2 text-base leading-relaxed text-slate-300"> + “{stats.firstBookmark.title}” + </p> + )} + </div> + </div> + </Card> + )} + + {/* Activity + Peak */} + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm"> + <h2 className="mb-2 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-300"> + <Clock className="h-4 w-4" /> + Activity Highlights + </h2> + <div className="grid gap-2 text-sm"> + {stats.mostActiveDay && ( + <div> + <p className="text-xs text-slate-400">Most Active Day</p> + <p className="text-base font-semibold"> + {new Date(stats.mostActiveDay.date).toLocaleDateString( + "en-US", + { + month: "short", + day: "numeric", + }, + )} + </p> + <p className="text-xs text-slate-400"> + {stats.mostActiveDay.count}{" "} + {stats.mostActiveDay.count === 1 ? "save" : "saves"} + </p> + </div> + )} + <div className="grid grid-cols-2 gap-2"> + <div> + <p className="text-xs text-slate-400">Peak Hour</p> + <p className="text-base font-semibold"> + {stats.peakHour === 0 + ? "12 AM" + : stats.peakHour < 12 + ? `${stats.peakHour} AM` + : stats.peakHour === 12 + ? "12 PM" + : `${stats.peakHour - 12} PM`} + </p> + </div> + <div> + <p className="text-xs text-slate-400">Peak Day</p> + <p className="text-base font-semibold"> + {dayNames[stats.peakDayOfWeek]} + </p> + </div> + </div> + </div> + </Card> + + {/* Top Lists */} + {(stats.topDomains.length > 0 || stats.topTags.length > 0) && ( + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm md:col-span-2 lg:col-span-2"> + <h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-slate-300"> + Top Lists + </h2> + <div className="grid gap-3 md:grid-cols-2"> + {stats.topDomains.length > 0 && ( + <div> + <h3 className="mb-1 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-slate-400"> + <Globe className="h-3.5 w-3.5" /> + Sites + </h3> + <div className="space-y-1.5 text-sm"> + {stats.topDomains.map((domain, index) => ( + <div + key={domain.domain} + className="flex items-center justify-between" + > + <div className="flex items-center gap-2"> + <div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-white/10 text-[10px] font-semibold text-slate-200"> + {index + 1} + </div> + <span className="text-slate-100"> + {domain.domain} + </span> + </div> + <Badge className="bg-white/10 text-[10px] text-slate-200"> + {domain.count} + </Badge> + </div> + ))} + </div> + </div> + )} + {stats.topTags.length > 0 && ( + <div> + <h3 className="mb-1 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-slate-400"> + <Hash className="h-3.5 w-3.5" /> + Tags + </h3> + <div className="space-y-1.5 text-sm"> + {stats.topTags.map((tag, index) => ( + <div + key={tag.name} + className="flex items-center justify-between" + > + <div className="flex items-center gap-2"> + <div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-white/10 text-[10px] font-semibold text-slate-200"> + {index + 1} + </div> + <span className="text-slate-100">{tag.name}</span> + </div> + <Badge className="bg-white/10 text-[10px] text-slate-200"> + {tag.count} + </Badge> + </div> + ))} + </div> + </div> + )} + </div> + </Card> + )} + + {/* Bookmarks by Source */} + {stats.bookmarksBySource.length > 0 && ( + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm"> + <h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-300"> + How You Save + </h2> + <div className="space-y-1.5 text-sm"> + {stats.bookmarksBySource.map((source) => ( + <div + key={source.source || "unknown"} + className="flex items-center justify-between" + > + <div className="flex items-center gap-2 text-slate-100"> + {getSourceIcon(source.source, "h-4 w-4")} + <span>{formatSourceName(source.source)}</span> + </div> + <Badge className="bg-white/10 text-[10px] text-slate-200"> + {source.count} + </Badge> + </div> + ))} + </div> + </Card> + )} + + {/* Monthly Activity */} + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm md:col-span-2 lg:col-span-3"> + <h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-300"> + <Calendar className="h-4 w-4" /> + Your Year in Saves + </h2> + <div className="grid gap-2 text-xs md:grid-cols-2 lg:grid-cols-3"> + {stats.monthlyActivity.map((month) => ( + <div key={month.month} className="flex items-center gap-2"> + <div className="w-7 text-right text-[10px] text-slate-400"> + {monthNames[month.month - 1]} + </div> + <div className="relative h-2 flex-1 overflow-hidden rounded-full bg-white/10"> + <div + className="h-full rounded-full bg-emerald-300/70" + style={{ + width: `${maxMonthlyCount > 0 ? (month.count / maxMonthlyCount) * 100 : 0}%`, + }} + /> + </div> + <div className="w-7 text-[10px] text-slate-300"> + {month.count} + </div> + </div> + ))} + </div> + </Card> + + {/* Summary Stats */} + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm md:col-span-2 lg:col-span-3"> + <div className="grid gap-3 text-center sm:grid-cols-3"> + <div className="rounded-lg bg-white/5 p-3"> + <Heart className="mx-auto mb-1 h-4 w-4 text-rose-200" /> + <p className="text-lg font-semibold"> + {stats.totalFavorites} + </p> + <p className="text-[10px] text-slate-400">Favorites</p> + </div> + <div className="rounded-lg bg-white/5 p-3"> + <Hash className="mx-auto mb-1 h-4 w-4 text-amber-200" /> + <p className="text-lg font-semibold">{stats.totalTags}</p> + <p className="text-[10px] text-slate-400">Tags Created</p> + </div> + <div className="rounded-lg bg-white/5 p-3"> + <Highlighter className="mx-auto mb-1 h-4 w-4 text-emerald-200" /> + <p className="text-lg font-semibold"> + {stats.totalHighlights} + </p> + <p className="text-[10px] text-slate-400">Highlights</p> + </div> + </div> + <div className="mt-3 grid gap-3 text-center sm:grid-cols-3"> + <div className="rounded-lg bg-white/5 p-3"> + <Link className="mx-auto mb-1 h-4 w-4 text-slate-200" /> + <p className="text-lg font-semibold"> + {stats.bookmarksByType.link} + </p> + <p className="text-[10px] text-slate-400">Links</p> + </div> + <div className="rounded-lg bg-white/5 p-3"> + <FileText className="mx-auto mb-1 h-4 w-4 text-slate-200" /> + <p className="text-lg font-semibold"> + {stats.bookmarksByType.text} + </p> + <p className="text-[10px] text-slate-400">Notes</p> + </div> + <div className="rounded-lg bg-white/5 p-3"> + <BookOpen className="mx-auto mb-1 h-4 w-4 text-slate-200" /> + <p className="text-lg font-semibold"> + {stats.bookmarksByType.asset} + </p> + <p className="text-[10px] text-slate-400">Assets</p> + </div> + </div> + </Card> + </div> + + {/* Footer */} + <div className="pb-4 pt-1 text-center text-[10px] text-slate-500"> + Made with Karakeep + </div> + </div> + </div> + ); + }, +); + +WrappedContent.displayName = "WrappedContent"; diff --git a/apps/web/components/wrapped/WrappedModal.tsx b/apps/web/components/wrapped/WrappedModal.tsx new file mode 100644 index 00000000..25e376b0 --- /dev/null +++ b/apps/web/components/wrapped/WrappedModal.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useRef } from "react"; +import { + Dialog, + DialogContent, + DialogOverlay, + DialogTitle, +} from "@/components/ui/dialog"; +import { api } from "@/lib/trpc"; +import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; +import { Loader2, X } from "lucide-react"; + +import { ShareButton } from "./ShareButton"; +import { WrappedContent } from "./WrappedContent"; + +interface WrappedModalProps { + open: boolean; + onClose: () => void; +} + +export function WrappedModal({ open, onClose }: WrappedModalProps) { + const contentRef = useRef<HTMLDivElement | null>(null); + const { data: stats, isLoading } = api.users.wrapped.useQuery(undefined, { + enabled: open, + }); + const { data: whoami } = api.users.whoami.useQuery(undefined, { + enabled: open, + }); + + return ( + <Dialog open={open} onOpenChange={onClose}> + <DialogOverlay className="z-50" /> + <DialogContent + className="max-w-screen h-screen max-h-screen w-screen overflow-hidden rounded-none border-none p-0" + hideCloseBtn={true} + > + <VisuallyHidden.Root> + <DialogTitle>Your 2025 Wrapped</DialogTitle> + </VisuallyHidden.Root> + <div className="fixed right-4 top-4 z-50 flex items-center gap-2"> + {/* Share button overlay */} + {stats && !isLoading && <ShareButton contentRef={contentRef} />} + {/* Close button overlay */} + <button + onClick={onClose} + className="rounded-full bg-white/10 p-2 backdrop-blur-sm transition-colors hover:bg-white/20" + aria-label="Close" + title="Close" + > + <X className="h-5 w-5 text-white" /> + </button> + </div> + + {/* Content */} + {isLoading ? ( + <div className="flex h-full items-center justify-center bg-slate-950 bg-[radial-gradient(1200px_600px_at_20%_-10%,rgba(16,185,129,0.18),transparent),radial-gradient(900px_500px_at_90%_10%,rgba(14,116,144,0.2),transparent)]"> + <div className="text-center text-white"> + <Loader2 className="mx-auto mb-4 h-12 w-12 animate-spin" /> + <p className="text-xl">Loading your Wrapped...</p> + </div> + </div> + ) : stats ? ( + <WrappedContent + ref={contentRef} + stats={stats} + userName={whoami?.name || undefined} + /> + ) : ( + <div className="flex h-full items-center justify-center bg-slate-950 bg-[radial-gradient(1200px_600px_at_20%_-10%,rgba(16,185,129,0.18),transparent),radial-gradient(900px_500px_at_90%_10%,rgba(14,116,144,0.2),transparent)]"> + <div className="text-center text-white"> + <p className="text-xl">Failed to load your Wrapped stats</p> + <button + onClick={onClose} + className="mt-4 rounded-lg bg-white/20 px-6 py-2 backdrop-blur-sm hover:bg-white/30" + > + Close + </button> + </div> + </div> + )} + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/components/wrapped/index.ts b/apps/web/components/wrapped/index.ts new file mode 100644 index 00000000..45d142e1 --- /dev/null +++ b/apps/web/components/wrapped/index.ts @@ -0,0 +1,3 @@ +export { WrappedModal } from "./WrappedModal"; +export { WrappedContent } from "./WrappedContent"; +export { ShareButton } from "./ShareButton"; diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 03aaa645..58e1af09 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -863,5 +863,54 @@ "no_release_notes": "No release notes were published for this version.", "release_notes_synced": "Release notes are synced from GitHub.", "view_on_github": "View on GitHub" + }, + "wrapped": { + "title": "Your {{year}} Wrapped", + "subtitle": "A Year in Karakeep", + "banner": { + "title": "Your 2025 Wrapped is ready!", + "description": "See your year in bookmarks", + "view_now": "View Now" + }, + "button": "2025 Wrapped", + "loading": "Loading your Wrapped...", + "failed_to_load": "Failed to load your Wrapped stats", + "sections": { + "total_saves": { + "prefix": "You saved", + "suffix": "items this year", + "suffix_singular": "item this year" + }, + "first_bookmark": { + "title": "Your Journey Started", + "description": "First save of {{year}}:" + }, + "top_domains": "Your Top Sites", + "top_tags": "Your Top Tags", + "monthly_activity": "Your Year in Saves", + "most_active_day": "Your Most Active Day", + "peak_times": { + "title": "When You Save", + "peak_hour": "Peak Hour", + "peak_day": "Peak Day" + }, + "how_you_save": "How You Save", + "what_you_saved": "What You Saved", + "summary": { + "favorites": "Favorites", + "tags_created": "Tags Created", + "highlights": "Highlights" + }, + "types": { + "links": "Links", + "notes": "Notes", + "assets": "Assets" + } + }, + "footer": "Made with Karakeep", + "share": "Share", + "download": "Download", + "close": "Close", + "generating": "Generating..." } } diff --git a/apps/web/package.json b/apps/web/package.json index 648ba92b..5bd26595 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -71,6 +71,7 @@ "i18next-resources-to-backend": "^1.2.1", "lexical": "^0.20.2", "lucide-react": "^0.501.0", + "modern-screenshot": "^4.6.7", "next": "15.3.8", "next-auth": "^4.24.11", "next-i18next": "^15.3.1", diff --git a/packages/shared/types/users.ts b/packages/shared/types/users.ts index 3ba56583..35db0e98 100644 --- a/packages/shared/types/users.ts +++ b/packages/shared/types/users.ts @@ -114,6 +114,73 @@ export const zUserStatsResponseSchema = z.object({ ), }); +export const zWrappedStatsResponseSchema = z.object({ + year: z.number(), + totalBookmarks: z.number(), + totalFavorites: z.number(), + totalArchived: z.number(), + totalHighlights: z.number(), + totalTags: z.number(), + totalLists: z.number(), + + firstBookmark: z + .object({ + id: z.string(), + title: z.string().nullable(), + createdAt: z.date(), + type: z.enum(["link", "text", "asset"]), + }) + .nullable(), + + mostActiveDay: z + .object({ + date: z.string(), + count: z.number(), + }) + .nullable(), + + topDomains: z + .array( + z.object({ + domain: z.string(), + count: z.number(), + }), + ) + .max(5), + + topTags: z + .array( + z.object({ + name: z.string(), + count: z.number(), + }), + ) + .max(5), + + bookmarksByType: z.object({ + link: z.number(), + text: z.number(), + asset: z.number(), + }), + + bookmarksBySource: z.array( + z.object({ + source: zBookmarkSourceSchema.nullable(), + count: z.number(), + }), + ), + + monthlyActivity: z.array( + z.object({ + month: z.number(), + count: z.number(), + }), + ), + + peakHour: z.number(), + peakDayOfWeek: z.number(), +}); + export const zReaderFontFamilySchema = z.enum(["serif", "sans", "mono"]); export type ZReaderFontFamily = z.infer<typeof zReaderFontFamilySchema>; diff --git a/packages/trpc/models/users.ts b/packages/trpc/models/users.ts index d8a84ffa..b1719200 100644 --- a/packages/trpc/models/users.ts +++ b/packages/trpc/models/users.ts @@ -1,6 +1,6 @@ import { randomBytes } from "crypto"; import { TRPCError } from "@trpc/server"; -import { and, count, desc, eq, gte, sql } from "drizzle-orm"; +import { and, count, desc, eq, gte, lte, sql } from "drizzle-orm"; import invariant from "tiny-invariant"; import { z } from "zod"; @@ -27,6 +27,7 @@ import { zUserSettingsSchema, zUserStatsResponseSchema, zWhoAmIResponseSchema, + zWrappedStatsResponseSchema, } from "@karakeep/shared/types/users"; import { AuthedContext, Context } from ".."; @@ -870,6 +871,308 @@ export class User { }; } + async hasWrapped(): Promise<boolean> { + const [{ numBookmarks }] = await this.ctx.db + .select({ + numBookmarks: count(bookmarks.id), + }) + .from(bookmarks) + .where(eq(bookmarks.userId, this.user.id)); + + return numBookmarks >= 20; + } + + async getWrappedStats( + year: number, + ): Promise<z.infer<typeof zWrappedStatsResponseSchema>> { + const userObj = await this.ctx.db.query.users.findFirst({ + where: eq(users.id, this.user.id), + columns: { + timezone: true, + }, + }); + const userTimezone = userObj?.timezone || "UTC"; + + // Define year range for 2025 + const yearStart = new Date(`${year}-01-01T00:00:00Z`); + const yearEnd = new Date(`${year}-12-31T23:59:59Z`); + + const yearFilter = and( + eq(bookmarks.userId, this.user.id), + gte(bookmarks.createdAt, yearStart), + lte(bookmarks.createdAt, yearEnd), + ); + + const [ + [{ totalBookmarks }], + [{ totalFavorites }], + [{ totalArchived }], + [{ numTags }], + [{ numLists }], + [{ numHighlights }], + firstBookmarkResult, + bookmarksByType, + topDomains, + topTags, + bookmarksBySource, + bookmarkTimestamps, + ] = await Promise.all([ + // Total bookmarks in year + this.ctx.db + .select({ totalBookmarks: count() }) + .from(bookmarks) + .where(yearFilter), + + // Total favorites in year + this.ctx.db + .select({ totalFavorites: count() }) + .from(bookmarks) + .where(and(yearFilter, eq(bookmarks.favourited, true))), + + // Total archived in year + this.ctx.db + .select({ totalArchived: count() }) + .from(bookmarks) + .where(and(yearFilter, eq(bookmarks.archived, true))), + + // Total unique tags (created in year) + this.ctx.db + .select({ numTags: count() }) + .from(bookmarkTags) + .where( + and( + eq(bookmarkTags.userId, this.user.id), + gte(bookmarkTags.createdAt, yearStart), + lte(bookmarkTags.createdAt, yearEnd), + ), + ), + + // Total lists (created in year) + this.ctx.db + .select({ numLists: count() }) + .from(bookmarkLists) + .where( + and( + eq(bookmarkLists.userId, this.user.id), + gte(bookmarkLists.createdAt, yearStart), + lte(bookmarkLists.createdAt, yearEnd), + ), + ), + + // Total highlights (created in year) + this.ctx.db + .select({ numHighlights: count() }) + .from(highlights) + .where( + and( + eq(highlights.userId, this.user.id), + gte(highlights.createdAt, yearStart), + lte(highlights.createdAt, yearEnd), + ), + ), + + // First bookmark of the year + this.ctx.db + .select({ + id: bookmarks.id, + title: bookmarks.title, + createdAt: bookmarks.createdAt, + type: bookmarks.type, + }) + .from(bookmarks) + .where(yearFilter) + .orderBy(bookmarks.createdAt) + .limit(1), + + // Bookmarks by type + this.ctx.db + .select({ + type: bookmarks.type, + count: count(), + }) + .from(bookmarks) + .where(yearFilter) + .groupBy(bookmarks.type), + + // Top 5 domains + this.ctx.db + .select({ + domain: sql<string>`CASE + WHEN ${bookmarkLinks.url} LIKE 'https://%' THEN + CASE + WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') > 0 THEN + SUBSTR(${bookmarkLinks.url}, 9, INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') - 1) + ELSE + SUBSTR(${bookmarkLinks.url}, 9) + END + WHEN ${bookmarkLinks.url} LIKE 'http://%' THEN + CASE + WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') > 0 THEN + SUBSTR(${bookmarkLinks.url}, 8, INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') - 1) + ELSE + SUBSTR(${bookmarkLinks.url}, 8) + END + ELSE + CASE + WHEN INSTR(${bookmarkLinks.url}, '/') > 0 THEN + SUBSTR(${bookmarkLinks.url}, 1, INSTR(${bookmarkLinks.url}, '/') - 1) + ELSE + ${bookmarkLinks.url} + END + END`, + count: count(), + }) + .from(bookmarkLinks) + .innerJoin(bookmarks, eq(bookmarks.id, bookmarkLinks.id)) + .where(yearFilter) + .groupBy( + sql`CASE + WHEN ${bookmarkLinks.url} LIKE 'https://%' THEN + CASE + WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') > 0 THEN + SUBSTR(${bookmarkLinks.url}, 9, INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') - 1) + ELSE + SUBSTR(${bookmarkLinks.url}, 9) + END + WHEN ${bookmarkLinks.url} LIKE 'http://%' THEN + CASE + WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') > 0 THEN + SUBSTR(${bookmarkLinks.url}, 8, INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') - 1) + ELSE + SUBSTR(${bookmarkLinks.url}, 8) + END + ELSE + CASE + WHEN INSTR(${bookmarkLinks.url}, '/') > 0 THEN + SUBSTR(${bookmarkLinks.url}, 1, INSTR(${bookmarkLinks.url}, '/') - 1) + ELSE + ${bookmarkLinks.url} + END + END`, + ) + .orderBy(desc(count())) + .limit(5), + + // Top 5 tags (used in bookmarks created this year) + this.ctx.db + .select({ + name: bookmarkTags.name, + count: count(), + }) + .from(bookmarkTags) + .innerJoin(tagsOnBookmarks, eq(tagsOnBookmarks.tagId, bookmarkTags.id)) + .innerJoin(bookmarks, eq(bookmarks.id, tagsOnBookmarks.bookmarkId)) + .where(yearFilter) + .groupBy(bookmarkTags.name) + .orderBy(desc(count())) + .limit(5), + + // Bookmarks by source + this.ctx.db + .select({ + source: bookmarks.source, + count: count(), + }) + .from(bookmarks) + .where(yearFilter) + .groupBy(bookmarks.source) + .orderBy(desc(count())), + + // All bookmark timestamps in the year for activity calculations + this.ctx.db + .select({ + createdAt: bookmarks.createdAt, + }) + .from(bookmarks) + .where(yearFilter), + ]); + + // Process bookmarks by type + const bookmarkTypeMap = { link: 0, text: 0, asset: 0 }; + bookmarksByType.forEach((item) => { + if (item.type in bookmarkTypeMap) { + bookmarkTypeMap[item.type as keyof typeof bookmarkTypeMap] = item.count; + } + }); + + // Process timestamps with user timezone for hourly/daily activity + const hourCounts = Array.from({ length: 24 }, () => 0); + const dayCounts = Array.from({ length: 7 }, () => 0); + const monthCounts = Array.from({ length: 12 }, () => 0); + const dayCounts_full: Record<string, number> = {}; + + bookmarkTimestamps.forEach(({ createdAt }) => { + if (createdAt) { + const date = new Date(createdAt); + const userDate = new Date( + date.toLocaleString("en-US", { timeZone: userTimezone }), + ); + + const hour = userDate.getHours(); + const day = userDate.getDay(); + const month = userDate.getMonth(); + const dateKey = userDate.toISOString().split("T")[0]; + + hourCounts[hour]++; + dayCounts[day]++; + monthCounts[month]++; + dayCounts_full[dateKey] = (dayCounts_full[dateKey] || 0) + 1; + } + }); + + // Find peak hour and day + const peakHour = hourCounts.indexOf(Math.max(...hourCounts)); + const peakDayOfWeek = dayCounts.indexOf(Math.max(...dayCounts)); + + // Find most active day + let mostActiveDay: { date: string; count: number } | null = null; + if (Object.keys(dayCounts_full).length > 0) { + const sortedDays = Object.entries(dayCounts_full).sort( + ([, a], [, b]) => b - a, + ); + mostActiveDay = { + date: sortedDays[0][0], + count: sortedDays[0][1], + }; + } + + // Monthly activity + const monthlyActivity = Array.from({ length: 12 }, (_, i) => ({ + month: i + 1, + count: monthCounts[i], + })); + + // First bookmark + const firstBookmark = + firstBookmarkResult.length > 0 + ? { + id: firstBookmarkResult[0].id, + title: firstBookmarkResult[0].title, + createdAt: firstBookmarkResult[0].createdAt, + type: firstBookmarkResult[0].type, + } + : null; + + return { + year, + totalBookmarks: totalBookmarks || 0, + totalFavorites: totalFavorites || 0, + totalArchived: totalArchived || 0, + totalHighlights: numHighlights || 0, + totalTags: numTags || 0, + totalLists: numLists || 0, + firstBookmark, + mostActiveDay, + topDomains: topDomains.filter((d) => d.domain && d.domain.length > 0), + topTags, + bookmarksByType: bookmarkTypeMap, + bookmarksBySource, + monthlyActivity, + peakHour, + peakDayOfWeek, + }; + } + asWhoAmI(): z.infer<typeof zWhoAmIResponseSchema> { return { id: this.user.id, diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts index dbfbbc3c..71c23a39 100644 --- a/packages/trpc/routers/users.ts +++ b/packages/trpc/routers/users.ts @@ -9,6 +9,7 @@ import { zUserSettingsSchema, zUserStatsResponseSchema, zWhoAmIResponseSchema, + zWrappedStatsResponseSchema, } from "@karakeep/shared/types/users"; import { @@ -136,6 +137,16 @@ export const usersAppRouter = router({ const user = await User.fromCtx(ctx); return await user.getStats(); }), + wrapped: authedProcedure + .output(zWrappedStatsResponseSchema) + .query(async ({ ctx }) => { + const user = await User.fromCtx(ctx); + return await user.getWrappedStats(2025); + }), + hasWrapped: authedProcedure.output(z.boolean()).query(async ({ ctx }) => { + const user = await User.fromCtx(ctx); + return await user.hasWrapped(); + }), settings: authedProcedure .output(zUserSettingsSchema) .query(async ({ ctx }) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d16ae27..3c9e3c77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -654,6 +654,9 @@ importers: lucide-react: specifier: ^0.501.0 version: 0.501.0(react@19.1.0) + modern-screenshot: + specifier: ^4.6.7 + version: 4.6.7 next: specifier: 15.3.8 version: 15.3.8(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.89.1) @@ -11133,6 +11136,9 @@ packages: mlly@1.7.4: resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==} + modern-screenshot@4.6.7: + resolution: {integrity: sha512-0GhgI6i6le4AhKzCvLYjwEmsP47kTsX45iT5yuAzsLTi/7i3Rjxe8fbH2VjGJLuyOThwsa0CdQAPd4auoEtsZg==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -15444,7 +15450,7 @@ snapshots: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - lru-cache: 11.2.2 + lru-cache: 11.2.4 '@asamuzakjp/dom-selector@6.7.3': dependencies: @@ -15987,7 +15993,7 @@ snapshots: '@babel/traverse': 7.27.4 '@babel/types': 7.27.6 convert-source-map: 2.0.0 - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.1 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -17081,7 +17087,7 @@ snapshots: '@babel/parser': 7.27.5 '@babel/template': 7.27.2 '@babel/types': 7.27.6 - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.1 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -17094,7 +17100,7 @@ snapshots: '@babel/parser': 7.28.0 '@babel/template': 7.27.2 '@babel/types': 7.28.1 - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.1 transitivePeerDependencies: - supports-color @@ -21625,24 +21631,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.28.0 - '@babel/types': 7.28.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.20.7 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.28.1 + '@babel/types': 7.28.5 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.28.0 - '@babel/types': 7.28.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 '@types/babel__traverse@7.20.7': dependencies: - '@babel/types': 7.28.1 + '@babel/types': 7.28.5 '@types/bcryptjs@2.4.6': {} @@ -23688,6 +23694,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.1: + dependencies: + ms: 2.1.3 + debug@4.4.1(supports-color@10.0.0): dependencies: ms: 2.1.3 @@ -25564,7 +25574,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.3 - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -25602,6 +25612,14 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.3 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + https-proxy-agent@7.0.6(supports-color@10.0.0): dependencies: agent-base: 7.1.3 @@ -26197,7 +26215,7 @@ snapshots: decimal.js: 10.6.0 html-encoding-sniffer: 4.0.0 http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6(supports-color@10.0.0) + https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 parse5: 8.0.0 saxes: 6.0.0 @@ -27920,6 +27938,8 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.1 + modern-screenshot@4.6.7: {} + mri@1.2.0: {} mrmime@2.0.1: {} @@ -32107,7 +32127,7 @@ snapshots: vite-node@3.2.4(@types/node@24.10.3)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.41.0)(tsx@4.20.3)(yaml@2.8.0): dependencies: cac: 6.7.14 - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.0.6(@types/node@24.10.3)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.41.0)(tsx@4.20.3)(yaml@2.8.0) @@ -32157,7 +32177,7 @@ snapshots: vite-tsconfig-paths@4.3.2(typescript@5.9.3)(vite@7.0.6(@types/node@24.10.3)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.41.0)(tsx@4.20.3)(yaml@2.8.0)): dependencies: - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.1 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: @@ -32195,7 +32215,7 @@ snapshots: '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.2.1 - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.1 expect-type: 1.2.2 magic-string: 0.30.17 pathe: 2.0.3 |
