aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/app/dashboard/layout.tsx20
-rw-r--r--apps/web/app/dashboard/wrapped/page.tsx24
-rw-r--r--apps/web/app/settings/stats/page.tsx41
-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
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json49
-rw-r--r--apps/web/package.json1
-rw-r--r--packages/shared/types/users.ts67
-rw-r--r--packages/trpc/models/users.ts305
-rw-r--r--packages/trpc/routers/users.ts11
-rw-r--r--pnpm-lock.yaml50
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">
+ &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";
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