diff --git a/spa/package-lock.json b/spa/package-lock.json index dfae3d2..7c7b1c2 100644 --- a/spa/package-lock.json +++ b/spa/package-lock.json @@ -22,6 +22,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.4.0", "embla-carousel-react": "^8.6.0", + "html2canvas-pro": "^2.0.4", "i18next": "^26.3.1", "input-otp": "^1.4.2", "lucide-react": "^1.17.0", @@ -3934,6 +3935,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.33", "license": "Apache-2.0", @@ -4347,6 +4357,15 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/cssesc": { "version": "3.0.0", "license": "MIT", @@ -5589,6 +5608,19 @@ "void-elements": "3.1.0" } }, + "node_modules/html2canvas-pro": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-2.0.4.tgz", + "integrity": "sha512-tfL8XNvuITvYQJKgAx4bvANauuLKc88C+ZSZt7HZJveqQBWjBDtkqs/It06UzlqbM+sSq7Cv45rFbuUxOFgmow==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "license": "MIT", @@ -7931,6 +7963,15 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "license": "MIT" @@ -8272,6 +8313,15 @@ "version": "1.0.2", "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/validate-npm-package-name": { "version": "7.0.2", "license": "ISC", diff --git a/spa/package.json b/spa/package.json index c8363d9..618a652 100644 --- a/spa/package.json +++ b/spa/package.json @@ -26,6 +26,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.4.0", "embla-carousel-react": "^8.6.0", + "html2canvas-pro": "^2.0.4", "i18next": "^26.3.1", "input-otp": "^1.4.2", "lucide-react": "^1.17.0", diff --git a/spa/public/shareable_bg.jpg b/spa/public/shareable_bg.jpg new file mode 100644 index 0000000..89ca518 Binary files /dev/null and b/spa/public/shareable_bg.jpg differ diff --git a/spa/src/components/wrapup-share-card.tsx b/spa/src/components/wrapup-share-card.tsx new file mode 100644 index 0000000..3b37e3f --- /dev/null +++ b/spa/src/components/wrapup-share-card.tsx @@ -0,0 +1,147 @@ +import { useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import { Download, Share2, X } from "lucide-react" +import html2canvas from "html2canvas-pro" +import { Button } from "@/components/ui/button" +import { posterUrl } from "@/lib/api/client" +import type { WrapUpReport } from "@/lib/api/wrapup" +const logoSrc = `${import.meta.env.BASE_URL}logo.webp` +const bgSrc = `${import.meta.env.BASE_URL}shareable_bg.jpg` + +type Props = { + report: WrapUpReport + onClose: () => void +} + +export function WrapUpShareCard({ report, onClose }: Props) { + const { t } = useTranslation() + const cardRef = useRef(null) + const [exporting, setExporting] = useState(false) + + const watchHours = Math.round(report.total_watch_time_minutes / 60) + const topGenre = report.top_genres[0]?.genre + const topDirector = report.top_directors[0]?.name + const topActor = report.top_actors[0]?.name + const cols = 5 + const rows = 3 + const posters = report.poster_paths.slice(0, cols * rows) + + async function exportImage() { + if (!cardRef.current) return + setExporting(true) + try { + const canvas = await html2canvas(cardRef.current, { + scale: 2, + useCORS: true, + backgroundColor: null, + }) + const blob = await new Promise((r) => canvas.toBlob(r, "image/png")) + if (!blob) return + + const file = new File([blob], "wrapup.png", { type: "image/png" }) + if (navigator.share && navigator.canShare?.({ files: [file] })) { + await navigator.share({ files: [file] }) + } else { + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = "year-in-review.png" + a.click() + URL.revokeObjectURL(url) + } + } finally { + setExporting(false) + } + } + + return ( +
+
+ + +
+ +
+
+ {/* Layer 1: background image */} + + + {/* Layer 2: poster collage */} +
+ {posters.map((p, i) => ( +
+ +
+ ))} +
+ + {/* Layer 3: dark blur to make text readable */} +
+ + {/* Layer 4: content */} +
+ {/* Header */} +
+

+ {t("wrapup.heroSubtitle")} +

+

+ {report.date_range.start.slice(0, 4)} +

+
+ + {/* Center hero — grows to fill middle */} +
+
+

{report.total_movies}

+

{t("wrapup.moviesWatched")}

+
+ +
+
+

{report.avg_rating?.toFixed(1) ?? "-"}★

+

{t("wrapup.averageRating")}

+
+
+

{watchHours}h

+

{t("wrapup.watchTime")}

+
+
+
+ + {/* Bottom stats */} +
+ {topGenre && } + {topDirector && } + {topActor && } + {report.busiest_month && } + +
+ +

Movies Diary

+
+
+
+
+
+
+ ) +} + +function StatLine({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ) +} diff --git a/spa/src/lib/api/wrapup.ts b/spa/src/lib/api/wrapup.ts index c67edf7..cb6eda2 100644 --- a/spa/src/lib/api/wrapup.ts +++ b/spa/src/lib/api/wrapup.ts @@ -67,9 +67,27 @@ export type GenreStat = { avg_rating: number } +export type MonthCount = { + year_month: string + label: string + count: number +} + +export type KeywordStat = { + keyword: string + count: number +} + +export type LangStat = { + language: string + count: number +} + export type WrapUpReport = { + date_range: { start: string; end: string } total_movies: number total_watch_time_minutes: number + movies_per_month: MonthCount[] busiest_month?: string busiest_day_of_week?: string avg_rating?: number @@ -84,6 +102,10 @@ export type WrapUpReport = { genre_diversity: number highest_rated_genre?: string lowest_rated_genre?: string + top_keywords: KeywordStat[] + total_budget_watched?: number + avg_budget?: number + language_distribution: LangStat[] oldest_movie?: MovieRef newest_movie?: MovieRef total_rewatches: number diff --git a/spa/src/locales/en.json b/spa/src/locales/en.json index 658a3f9..c8c9a37 100644 --- a/spa/src/locales/en.json +++ b/spa/src/locales/en.json @@ -148,10 +148,9 @@ "webhookTokens": "Webhook Tokens", "webhookTokensDesc": "Jellyfin, Plex", "blockedUsers": "Blocked Users", + "blockedUsersAndDomains": "Blocked Users & Domains", "blockedUsersDesc": "Manage blocked users", "blockedUsersDescAdmin": "Users & domains", - "blockedDomains": "Blocked Domains", - "blockedDomainsDesc": "Federation blocks", "logOut": "Log Out", "account": "Account", "data": "Data", @@ -199,7 +198,19 @@ "plex": "Plex", "labelOptional": "Label (optional)", "labelPlaceholder": "e.g. Living room", - "copied": "Webhook URL copied to clipboard" + "copied": "Webhook URL copied to clipboard", + "setup": "Setup", + "webhookUrl": "Webhook URL", + "setupSteps": "Setup steps", + "jellyfinStep1": "Install the Webhook plugin (Dashboard → Plugins → Catalog)", + "jellyfinStep2": "Add a Generic Destination with the URL above", + "jellyfinStep3": "Add header: Authorization = Bearer YOUR_TOKEN", + "jellyfinStep4": "Check \"Send All Properties\"", + "jellyfinStep5": "Notification Type: Playback Stop only", + "jellyfinStep6": "Item Type: Movies only", + "plexStep1": "Go to Settings → Webhooks in your Plex server", + "plexStep2": "Add the URL above, replacing YOUR_TOKEN with your generated token", + "plexStep3": "Plex sends scrobble events when a movie is watched to 90%+ (requires Plex Pass)" }, "wrapup": { "title": "Year Wrap-Up", @@ -237,7 +248,18 @@ "rewatches": "Rewatches", "moviesRewatched": "movies rewatched", "mostRewatched": "Most rewatched:", - "posterMosaic": "Your Year in Posters" + "monthlyActivity": "Monthly Activity", + "keywords": "Keywords", + "totalBudget": "total budget watched", + "avgBudget": "avg {{amount}} per film", + "languages": "languages", + "shareExport": "Share", + "watchTime": "watch time", + "topGenre": "Top Genre", + "topDirectorLabel": "Top Director", + "topActorLabel": "Top Actor", + "busiestMonthLabel": "Busiest Month", + "allMovies": "All Movies ({{count}})" }, "logReview": { "title": "Log Review", diff --git a/spa/src/routes/_app/profile.tsx b/spa/src/routes/_app/profile.tsx index fd4e532..2e21239 100644 --- a/spa/src/routes/_app/profile.tsx +++ b/spa/src/routes/_app/profile.tsx @@ -1,6 +1,7 @@ import { createFileRoute, Link } from "@tanstack/react-router" +import { useState } from "react" import { useTranslation } from "react-i18next" -import { ChevronRight, Settings, Sparkles } from "lucide-react" +import { ChevronDown, ChevronRight, Settings, Sparkles } from "lucide-react" import { Button } from "@/components/ui/button" import { ProfileView, ProfileSkeleton } from "@/components/profile-view" import { useAuth } from "@/components/auth-provider" @@ -41,7 +42,7 @@ function ProfilePage() { - + } /> @@ -49,22 +50,50 @@ function ProfilePage() { ) } -function WrapUpLink() { +function wrapupYear(startDate: string): string { + return startDate.slice(0, 4) +} + +function WrapUpLinks() { const { t } = useTranslation() const { data } = useWrapUps() - const latest = data?.items?.find((w) => w.status === "Ready") + const ready = (data?.items?.filter((w) => w.status === "Ready") ?? []) + .sort((a, b) => b.start_date.localeCompare(a.start_date)) + const [expanded, setExpanded] = useState(false) - if (!latest) return null + if (!ready.length) return null + + if (ready.length === 1) { + return ( + + + + ) + } return ( - - - + {expanded && ready.map((w) => ( + + + + ))} +
) } diff --git a/spa/src/routes/_app/settings/index.tsx b/spa/src/routes/_app/settings/index.tsx index 4f3e3ba..06512b5 100644 --- a/spa/src/routes/_app/settings/index.tsx +++ b/spa/src/routes/_app/settings/index.tsx @@ -5,7 +5,6 @@ import { ArrowLeft, ChevronRight, Download, - Globe, Key, LogOut, RefreshCw, @@ -69,22 +68,13 @@ function SettingsPage() { const social: SettingsItem[] = [ { - label: t("settings.blockedUsers"), + label: isAdmin ? t("settings.blockedUsersAndDomains") : t("settings.blockedUsers"), description: isAdmin ? t("settings.blockedUsersDescAdmin") : t("settings.blockedUsersDesc"), to: "/settings/blocked", icon: , }, ] - if (isAdmin) { - social.push({ - label: t("settings.blockedDomains"), - description: t("settings.blockedDomainsDesc"), - to: "/settings/blocked", - icon: , - }) - } - const handleLogout = () => { logout() navigate({ to: "/login" }) diff --git a/spa/src/routes/_app/settings/webhooks.tsx b/spa/src/routes/_app/settings/webhooks.tsx index df71aac..eb7f5e3 100644 --- a/spa/src/routes/_app/settings/webhooks.tsx +++ b/spa/src/routes/_app/settings/webhooks.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next" import { ArrowLeft, Key, Plus, Trash2 } from "lucide-react" import { toast } from "sonner" import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { @@ -26,6 +27,7 @@ import { useGenerateToken, useDeleteToken, } from "@/hooks/use-webhooks" +import { API_URL } from "@/lib/api/client" export const Route = createFileRoute("/_app/settings/webhooks")({ component: WebhooksPage, @@ -104,6 +106,8 @@ function WebhooksPage() { )} + + @@ -143,3 +147,53 @@ function WebhooksPage() { ) } + +function SetupInstructions() { + const { t } = useTranslation() + const baseUrl = API_URL || window.location.origin + + return ( +
+

{t("webhooks.setup")}

+ + + +

{t("webhooks.jellyfin")}

+
+

{t("webhooks.webhookUrl")}

+ {baseUrl}/api/v1/webhooks/jellyfin +
+
+ {t("webhooks.setupSteps")} +
    +
  1. {t("webhooks.jellyfinStep1")}
  2. +
  3. {t("webhooks.jellyfinStep2")}
  4. +
  5. {t("webhooks.jellyfinStep3")}
  6. +
  7. {t("webhooks.jellyfinStep4")}
  8. +
  9. {t("webhooks.jellyfinStep5")}
  10. +
  11. {t("webhooks.jellyfinStep6")}
  12. +
+
+
+
+ + + +

{t("webhooks.plex")}

+
+

{t("webhooks.webhookUrl")}

+ {baseUrl}/api/v1/webhooks/plex?token=YOUR_TOKEN +
+
+ {t("webhooks.setupSteps")} +
    +
  1. {t("webhooks.plexStep1")}
  2. +
  3. {t("webhooks.plexStep2")}
  4. +
  5. {t("webhooks.plexStep3")}
  6. +
+
+
+
+
+ ) +} diff --git a/spa/src/routes/_app/wrapup.$id.tsx b/spa/src/routes/_app/wrapup.$id.tsx index 827c51d..7bb930a 100644 --- a/spa/src/routes/_app/wrapup.$id.tsx +++ b/spa/src/routes/_app/wrapup.$id.tsx @@ -1,7 +1,11 @@ import { createFileRoute, Link } from "@tanstack/react-router" +import { lazy, Suspense, useState } from "react" import { useTranslation } from "react-i18next" -import { Star, Users } from "lucide-react" +import { BarChart3, DollarSign, Globe, Hash, Share2, Star, Users } from "lucide-react" import { BackButton } from "@/components/back-button" +import { Button } from "@/components/ui/button" + +const WrapUpShareCard = lazy(() => import("@/components/wrapup-share-card").then((m) => ({ default: m.WrapUpShareCard }))) import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Skeleton } from "@/components/ui/skeleton" @@ -20,6 +24,8 @@ function WrapUpReportPage() { const { id } = Route.useParams() const { data: report, isPending } = useWrapUpReport(id) + const [showShare, setShowShare] = useState(false) + if (isPending) return if (!report) return null @@ -27,7 +33,18 @@ function WrapUpReportPage() { return (
- +
+ + +
+ + {showShare && ( + + setShowShare(false)} /> + + )} {/* Hero */} @@ -118,6 +135,96 @@ function WrapUpReportPage() { )} + {/* Monthly Activity */} + {report.movies_per_month.length > 0 && ( + + + + {t("wrapup.monthlyActivity")} + + + + {(() => { + const max = Math.max(...report.movies_per_month.map((x) => x.count)) + const barHeight = 96 + return ( +
+ {report.movies_per_month.map((m) => { + const h = max > 0 ? Math.max((m.count / max) * barHeight, 4) : 4 + return ( +
+ {m.count} +
+ {m.year_month.slice(5)} +
+ ) + })} +
+ ) + })()} + + + )} + + {/* Keywords */} + {report.top_keywords.length > 0 && ( + + + + {t("wrapup.keywords")} + + + +
+ {report.top_keywords + .filter((k) => !k.keyword.includes("creditsstinger")) + .slice(0, 15) + .map((k) => ( + + {k.keyword} {k.count} + + ))} +
+
+
+ )} + + {/* Budget & Language */} + {(report.total_budget_watched != null || report.language_distribution.length > 1) && (() => { + const hasBudget = report.total_budget_watched != null + const hasLang = report.language_distribution.length > 1 + const bothVisible = hasBudget && hasLang + return ( +
+ {hasBudget && ( + + + +

${Math.round(report.total_budget_watched! / 1_000_000)}M

+

{t("wrapup.totalBudget")}

+ {report.avg_budget != null && ( +

+ {t("wrapup.avgBudget", { amount: `$${Math.round(report.avg_budget / 1_000_000)}M` })} +

+ )} +
+
+ )} + {hasLang && ( + + + +

{report.language_distribution.length}

+

{t("wrapup.languages")}

+

+ {report.language_distribution.slice(0, 3).map((l) => l.language.toUpperCase()).join(", ")} +

+
+
+ )} +
+ )})()} + {/* Highlights */} @@ -159,7 +266,7 @@ function WrapUpReportPage() { {report.poster_paths.length > 0 && ( - {t("wrapup.posterMosaic")} + {t("wrapup.allMovies", { count: report.poster_paths.length })}