From a0b4a26ad398137e13c35f3fe0dad99154537d91 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Tue, 30 Dec 2025 12:52:50 +0200 Subject: feat: 2025 wrapped (#2322) * feat: 2025 wrapped * don't add wrapped for new users --- apps/web/app/dashboard/layout.tsx | 20 +- apps/web/app/dashboard/wrapped/page.tsx | 24 ++ apps/web/app/settings/stats/page.tsx | 41 ++- apps/web/components/wrapped/ShareButton.tsx | 92 ++++++ apps/web/components/wrapped/WrappedContent.tsx | 390 +++++++++++++++++++++++++ apps/web/components/wrapped/WrappedModal.tsx | 85 ++++++ apps/web/components/wrapped/index.ts | 3 + apps/web/lib/i18n/locales/en/translation.json | 49 ++++ apps/web/package.json | 1 + 9 files changed, 690 insertions(+), 15 deletions(-) create mode 100644 apps/web/app/dashboard/wrapped/page.tsx create mode 100644 apps/web/components/wrapped/ShareButton.tsx create mode 100644 apps/web/components/wrapped/WrappedContent.tsx create mode 100644 apps/web/components/wrapped/WrappedModal.tsx create mode 100644 apps/web/components/wrapped/index.ts (limited to 'apps') 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: , path: "/dashboard/archive", }, + // Only show wrapped if user has at least 20 bookmarks + showWrapped.data + ? [ + { + name: t("wrapped.button"), + icon: , + path: "/dashboard/wrapped", + }, + ] + : [], ].flat(); const mobileSidebar = (t: TFunction) => [ - ...items(t), + ...items(t).filter((item) => item.path !== "/dashboard/wrapped"), { name: t("lists.all_lists"), icon: , 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 ; +} 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 (
-
-

- {t("settings.stats.usage_statistics")} -

-

- Insights into your bookmarking habits and collection - {userSettings?.timezone && userSettings.timezone !== "UTC" && ( - - Times shown in {userSettings.timezone} timezone - - )} -

+
+
+

+ {t("settings.stats.usage_statistics")} +

+

+ Insights into your bookmarking habits and collection + {userSettings?.timezone && userSettings.timezone !== "UTC" && ( + + Times shown in {userSettings.timezone} timezone + + )} +

+
+ {hasWrapped && ( + + )}
+ setShowWrapped(false)} /> + {/* Overview Stats */}
; + 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 ( + + ); +} 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; +type BookmarkSource = z.infer; + +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 = { + 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 ; + case "web": + return ; + case "extension": + return ; + case "cli": + return ; + case "mobile": + return ; + case "singlefile": + return ; + case "rss": + return ; + case "import": + return ; + default: + return ; + } +} + +export const WrappedContent = forwardRef( + ({ stats, userName }, ref) => { + const maxMonthlyCount = Math.max( + ...stats.monthlyActivity.map((m) => m.count), + ); + + return ( +
+
+ {/* Header */} +
+
+

+ Your {stats.year} Wrapped +

+

+ A Year in Karakeep +

+ {userName && ( +

{userName}

+ )} +
+
+ +
+ +

You saved

+

+ {stats.totalBookmarks} +

+

+ {stats.totalBookmarks === 1 ? "item" : "items"} this year +

+
+ {/* First Bookmark */} + {stats.firstBookmark && ( + +
+
+ +

+ First Bookmark of {stats.year} +

+
+
+

+ {new Date( + stats.firstBookmark.createdAt, + ).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + })} +

+ {stats.firstBookmark.title && ( +

+ “{stats.firstBookmark.title}” +

+ )} +
+
+
+ )} + + {/* Activity + Peak */} + +

+ + Activity Highlights +

+
+ {stats.mostActiveDay && ( +
+

Most Active Day

+

+ {new Date(stats.mostActiveDay.date).toLocaleDateString( + "en-US", + { + month: "short", + day: "numeric", + }, + )} +

+

+ {stats.mostActiveDay.count}{" "} + {stats.mostActiveDay.count === 1 ? "save" : "saves"} +

+
+ )} +
+
+

Peak Hour

+

+ {stats.peakHour === 0 + ? "12 AM" + : stats.peakHour < 12 + ? `${stats.peakHour} AM` + : stats.peakHour === 12 + ? "12 PM" + : `${stats.peakHour - 12} PM`} +

+
+
+

Peak Day

+

+ {dayNames[stats.peakDayOfWeek]} +

+
+
+
+
+ + {/* Top Lists */} + {(stats.topDomains.length > 0 || stats.topTags.length > 0) && ( + +

+ Top Lists +

+
+ {stats.topDomains.length > 0 && ( +
+

+ + Sites +

+
+ {stats.topDomains.map((domain, index) => ( +
+
+
+ {index + 1} +
+ + {domain.domain} + +
+ + {domain.count} + +
+ ))} +
+
+ )} + {stats.topTags.length > 0 && ( +
+

+ + Tags +

+
+ {stats.topTags.map((tag, index) => ( +
+
+
+ {index + 1} +
+ {tag.name} +
+ + {tag.count} + +
+ ))} +
+
+ )} +
+
+ )} + + {/* Bookmarks by Source */} + {stats.bookmarksBySource.length > 0 && ( + +

+ How You Save +

+
+ {stats.bookmarksBySource.map((source) => ( +
+
+ {getSourceIcon(source.source, "h-4 w-4")} + {formatSourceName(source.source)} +
+ + {source.count} + +
+ ))} +
+
+ )} + + {/* Monthly Activity */} + +

+ + Your Year in Saves +

+
+ {stats.monthlyActivity.map((month) => ( +
+
+ {monthNames[month.month - 1]} +
+
+
0 ? (month.count / maxMonthlyCount) * 100 : 0}%`, + }} + /> +
+
+ {month.count} +
+
+ ))} +
+ + + {/* Summary Stats */} + +
+
+ +

+ {stats.totalFavorites} +

+

Favorites

+
+
+ +

{stats.totalTags}

+

Tags Created

+
+
+ +

+ {stats.totalHighlights} +

+

Highlights

+
+
+
+
+ +

+ {stats.bookmarksByType.link} +

+

Links

+
+
+ +

+ {stats.bookmarksByType.text} +

+

Notes

+
+
+ +

+ {stats.bookmarksByType.asset} +

+

Assets

+
+
+
+
+ + {/* Footer */} +
+ Made with Karakeep +
+
+
+ ); + }, +); + +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(null); + const { data: stats, isLoading } = api.users.wrapped.useQuery(undefined, { + enabled: open, + }); + const { data: whoami } = api.users.whoami.useQuery(undefined, { + enabled: open, + }); + + return ( + + + + + Your 2025 Wrapped + +
+ {/* Share button overlay */} + {stats && !isLoading && } + {/* Close button overlay */} + +
+ + {/* Content */} + {isLoading ? ( +
+
+ +

Loading your Wrapped...

+
+
+ ) : stats ? ( + + ) : ( +
+
+

Failed to load your Wrapped stats

+ +
+
+ )} +
+
+ ); +} 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", -- cgit v1.2.3-70-g09d2