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:
@@ -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() {
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</Link>
|
||||
<WrapUpLink />
|
||||
<WrapUpLinks />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -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 (
|
||||
<Link to="/wrapup/$id" params={{ id: ready[0].id }}>
|
||||
<Button variant="outline" className="w-full justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Sparkles className="size-4" />
|
||||
{t("profile.yearInReview")}
|
||||
</span>
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to="/wrapup/$id" params={{ id: latest.id }}>
|
||||
<Button variant="outline" className="w-full justify-between">
|
||||
<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>
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
<ChevronDown className={`size-4 text-muted-foreground transition-transform ${expanded ? "rotate-180" : ""}`} />
|
||||
</Button>
|
||||
</Link>
|
||||
{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,
|
||||
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: <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 = () => {
|
||||
logout()
|
||||
navigate({ to: "/login" })
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SetupInstructions />
|
||||
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
@@ -143,3 +147,53 @@ function WebhooksPage() {
|
||||
</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 { 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 <ReportSkeleton />
|
||||
if (!report) return null
|
||||
|
||||
@@ -27,7 +33,18 @@ function WrapUpReportPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<BackButton />
|
||||
<div className="flex items-center justify-between">
|
||||
<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 */}
|
||||
<Card>
|
||||
@@ -118,6 +135,96 @@ function WrapUpReportPage() {
|
||||
</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 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -159,7 +266,7 @@ function WrapUpReportPage() {
|
||||
{report.poster_paths.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{t("wrapup.posterMosaic")}</CardTitle>
|
||||
<CardTitle className="text-sm">{t("wrapup.allMovies", { count: report.poster_paths.length })}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
|
||||
Reference in New Issue
Block a user