Files
silo-server/web/src/components/settings/SubtitleAppearancePanelView.tsx
T

398 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { RotateCcw, X } from "lucide-react";
import {
BACKGROUND_STYLE_OPTIONS,
BG_COLOR_PALETTE,
FONT_COLOR_PALETTE,
FONT_FAMILY_OPTIONS,
FONT_SIZE_OPTIONS,
POSITION_OPTIONS,
computeSubtitleStyles,
type SubtitleAppearance,
} from "@/lib/subtitleAppearance";
export interface SubtitleAppearancePanelViewProps {
/** Whether the panel is rendered. Mounting the portal is gated on this. */
open: boolean;
/** Current appearance value. Parent owns parsing and serialization. */
value: SubtitleAppearance;
/**
* Apply a partial patch to the current value. The parent decides how to
* persist (auto-save in the player; mutation-bound in admin), and is
* expected to feed the next value back via `value` (optimistic OK).
*/
onChange: (patch: Partial<SubtitleAppearance>) => void;
/** Close affordance (Escape key + backdrop click + chrome × button). */
onClose: () => void;
/** Whether to render the footer reset action. */
canReset?: boolean;
/** Reset action — clears the override / restores the fallback style. */
onReset?: () => void;
/** Reset button label. Defaults to "Use fallback style". */
resetLabel?: string;
/**
* Small contextual eyebrow above the title — "Captions" in player,
* something like "admin · Apple TV 4K" in admin. Defaults to "Captions".
*/
eyebrow?: string;
/** Footer status message on the right. Defaults to auto-save copy. */
status?: string;
}
/**
* Pure view of the subtitle appearance panel — preview, sectioned form,
* and chrome — with no data-fetching of its own. Both the in-player panel
* and the admin override dialog wrap this with their own hook layer so the
* UI is identical wherever subtitles are tuned.
*/
export function SubtitleAppearancePanelView({
open,
value,
onChange,
onClose,
canReset = true,
onReset,
resetLabel = "Use fallback style",
eyebrow = "Captions",
status = "Changes saved automatically",
}: SubtitleAppearancePanelViewProps) {
const previewStyles = useMemo(() => computeSubtitleStyles(value), [value]);
// Close on Escape.
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [open, onClose]);
if (!open) return null;
const portalHost = document.fullscreenElement ?? document.body;
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label="Subtitle appearance"
className="player-subtitle-panel fixed inset-0 z-[70] flex items-center justify-center p-4"
onClick={onClose}
>
{/* Backdrop — dims whatever's behind the panel. */}
<div aria-hidden="true" className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div
role="document"
className="relative z-10 w-full max-w-[640px] overflow-hidden rounded-2xl border border-white/10 bg-[#111114]/95 text-white shadow-2xl ring-1 ring-black/40 backdrop-blur-xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-white/[0.08] px-5 py-3.5">
<div className="flex flex-col">
<div className="text-[10px] font-semibold tracking-[0.22em] text-white/50 uppercase">
{eyebrow}
</div>
<div className="text-[17px] font-semibold tracking-tight text-white">
Subtitle appearance
</div>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="flex h-9 w-9 items-center justify-center rounded-full text-white/70 transition-colors hover:bg-white/10 hover:text-white focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none"
>
<X className="h-[18px] w-[18px]" />
</button>
</div>
{/* Live preview */}
<div className="relative mx-5 mt-5 flex h-28 items-center justify-center overflow-hidden rounded-lg border border-white/[0.06] bg-[linear-gradient(135deg,#1f2937_0%,#0b0f1a_60%,#1e293b_100%)]">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-[0.12] mix-blend-overlay"
style={{
backgroundImage: "radial-gradient(rgba(255,255,255,0.7) 1px, transparent 1px)",
backgroundSize: "3px 3px",
}}
/>
<div
className="absolute inset-x-0 flex flex-col items-center gap-1 px-4 text-center"
style={previewStyles.containerStyle}
>
<span
className="inline-block rounded px-3 py-1 leading-snug"
style={{ ...previewStyles.cueStyle, whiteSpace: "pre-line" }}
>
The quick brown fox
</span>
<span
className="inline-block rounded px-3 py-1 leading-snug"
style={{ ...previewStyles.cueStyle, whiteSpace: "pre-line" }}
>
jumps over the lazy dog.
</span>
</div>
</div>
{/* Scrollable body */}
<div className="overlay-scroll max-h-[52vh] space-y-6 overflow-y-auto px-5 py-5">
{/* Text */}
<Section label="Text">
<Row label="Size">
<PillGroup
options={FONT_SIZE_OPTIONS}
value={value.fontSize}
onChange={(v) => onChange({ fontSize: v })}
/>
</Row>
<Row label="Font">
<PillGroup
options={FONT_FAMILY_OPTIONS}
value={value.fontFamily}
onChange={(v) => onChange({ fontFamily: v })}
/>
</Row>
<Row label="Color">
<ColorSwatchRow
colors={FONT_COLOR_PALETTE}
value={value.fontColor}
onChange={(v) => onChange({ fontColor: v })}
/>
</Row>
<Row label="Outline">
<ToggleSwitch
checked={value.textOutline}
onChange={(v) => onChange({ textOutline: v })}
label="Text outline"
/>
</Row>
</Section>
<Divider />
{/* Background */}
<Section label="Background">
<Row label="Style">
<PillGroup
options={BACKGROUND_STYLE_OPTIONS}
value={value.backgroundStyle}
onChange={(v) => onChange({ backgroundStyle: v })}
/>
</Row>
{value.backgroundStyle === "box" && (
<>
<Row label="Opacity">
<OpacitySlider
value={value.backgroundOpacity}
onChange={(v) => onChange({ backgroundOpacity: v })}
/>
</Row>
<Row label="Color">
<ColorSwatchRow
colors={BG_COLOR_PALETTE}
value={value.backgroundColor}
onChange={(v) => onChange({ backgroundColor: v })}
/>
</Row>
</>
)}
</Section>
<Divider />
{/* Position */}
<Section label="Position">
<Row label="Vertical">
<PillGroup
options={POSITION_OPTIONS}
value={value.position}
onChange={(v) => onChange({ position: v })}
/>
</Row>
</Section>
</div>
{/* Footer */}
<div className="flex items-center justify-between gap-3 border-t border-white/[0.08] bg-black/30 px-5 py-3">
{canReset && onReset ? (
<button
type="button"
onClick={onReset}
className="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[13px] text-white/70 transition-colors hover:bg-white/10 hover:text-white focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none"
>
<RotateCcw className="h-3.5 w-3.5" />
{resetLabel}
</button>
) : (
<span aria-hidden="true" />
)}
<div className="text-[11px] tracking-wide text-white/40 uppercase">{status}</div>
</div>
</div>
</div>,
portalHost,
);
}
/* ─────────────────────────────────────────────────────────────────────
Internal form primitives — bespoke for the dark glass aesthetic.
Kept local (rather than reaching for shadcn) so the panel matches the
immersive, always-black chrome wherever it's hosted.
───────────────────────────────────────────────────────────────────── */
function Section({ label, children }: { label: string; children: ReactNode }) {
return (
<section className="space-y-3">
<div className="text-[10.5px] font-semibold tracking-[0.2em] text-white/45 uppercase">
{label}
</div>
<div className="space-y-3">{children}</div>
</section>
);
}
function Row({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<div className="text-[13px] text-white/80 sm:w-24 sm:shrink-0">{label}</div>
<div className="min-w-0 flex-1">{children}</div>
</div>
);
}
function Divider() {
return <div className="h-px w-full bg-white/[0.06]" />;
}
interface PillOption<T extends string> {
value: T;
label: string;
}
function PillGroup<T extends string>({
options,
value,
onChange,
}: {
options: readonly PillOption<T>[];
value: T;
onChange: (v: T) => void;
}) {
return (
<div role="radiogroup" className="flex flex-wrap gap-1.5">
{options.map((option) => {
const active = option.value === value;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => onChange(option.value)}
className={`inline-flex items-center rounded-full px-3 py-1 text-[12.5px] font-medium transition-colors focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none ${
active
? "bg-white text-black"
: "bg-white/[0.06] text-white/75 hover:bg-white/10 hover:text-white"
}`}
>
{option.label}
</button>
);
})}
</div>
);
}
function ColorSwatchRow({
colors,
value,
onChange,
}: {
colors: readonly { hex: string; label: string }[];
value: string;
onChange: (hex: string) => void;
}) {
return (
<div role="radiogroup" className="flex flex-wrap gap-2">
{colors.map((color) => {
const active = color.hex.toLowerCase() === value.toLowerCase();
return (
<button
key={color.hex}
type="button"
role="radio"
aria-checked={active}
aria-label={color.label}
title={color.label}
onClick={() => onChange(color.hex)}
className={`relative h-7 w-7 rounded-full transition-transform focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none ${
active ? "scale-110" : "hover:scale-105"
}`}
style={{
backgroundColor: color.hex,
boxShadow: active
? "0 0 0 2px rgb(255 255 255 / 0.9), 0 0 0 4px rgb(0 0 0 / 0.6)"
: "inset 0 0 0 1px rgb(255 255 255 / 0.18)",
}}
/>
);
})}
</div>
);
}
function OpacitySlider({ value, onChange }: { value: number; onChange: (v: number) => void }) {
const pct = Math.max(0, Math.min(100, value));
return (
<div className="flex items-center gap-3">
<input
type="range"
min={0}
max={100}
step={5}
value={pct}
onChange={(e) => onChange(Number(e.target.value))}
className="player-subtitle-opacity-slider h-1 flex-1 cursor-pointer appearance-none rounded-full"
style={{
background: `linear-gradient(to right, rgb(255 255 255 / 0.85) 0%, rgb(255 255 255 / 0.85) ${pct}%, rgb(255 255 255 / 0.12) ${pct}%, rgb(255 255 255 / 0.12) 100%)`,
}}
aria-label="Background opacity"
/>
<div className="w-10 text-right font-mono text-[11px] text-white/60 tabular-nums">{pct}%</div>
</div>
);
}
function ToggleSwitch({
checked,
onChange,
label,
}: {
checked: boolean;
onChange: (v: boolean) => void;
label: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none ${
checked ? "bg-white" : "bg-white/15"
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full shadow-md transition-transform ${
checked ? "translate-x-[22px] bg-black" : "translate-x-[2px] bg-white"
}`}
/>
</button>
);
}