diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-12-30 12:52:50 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-12-30 10:52:50 +0000 |
| commit | a0b4a26ad398137e13c35f3fe0dad99154537d91 (patch) | |
| tree | 6e7f7b8acb7725717fdbb06ad262a122cdd2dfd5 /apps/web/components/wrapped | |
| parent | 7ab7db8e48360417498643eec2384b0fcb7fbdfb (diff) | |
| download | karakeep-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.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 |
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"> + “{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"; |
