aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components/wrapped
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-12-30 12:52:50 +0200
committerGitHub <noreply@github.com>2025-12-30 10:52:50 +0000
commita0b4a26ad398137e13c35f3fe0dad99154537d91 (patch)
tree6e7f7b8acb7725717fdbb06ad262a122cdd2dfd5 /apps/web/components/wrapped
parent7ab7db8e48360417498643eec2384b0fcb7fbdfb (diff)
downloadkarakeep-a0b4a26ad398137e13c35f3fe0dad99154537d91.tar.zst
feat: 2025 wrapped (#2322)
* feat: 2025 wrapped * don't add wrapped for new users
Diffstat (limited to 'apps/web/components/wrapped')
-rw-r--r--apps/web/components/wrapped/ShareButton.tsx92
-rw-r--r--apps/web/components/wrapped/WrappedContent.tsx390
-rw-r--r--apps/web/components/wrapped/WrappedModal.tsx85
-rw-r--r--apps/web/components/wrapped/index.ts3
4 files changed, 570 insertions, 0 deletions
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">
+ &ldquo;{stats.firstBookmark.title}&rdquo;
+ </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";