feat: SPA polish — wrapup sections, shareable card, webhook instructions, blocked merge
Some checks failed
CI / Check / Test (push) Failing after 6m35s
Some checks failed
CI / Check / Test (push) Failing after 6m35s
This commit is contained in:
50
spa/package-lock.json
generated
50
spa/package-lock.json
generated
@@ -22,6 +22,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"html2canvas-pro": "^2.0.4",
|
||||||
"i18next": "^26.3.1",
|
"i18next": "^26.3.1",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
@@ -3934,6 +3935,15 @@
|
|||||||
"node": "18 || 20 || >=22"
|
"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": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.33",
|
"version": "2.10.33",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
@@ -4347,6 +4357,15 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -5589,6 +5608,19 @@
|
|||||||
"void-elements": "3.1.0"
|
"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": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -7931,6 +7963,15 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/tiny-invariant": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -8272,6 +8313,15 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/validate-npm-package-name": {
|
||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"html2canvas-pro": "^2.0.4",
|
||||||
"i18next": "^26.3.1",
|
"i18next": "^26.3.1",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
|
|||||||
BIN
spa/public/shareable_bg.jpg
Normal file
BIN
spa/public/shareable_bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 595 KiB |
147
spa/src/components/wrapup-share-card.tsx
Normal file
147
spa/src/components/wrapup-share-card.tsx
Normal file
@@ -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<HTMLDivElement>(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<Blob | null>((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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black/80 p-4">
|
||||||
|
<div className="mb-4 flex w-full max-w-sm items-center justify-between">
|
||||||
|
<Button variant="ghost" size="icon" onClick={onClose} className="text-white">
|
||||||
|
<X className="size-5" />
|
||||||
|
</Button>
|
||||||
|
<Button onClick={exportImage} disabled={exporting} size="sm" className="gap-2">
|
||||||
|
{"share" in navigator ? <Share2 className="size-4" /> : <Download className="size-4" />}
|
||||||
|
{exporting ? t("common.saving") : t("wrapup.shareExport")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[75vh] overflow-y-auto rounded-2xl">
|
||||||
|
<div
|
||||||
|
ref={cardRef}
|
||||||
|
className="relative w-[360px] overflow-hidden rounded-2xl"
|
||||||
|
style={{ aspectRatio: "9/16" }}
|
||||||
|
>
|
||||||
|
{/* Layer 1: background image */}
|
||||||
|
<img src={bgSrc} alt="" className="absolute inset-0 size-full object-cover" />
|
||||||
|
|
||||||
|
{/* Layer 2: poster collage */}
|
||||||
|
<div className="absolute inset-0 grid gap-0.5 opacity-30" style={{ gridTemplateColumns: `repeat(${cols}, 1fr)` }}>
|
||||||
|
{posters.map((p, i) => (
|
||||||
|
<div key={i} className="overflow-hidden">
|
||||||
|
<img src={posterUrl(p)} alt="" className="size-full object-cover" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Layer 3: dark blur to make text readable */}
|
||||||
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-[2px]" />
|
||||||
|
|
||||||
|
{/* Layer 4: content */}
|
||||||
|
<div className="relative flex h-full flex-col p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.15em] text-white drop-shadow">
|
||||||
|
{t("wrapup.heroSubtitle")}
|
||||||
|
</p>
|
||||||
|
<p className="text-4xl font-black drop-shadow-lg" style={{ color: "oklch(0.852 0.199 91.936)" }}>
|
||||||
|
{report.date_range.start.slice(0, 4)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center hero — grows to fill middle */}
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-8xl font-black tracking-tight text-white drop-shadow-lg">{report.total_movies}</p>
|
||||||
|
<p className="text-base font-medium text-white/80">{t("wrapup.moviesWatched")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-10">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-3xl font-bold drop-shadow" style={{ color: "oklch(0.852 0.199 91.936)" }}>{report.avg_rating?.toFixed(1) ?? "-"}★</p>
|
||||||
|
<p className="text-xs text-white/60">{t("wrapup.averageRating")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-3xl font-bold text-white drop-shadow">{watchHours}h</p>
|
||||||
|
<p className="text-xs text-white/60">{t("wrapup.watchTime")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom stats */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{topGenre && <StatLine label={t("wrapup.topGenre")} value={topGenre} />}
|
||||||
|
{topDirector && <StatLine label={t("wrapup.topDirectorLabel")} value={topDirector} />}
|
||||||
|
{topActor && <StatLine label={t("wrapup.topActorLabel")} value={topActor} />}
|
||||||
|
{report.busiest_month && <StatLine label={t("wrapup.busiestMonthLabel")} value={report.busiest_month} />}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-2 pt-3">
|
||||||
|
<img src={logoSrc} alt="" className="size-5 rounded" />
|
||||||
|
<p className="text-xs font-medium text-white/40">Movies Diary</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatLine({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<span className="text-[11px] text-white/40">{label}</span>
|
||||||
|
<span className="max-w-[60%] truncate text-right text-sm font-semibold text-white">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -67,9 +67,27 @@ export type GenreStat = {
|
|||||||
avg_rating: number
|
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 = {
|
export type WrapUpReport = {
|
||||||
|
date_range: { start: string; end: string }
|
||||||
total_movies: number
|
total_movies: number
|
||||||
total_watch_time_minutes: number
|
total_watch_time_minutes: number
|
||||||
|
movies_per_month: MonthCount[]
|
||||||
busiest_month?: string
|
busiest_month?: string
|
||||||
busiest_day_of_week?: string
|
busiest_day_of_week?: string
|
||||||
avg_rating?: number
|
avg_rating?: number
|
||||||
@@ -84,6 +102,10 @@ export type WrapUpReport = {
|
|||||||
genre_diversity: number
|
genre_diversity: number
|
||||||
highest_rated_genre?: string
|
highest_rated_genre?: string
|
||||||
lowest_rated_genre?: string
|
lowest_rated_genre?: string
|
||||||
|
top_keywords: KeywordStat[]
|
||||||
|
total_budget_watched?: number
|
||||||
|
avg_budget?: number
|
||||||
|
language_distribution: LangStat[]
|
||||||
oldest_movie?: MovieRef
|
oldest_movie?: MovieRef
|
||||||
newest_movie?: MovieRef
|
newest_movie?: MovieRef
|
||||||
total_rewatches: number
|
total_rewatches: number
|
||||||
|
|||||||
@@ -148,10 +148,9 @@
|
|||||||
"webhookTokens": "Webhook Tokens",
|
"webhookTokens": "Webhook Tokens",
|
||||||
"webhookTokensDesc": "Jellyfin, Plex",
|
"webhookTokensDesc": "Jellyfin, Plex",
|
||||||
"blockedUsers": "Blocked Users",
|
"blockedUsers": "Blocked Users",
|
||||||
|
"blockedUsersAndDomains": "Blocked Users & Domains",
|
||||||
"blockedUsersDesc": "Manage blocked users",
|
"blockedUsersDesc": "Manage blocked users",
|
||||||
"blockedUsersDescAdmin": "Users & domains",
|
"blockedUsersDescAdmin": "Users & domains",
|
||||||
"blockedDomains": "Blocked Domains",
|
|
||||||
"blockedDomainsDesc": "Federation blocks",
|
|
||||||
"logOut": "Log Out",
|
"logOut": "Log Out",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"data": "Data",
|
"data": "Data",
|
||||||
@@ -199,7 +198,19 @@
|
|||||||
"plex": "Plex",
|
"plex": "Plex",
|
||||||
"labelOptional": "Label (optional)",
|
"labelOptional": "Label (optional)",
|
||||||
"labelPlaceholder": "e.g. Living room",
|
"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": {
|
"wrapup": {
|
||||||
"title": "Year Wrap-Up",
|
"title": "Year Wrap-Up",
|
||||||
@@ -237,7 +248,18 @@
|
|||||||
"rewatches": "Rewatches",
|
"rewatches": "Rewatches",
|
||||||
"moviesRewatched": "movies rewatched",
|
"moviesRewatched": "movies rewatched",
|
||||||
"mostRewatched": "Most 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": {
|
"logReview": {
|
||||||
"title": "Log Review",
|
"title": "Log Review",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router"
|
import { createFileRoute, Link } from "@tanstack/react-router"
|
||||||
|
import { useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { ProfileView, ProfileSkeleton } from "@/components/profile-view"
|
import { ProfileView, ProfileSkeleton } from "@/components/profile-view"
|
||||||
import { useAuth } from "@/components/auth-provider"
|
import { useAuth } from "@/components/auth-provider"
|
||||||
@@ -41,7 +42,7 @@ function ProfilePage() {
|
|||||||
<ChevronRight className="size-4 text-muted-foreground" />
|
<ChevronRight className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<WrapUpLink />
|
<WrapUpLinks />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -49,15 +50,22 @@ function ProfilePage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function WrapUpLink() {
|
function wrapupYear(startDate: string): string {
|
||||||
|
return startDate.slice(0, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WrapUpLinks() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { data } = useWrapUps()
|
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 (
|
||||||
<Link to="/wrapup/$id" params={{ id: latest.id }}>
|
<Link to="/wrapup/$id" params={{ id: ready[0].id }}>
|
||||||
<Button variant="outline" className="w-full justify-between">
|
<Button variant="outline" className="w-full justify-between">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Sparkles className="size-4" />
|
<Sparkles className="size-4" />
|
||||||
@@ -68,3 +76,24 @@ function WrapUpLink() {
|
|||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Button variant="outline" className="w-full justify-between" onClick={() => setExpanded(!expanded)}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Sparkles className="size-4" />
|
||||||
|
{t("profile.yearInReview")}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className={`size-4 text-muted-foreground transition-transform ${expanded ? "rotate-180" : ""}`} />
|
||||||
|
</Button>
|
||||||
|
{expanded && ready.map((w) => (
|
||||||
|
<Link key={w.id} to="/wrapup/$id" params={{ id: w.id }}>
|
||||||
|
<Button variant="ghost" size="sm" className="w-full justify-between">
|
||||||
|
<span>{wrapupYear(w.start_date)}</span>
|
||||||
|
<ChevronRight className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Download,
|
Download,
|
||||||
Globe,
|
|
||||||
Key,
|
Key,
|
||||||
LogOut,
|
LogOut,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -69,22 +68,13 @@ function SettingsPage() {
|
|||||||
|
|
||||||
const social: SettingsItem[] = [
|
const social: SettingsItem[] = [
|
||||||
{
|
{
|
||||||
label: t("settings.blockedUsers"),
|
label: isAdmin ? t("settings.blockedUsersAndDomains") : t("settings.blockedUsers"),
|
||||||
description: isAdmin ? t("settings.blockedUsersDescAdmin") : t("settings.blockedUsersDesc"),
|
description: isAdmin ? t("settings.blockedUsersDescAdmin") : t("settings.blockedUsersDesc"),
|
||||||
to: "/settings/blocked",
|
to: "/settings/blocked",
|
||||||
icon: <ShieldBan className="size-4" />,
|
icon: <ShieldBan className="size-4" />,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
if (isAdmin) {
|
|
||||||
social.push({
|
|
||||||
label: t("settings.blockedDomains"),
|
|
||||||
description: t("settings.blockedDomainsDesc"),
|
|
||||||
to: "/settings/blocked",
|
|
||||||
icon: <Globe className="size-4" />,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout()
|
logout()
|
||||||
navigate({ to: "/login" })
|
navigate({ to: "/login" })
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"
|
|||||||
import { ArrowLeft, Key, Plus, Trash2 } from "lucide-react"
|
import { ArrowLeft, Key, Plus, Trash2 } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
useGenerateToken,
|
useGenerateToken,
|
||||||
useDeleteToken,
|
useDeleteToken,
|
||||||
} from "@/hooks/use-webhooks"
|
} from "@/hooks/use-webhooks"
|
||||||
|
import { API_URL } from "@/lib/api/client"
|
||||||
|
|
||||||
export const Route = createFileRoute("/_app/settings/webhooks")({
|
export const Route = createFileRoute("/_app/settings/webhooks")({
|
||||||
component: WebhooksPage,
|
component: WebhooksPage,
|
||||||
@@ -104,6 +106,8 @@ function WebhooksPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SetupInstructions />
|
||||||
|
|
||||||
<Drawer open={open} onOpenChange={setOpen}>
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
@@ -143,3 +147,53 @@ function WebhooksPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SetupInstructions() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const baseUrl = API_URL || window.location.origin
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="px-1 text-xs font-medium text-muted-foreground">{t("webhooks.setup")}</p>
|
||||||
|
|
||||||
|
<Card size="sm">
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">{t("webhooks.jellyfin")}</p>
|
||||||
|
<div className="rounded-lg bg-muted p-2">
|
||||||
|
<p className="text-[10px] text-muted-foreground">{t("webhooks.webhookUrl")}</p>
|
||||||
|
<code className="break-all text-xs">{baseUrl}/api/v1/webhooks/jellyfin</code>
|
||||||
|
</div>
|
||||||
|
<details className="text-xs text-muted-foreground">
|
||||||
|
<summary className="cursor-pointer font-medium text-foreground">{t("webhooks.setupSteps")}</summary>
|
||||||
|
<ol className="mt-2 list-inside list-decimal space-y-1 pl-1">
|
||||||
|
<li>{t("webhooks.jellyfinStep1")}</li>
|
||||||
|
<li>{t("webhooks.jellyfinStep2")}</li>
|
||||||
|
<li>{t("webhooks.jellyfinStep3")}</li>
|
||||||
|
<li>{t("webhooks.jellyfinStep4")}</li>
|
||||||
|
<li>{t("webhooks.jellyfinStep5")}</li>
|
||||||
|
<li>{t("webhooks.jellyfinStep6")}</li>
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card size="sm">
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">{t("webhooks.plex")}</p>
|
||||||
|
<div className="rounded-lg bg-muted p-2">
|
||||||
|
<p className="text-[10px] text-muted-foreground">{t("webhooks.webhookUrl")}</p>
|
||||||
|
<code className="break-all text-xs">{baseUrl}/api/v1/webhooks/plex?token=YOUR_TOKEN</code>
|
||||||
|
</div>
|
||||||
|
<details className="text-xs text-muted-foreground">
|
||||||
|
<summary className="cursor-pointer font-medium text-foreground">{t("webhooks.setupSteps")}</summary>
|
||||||
|
<ol className="mt-2 list-inside list-decimal space-y-1 pl-1">
|
||||||
|
<li>{t("webhooks.plexStep1")}</li>
|
||||||
|
<li>{t("webhooks.plexStep2")}</li>
|
||||||
|
<li>{t("webhooks.plexStep3")}</li>
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router"
|
import { createFileRoute, Link } from "@tanstack/react-router"
|
||||||
|
import { lazy, Suspense, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
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 { 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 { Badge } from "@/components/ui/badge"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
@@ -20,6 +24,8 @@ function WrapUpReportPage() {
|
|||||||
const { id } = Route.useParams()
|
const { id } = Route.useParams()
|
||||||
const { data: report, isPending } = useWrapUpReport(id)
|
const { data: report, isPending } = useWrapUpReport(id)
|
||||||
|
|
||||||
|
const [showShare, setShowShare] = useState(false)
|
||||||
|
|
||||||
if (isPending) return <ReportSkeleton />
|
if (isPending) return <ReportSkeleton />
|
||||||
if (!report) return null
|
if (!report) return null
|
||||||
|
|
||||||
@@ -27,7 +33,18 @@ function WrapUpReportPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-4">
|
<div className="space-y-4 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<BackButton />
|
<BackButton />
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setShowShare(true)}>
|
||||||
|
<Share2 className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showShare && (
|
||||||
|
<Suspense>
|
||||||
|
<WrapUpShareCard report={report} onClose={() => setShowShare(false)} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<Card>
|
<Card>
|
||||||
@@ -118,6 +135,96 @@ function WrapUpReportPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Monthly Activity */}
|
||||||
|
{report.movies_per_month.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<BarChart3 className="size-4" /> {t("wrapup.monthlyActivity")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{(() => {
|
||||||
|
const max = Math.max(...report.movies_per_month.map((x) => x.count))
|
||||||
|
const barHeight = 96
|
||||||
|
return (
|
||||||
|
<div className="flex items-end gap-1">
|
||||||
|
{report.movies_per_month.map((m) => {
|
||||||
|
const h = max > 0 ? Math.max((m.count / max) * barHeight, 4) : 4
|
||||||
|
return (
|
||||||
|
<div key={m.year_month} className="flex flex-1 flex-col items-center gap-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground">{m.count}</span>
|
||||||
|
<div className="w-full rounded-t bg-primary" style={{ height: h }} />
|
||||||
|
<span className="text-[9px] text-muted-foreground">{m.year_month.slice(5)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Keywords */}
|
||||||
|
{report.top_keywords.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<Hash className="size-4" /> {t("wrapup.keywords")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{report.top_keywords
|
||||||
|
.filter((k) => !k.keyword.includes("creditsstinger"))
|
||||||
|
.slice(0, 15)
|
||||||
|
.map((k) => (
|
||||||
|
<Badge key={k.keyword} variant="secondary" className="text-xs">
|
||||||
|
{k.keyword} <span className="ml-1 opacity-60">{k.count}</span>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<div className={bothVisible ? "grid grid-cols-2 gap-3" : ""}>
|
||||||
|
{hasBudget && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-4 text-center">
|
||||||
|
<DollarSign className="mx-auto mb-1 size-4 text-muted-foreground" />
|
||||||
|
<p className="text-lg font-bold">${Math.round(report.total_budget_watched! / 1_000_000)}M</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{t("wrapup.totalBudget")}</p>
|
||||||
|
{report.avg_budget != null && (
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||||
|
{t("wrapup.avgBudget", { amount: `$${Math.round(report.avg_budget / 1_000_000)}M` })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{hasLang && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-4 text-center">
|
||||||
|
<Globe className="mx-auto mb-1 size-4 text-muted-foreground" />
|
||||||
|
<p className="text-lg font-bold">{report.language_distribution.length}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{t("wrapup.languages")}</p>
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||||
|
{report.language_distribution.slice(0, 3).map((l) => l.language.toUpperCase()).join(", ")}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)})()}
|
||||||
|
|
||||||
{/* Highlights */}
|
{/* Highlights */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -159,7 +266,7 @@ function WrapUpReportPage() {
|
|||||||
{report.poster_paths.length > 0 && (
|
{report.poster_paths.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm">{t("wrapup.posterMosaic")}</CardTitle>
|
<CardTitle className="text-sm">{t("wrapup.allMovies", { count: report.poster_paths.length })}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-5 gap-1">
|
<div className="grid grid-cols-5 gap-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user