style(frontend): Prettier config + one-time repo-wide format pass

Prettier 3 (pinned), printWidth 100, double quotes — chosen to match the existing
hand-formatting and minimise reflow. This commit is ONLY the mechanical format pass
(`prettier --write`) plus the config/ignore/scripts, isolated so it never pollutes a
feature diff. Verified behaviour-neutral: typecheck, eslint (0 errors), and vitest
all green before and after. See .git-blame-ignore-revs — `git blame` skips this sha.
This commit is contained in:
2026-07-21 02:26:40 +02:00
parent 04ecd8b3a7
commit e257e2aca0
101 changed files with 4388 additions and 2641 deletions
+293 -257
View File
@@ -4,7 +4,13 @@ import { useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { DetailCustomizeMenu, Filterable, PrefToggle, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
import {
DetailCustomizeMenu,
Filterable,
PrefToggle,
useArtBackdrop,
useDetailPrefs,
} from "../lib/plexDetailUi";
import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
@@ -60,7 +66,7 @@ export default function PlexInfo({
return next;
});
};
const isAdmin = (qc.getQueryData<{ role?: string }>(["me"])?.role) === "admin";
const isAdmin = qc.getQueryData<{ role?: string }>(["me"])?.role === "admin";
const [editorOpen, setEditorOpen] = useState(false);
const [playlistOpen, setPlaylistOpen] = useState(false);
@@ -93,215 +99,236 @@ export default function PlexInfo({
<div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
<div className={overlay ? "p-5" : "flex flex-col gap-4 p-3 sm:p-4"}>
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
<div
className={overlay ? "" : "glass rounded-2xl p-4 sm:p-6"}
>
{/* Header: close (overlay) / customize */}
<div className="mb-4 flex items-start gap-3">
<div className="min-w-0 flex-1">
<h1 className={overlay ? "text-lg font-bold text-white" : "text-2xl font-bold"}>
{detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title}
</h1>
{detail.kind === "episode" && (
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
S{detail.season_number}·E{detail.episode_number} — {detail.title}
</p>
)}
</div>
<DetailCustomizeMenu
overlay={overlay}
artBg={artBg}
onToggleArtBg={toggleArtBg}
showCast={showCast}
onToggleCast={toggleCast}
hasCast={detail.cast.length > 0}
extra={presentSources.map((src) => (
<PrefToggle
key={src}
label={sourceLabel(src)}
on={!hiddenSources.includes(src)}
onClick={() => toggleSource(src)}
/>
))}
/>
{overlay && onClose && (
<button
onClick={onClose}
className="shrink-0 p-1.5 rounded-lg text-white/80 hover:bg-white/15"
aria-label={t("plex.player.back")}
>
<X className="w-5 h-5" />
</button>
)}
</div>
<div className="flex gap-4 sm:gap-6">
{/* Poster */}
<img
src={detail.thumb}
alt=""
className={`shrink-0 rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : "w-32 sm:w-44"} aspect-[2/3]`}
/>
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
{/* Meta line + IMDb rating/link (each value can set the matching filter). */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
{detail.year != null && (
<Filterable
className={mutedCls}
title={onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined}
onClick={onFilter ? () => onFilter({ yearMin: detail.year, yearMax: detail.year }) : undefined}
>
{detail.year}
</Filterable>
)}
{detail.content_rating && (
<Filterable
className={mutedCls}
onClick={onFilter ? () => onFilter({ contentRatings: [detail.content_rating!] }) : undefined}
>
{detail.content_rating}
</Filterable>
)}
{detail.duration_seconds ? <span className={mutedCls}>{formatRuntime(detail.duration_seconds)}</span> : null}
{detail.studio && (
<Filterable
className={mutedCls}
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
>
{detail.studio}
</Filterable>
)}
{detail.imdb_rating != null && (
<span className="inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500">
<button
onClick={onFilter ? () => onFilter({ ratingMin: Math.floor(detail.imdb_rating!) }) : undefined}
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
title={onFilter ? t("plex.info.filterRating", { n: Math.floor(detail.imdb_rating!) }) : undefined}
>
<Star className="w-3.5 h-3.5 fill-current" />
{detail.imdb_rating.toFixed(1)}
</button>
{detail.imdb_url && (
<a href={detail.imdb_url} target="_blank" rel="noopener noreferrer" title={t("plex.info.openImdb")}>
<ExternalLink className="w-3 h-3 opacity-70 hover:opacity-100" />
</a>
)}
</span>
<div className={overlay ? "" : "glass rounded-2xl p-4 sm:p-6"}>
{/* Header: close (overlay) / customize */}
<div className="mb-4 flex items-start gap-3">
<div className="min-w-0 flex-1">
<h1 className={overlay ? "text-lg font-bold text-white" : "text-2xl font-bold"}>
{detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title}
</h1>
{detail.kind === "episode" && (
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
S{detail.season_number}·E{detail.episode_number} — {detail.title}
</p>
)}
</div>
{detail.tagline && (
<p className={`mt-2 text-sm italic ${overlay ? "text-white/60" : "text-muted"}`}>
{detail.tagline}
</p>
)}
{detail.genres.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{detail.genres.map((g) => (
<Filterable
key={g}
className={`rounded-full px-2 py-0.5 text-xs ${overlay ? "bg-white/10" : "bg-surface text-muted"}`}
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
>
{g}
</Filterable>
))}
</div>
)}
{detail.directors.length > 0 && (
<p className={`mt-2 text-sm ${mutedCls}`}>
{t("plex.info.director")}:{" "}
{detail.directors.map((d, i) => (
<span key={d}>
<Filterable
className="font-medium"
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
>
{d}
</Filterable>
{i < detail.directors.length - 1 ? ", " : ""}
</span>
))}
</p>
)}
{detail.summary && (
<p className={`mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}>
{detail.summary}
</p>
)}
{/* Page actions: Play/Resume + watch controls */}
{!overlay && (
<div className="mt-4 flex flex-wrap items-center gap-2">
{onPlay && (
<button
onClick={onPlay}
className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-white hover:opacity-90"
>
<Play className="w-5 h-5 fill-current" />
{inProgress ? t("plex.resume") : t("plex.play")}
</button>
)}
{detail.status === "watched" ? (
<button
onClick={() => setState("new")}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<RotateCcw className="w-4 h-4" />
{t("plex.info.markUnwatched")}
</button>
) : (
<button
onClick={() => setState("watched")}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<Check className="w-4 h-4" />
{t("plex.info.markWatched")}
</button>
)}
{inProgress && (
<button
onClick={() => setState("new")}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<RotateCcw className="w-4 h-4" />
{t("plex.info.clearResume")}
</button>
)}
{detail.kind === "movie" && (
<button
onClick={() => setPlaylistOpen(true)}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<ListPlus className="w-4 h-4" />
{t("plex.playlistAdd.manage")}
</button>
)}
{isAdmin && detail.kind === "movie" && library && (
<button
onClick={() => setEditorOpen(true)}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<FolderPlus className="w-4 h-4" />
{t("plex.collEditor.manage")}
</button>
)}
</div>
<DetailCustomizeMenu
overlay={overlay}
artBg={artBg}
onToggleArtBg={toggleArtBg}
showCast={showCast}
onToggleCast={toggleCast}
hasCast={detail.cast.length > 0}
extra={presentSources.map((src) => (
<PrefToggle
key={src}
label={sourceLabel(src)}
on={!hiddenSources.includes(src)}
onClick={() => toggleSource(src)}
/>
))}
/>
{overlay && onClose && (
<button
onClick={onClose}
className="shrink-0 p-1.5 rounded-lg text-white/80 hover:bg-white/15"
aria-label={t("plex.player.back")}
>
<X className="w-5 h-5" />
</button>
)}
</div>
</div>
<div className="flex gap-4 sm:gap-6">
{/* Poster */}
<img
src={detail.thumb}
alt=""
className={`shrink-0 rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : "w-32 sm:w-44"} aspect-[2/3]`}
/>
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
{/* Meta line + IMDb rating/link (each value can set the matching filter). */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
{detail.year != null && (
<Filterable
className={mutedCls}
title={onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined}
onClick={
onFilter
? () => onFilter({ yearMin: detail.year, yearMax: detail.year })
: undefined
}
>
{detail.year}
</Filterable>
)}
{detail.content_rating && (
<Filterable
className={mutedCls}
onClick={
onFilter
? () => onFilter({ contentRatings: [detail.content_rating!] })
: undefined
}
>
{detail.content_rating}
</Filterable>
)}
{detail.duration_seconds ? (
<span className={mutedCls}>{formatRuntime(detail.duration_seconds)}</span>
) : null}
{detail.studio && (
<Filterable
className={mutedCls}
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
>
{detail.studio}
</Filterable>
)}
{detail.imdb_rating != null && (
<span className="inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500">
<button
onClick={
onFilter
? () => onFilter({ ratingMin: Math.floor(detail.imdb_rating!) })
: undefined
}
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
title={
onFilter
? t("plex.info.filterRating", { n: Math.floor(detail.imdb_rating!) })
: undefined
}
>
<Star className="w-3.5 h-3.5 fill-current" />
{detail.imdb_rating.toFixed(1)}
</button>
{detail.imdb_url && (
<a
href={detail.imdb_url}
target="_blank"
rel="noopener noreferrer"
title={t("plex.info.openImdb")}
>
<ExternalLink className="w-3 h-3 opacity-70 hover:opacity-100" />
</a>
)}
</span>
)}
</div>
{detail.tagline && (
<p className={`mt-2 text-sm italic ${overlay ? "text-white/60" : "text-muted"}`}>
{detail.tagline}
</p>
)}
{detail.genres.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{detail.genres.map((g) => (
<Filterable
key={g}
className={`rounded-full px-2 py-0.5 text-xs ${overlay ? "bg-white/10" : "bg-surface text-muted"}`}
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
>
{g}
</Filterable>
))}
</div>
)}
{detail.directors.length > 0 && (
<p className={`mt-2 text-sm ${mutedCls}`}>
{t("plex.info.director")}:{" "}
{detail.directors.map((d, i) => (
<span key={d}>
<Filterable
className="font-medium"
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
>
{d}
</Filterable>
{i < detail.directors.length - 1 ? ", " : ""}
</span>
))}
</p>
)}
{detail.summary && (
<p
className={`mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
>
{detail.summary}
</p>
)}
{/* Page actions: Play/Resume + watch controls */}
{!overlay && (
<div className="mt-4 flex flex-wrap items-center gap-2">
{onPlay && (
<button
onClick={onPlay}
className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-white hover:opacity-90"
>
<Play className="w-5 h-5 fill-current" />
{inProgress ? t("plex.resume") : t("plex.play")}
</button>
)}
{detail.status === "watched" ? (
<button
onClick={() => setState("new")}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<RotateCcw className="w-4 h-4" />
{t("plex.info.markUnwatched")}
</button>
) : (
<button
onClick={() => setState("watched")}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<Check className="w-4 h-4" />
{t("plex.info.markWatched")}
</button>
)}
{inProgress && (
<button
onClick={() => setState("new")}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<RotateCcw className="w-4 h-4" />
{t("plex.info.clearResume")}
</button>
)}
{detail.kind === "movie" && (
<button
onClick={() => setPlaylistOpen(true)}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<ListPlus className="w-4 h-4" />
{t("plex.playlistAdd.manage")}
</button>
)}
{isAdmin && detail.kind === "movie" && library && (
<button
onClick={() => setEditorOpen(true)}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<FolderPlus className="w-4 h-4" />
{t("plex.collEditor.manage")}
</button>
)}
</div>
)}
</div>
</div>
</div>
{/* End hero panel. */}
{/* Cast row (toggleable), circular photos. */}
{cast.length > 0 && (
<div
className={overlay ? "mt-5" : "glass-card rounded-2xl p-4 sm:p-5"}
>
<div className={overlay ? "mt-5" : "glass-card rounded-2xl p-4 sm:p-5"}>
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
{t("plex.info.cast")}
</h2>
@@ -330,7 +357,9 @@ export default function PlexInfo({
{c.name}
</div>
{c.role && (
<div className={`truncate text-[11px] ${overlay ? "text-white/55" : "text-muted"}`}>
<div
className={`truncate text-[11px] ${overlay ? "text-white/55" : "text-muted"}`}
>
{c.role}
</div>
)}
@@ -360,61 +389,68 @@ export default function PlexInfo({
{detail.collections
?.filter((col) => !hiddenSources.includes(col.source))
.map((col) => (
<div
key={col.id}
className={overlay ? "mt-6" : "glass-card rounded-2xl p-4 sm:p-5"}
>
<div className="mb-2 flex items-center justify-between gap-3">
<h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>{col.title}</h2>
{onFilter && (
<button
onClick={() => onFilter({ collection: col.id, collectionTitle: col.title })}
className="shrink-0 text-xs text-muted hover:text-accent"
>
{t("plex.info.browseCollection")}
</button>
)}
<div key={col.id} className={overlay ? "mt-6" : "glass-card rounded-2xl p-4 sm:p-5"}>
<div className="mb-2 flex items-center justify-between gap-3">
<h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
{col.title}
</h2>
{onFilter && (
<button
onClick={() => onFilter({ collection: col.id, collectionTitle: col.title })}
className="shrink-0 text-xs text-muted hover:text-accent"
>
{t("plex.info.browseCollection")}
</button>
)}
</div>
<div className="flex gap-3 overflow-x-auto pb-2">
{col.items.map((m) => (
<button
key={m.id}
onClick={() => onPlayItem?.(m.id)}
disabled={!onPlayItem}
className="group/col w-28 shrink-0 text-left"
>
<div className="relative aspect-[2/3] overflow-hidden rounded-lg border border-border bg-surface">
<img
src={m.thumb}
alt=""
className="h-full w-full object-cover transition group-hover/col:scale-105"
/>
{m.status === "watched" && (
<span className="absolute right-1 top-1 rounded bg-black/70 px-1 text-[9px] text-white">
✓
</span>
)}
{onPlayItem && (
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover/col:opacity-100">
<Play className="h-8 w-8 text-white" fill="currentColor" />
</div>
)}
{(m.position_seconds ?? 0) > 0 &&
m.status !== "watched" &&
m.duration_seconds ? (
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-black/40">
<div
className="h-full bg-accent"
style={{
width: `${Math.min(100, ((m.position_seconds ?? 0) / m.duration_seconds) * 100)}%`,
}}
/>
</div>
) : null}
</div>
<div
className={`mt-1 truncate text-xs font-medium ${overlay ? "text-white/90" : ""}`}
>
{m.title}
</div>
{m.year ? <div className="text-[11px] text-muted">{m.year}</div> : null}
</button>
))}
</div>
</div>
<div className="flex gap-3 overflow-x-auto pb-2">
{col.items.map((m) => (
<button
key={m.id}
onClick={() => onPlayItem?.(m.id)}
disabled={!onPlayItem}
className="group/col w-28 shrink-0 text-left"
>
<div className="relative aspect-[2/3] overflow-hidden rounded-lg border border-border bg-surface">
<img
src={m.thumb}
alt=""
className="h-full w-full object-cover transition group-hover/col:scale-105"
/>
{m.status === "watched" && (
<span className="absolute right-1 top-1 rounded bg-black/70 px-1 text-[9px] text-white">✓</span>
)}
{onPlayItem && (
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover/col:opacity-100">
<Play className="h-8 w-8 text-white" fill="currentColor" />
</div>
)}
{(m.position_seconds ?? 0) > 0 && m.status !== "watched" && m.duration_seconds ? (
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-black/40">
<div
className="h-full bg-accent"
style={{ width: `${Math.min(100, ((m.position_seconds ?? 0) / m.duration_seconds) * 100)}%` }}
/>
</div>
) : null}
</div>
<div className={`mt-1 truncate text-xs font-medium ${overlay ? "text-white/90" : ""}`}>
{m.title}
</div>
{m.year ? <div className="text-[11px] text-muted">{m.year}</div> : null}
</button>
))}
</div>
</div>
))}
))}
</div>
{editorOpen && library && (
<PlexCollectionEditor