feat(design): glass surfaces by role, the static Badge, LinkButton, user pickers on the Combobox (E28 S3d1)
This commit is contained in:
@@ -5,6 +5,7 @@ import { api } from "../lib/api";
|
||||
import ChannelAboutContent from "./ChannelAbout";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
/**
|
||||
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
|
||||
@@ -56,7 +57,7 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
|
||||
</span>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
className="glass w-120 overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
|
||||
className={`${SURFACE.popover} w-120 overflow-y-auto px-3.5 py-3 animate-[fadeIn_0.12s_ease]`}
|
||||
// min(design cap, available space): keeps the 28rem cap on a tall viewport, shrinks to fit on a
|
||||
// short one (high zoom). --fit-max-h is set by the fitHeight `size` middleware once positioned.
|
||||
style={{ maxHeight: "min(28rem, var(--fit-max-h, 28rem))" }}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { formatDateTime } from "../lib/format";
|
||||
import { invalidateUserAccess } from "../lib/adminUsers";
|
||||
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
||||
import { Button } from "./ui/Button";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The admin's answer to "why can this person not get in?", and the place every recovery action for
|
||||
// one account lives (E12 · S5, design option C).
|
||||
@@ -117,7 +118,7 @@ export default function AccountPanel({
|
||||
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
|
||||
{t("users.account.access")}
|
||||
</div>
|
||||
<div className="glass-card rounded-xl p-3 space-y-2 mb-5">
|
||||
<div className={`${SURFACE.card} p-3 space-y-2 mb-5`}>
|
||||
<AccessRow
|
||||
icon={Mail}
|
||||
label={t("users.account.email")}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Chip } from "./ui/Chip";
|
||||
import { chipClass } from "./ui/chipStyles";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
|
||||
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
|
||||
@@ -81,7 +82,7 @@ function OverflowPopover({
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={name}
|
||||
className="glass-menu min-w-48 max-w-72 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.menu} min-w-48 max-w-72 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
<div role="group" aria-label={name} className="max-h-72 overflow-y-auto no-scrollbar">
|
||||
{chips.map((f) => (
|
||||
@@ -90,7 +91,7 @@ function OverflowPopover({
|
||||
role="menuitem"
|
||||
onSelect={f.remove}
|
||||
label={f.label}
|
||||
className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-fg transition"
|
||||
className="group flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm text-fg transition"
|
||||
>
|
||||
<span className="flex-1 truncate">{f.label}</span>
|
||||
<X className="w-3.5 h-3.5 shrink-0 opacity-60 group-hover:opacity-100" />
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
|
||||
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
|
||||
@@ -111,7 +112,7 @@ export default function AddToPlaylist({
|
||||
<ListPlus />
|
||||
</IconButton>
|
||||
|
||||
<PopoverSurface popover={pop} className="glass-menu w-60 rounded-xl p-2">
|
||||
<PopoverSurface popover={pop} className={`${SURFACE.popover} w-60 p-2`}>
|
||||
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
|
||||
would otherwise reach the feed card that hosts the trigger and start playback. */}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -45,6 +45,7 @@ import { useMe } from "../lib/useMe";
|
||||
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
|
||||
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
|
||||
@@ -231,7 +232,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
{rows.map((u) => {
|
||||
const self = u.id === me.id;
|
||||
return (
|
||||
<div key={u.id} className="glass-card flex items-center gap-2.5 p-2.5 rounded-xl">
|
||||
<div key={u.id} className={`${SURFACE.card} flex items-center gap-2.5 p-2.5`}>
|
||||
<Pressable
|
||||
onClick={() => setPanelFor(u)}
|
||||
title={t("users.account.open")}
|
||||
@@ -429,7 +430,7 @@ function PendingAccessRowView({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
||||
<div className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm truncate">{row.email}</div>
|
||||
<div className="text-2xs text-muted flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
@@ -829,7 +830,7 @@ function AdminDemo() {
|
||||
{rows.length > 0 && (
|
||||
<div className="space-y-1.5 mb-3">
|
||||
{rows.map((r) => (
|
||||
<div key={r.id} className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
||||
<div key={r.id} className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm truncate">{r.email}</div>
|
||||
{r.added_by && (
|
||||
@@ -916,7 +917,7 @@ function InviteRow({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
||||
<div className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm truncate">{inv.email}</div>
|
||||
{inv.requested_at && (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import type { ChannelDetail } from "../lib/api";
|
||||
import { linkify } from "../lib/linkify";
|
||||
import { Badge } from "./ui/Badge";
|
||||
|
||||
// --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) ---
|
||||
function displayName(code: string, lang: string, type: "region" | "language"): string {
|
||||
@@ -101,9 +102,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{/* Future search epic: these topic chips become clickable filters. */}
|
||||
{topics.map((tp) => (
|
||||
<span key={tp} className="glass-card px-2 py-0.5 rounded-full text-xs">
|
||||
{tp}
|
||||
</span>
|
||||
<Badge key={tp}>{tp}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,9 +112,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
|
||||
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.keywords")}</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{keywords.map((kw, i) => (
|
||||
<span key={i} className="glass-card px-2 py-0.5 rounded-full text-xs">
|
||||
{kw}
|
||||
</span>
|
||||
<Badge key={i}>{kw}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type Column } from "./ui/DataTable";
|
||||
import VirtualGrid from "./ui/VirtualGrid";
|
||||
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The channel manager's `cards` layout (E4 S3), built by reusing the SAME `Column<T>` render closures
|
||||
// the DataTable draws — so the subscribed and discovery tables (which have different columns) both get
|
||||
@@ -60,7 +61,7 @@ export default function ChannelLayoutGrid<T>({
|
||||
<div
|
||||
{...rowProps?.(row)}
|
||||
{...navItemAttrs(index)}
|
||||
className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
|
||||
className={`${SURFACE.card} p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1 font-medium">
|
||||
|
||||
@@ -15,6 +15,7 @@ import { formatEta, formatNumber } from "../lib/format";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { Button } from "./ui/Button";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The channel-manager catalog stats, collapsed from a crowded inline row into a compact "Stats" chip
|
||||
// that reveals the numbers on hover (reuses the AnchoredPopover hover mode). One row per stat, each
|
||||
@@ -90,7 +91,7 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("channels.stats.label")}
|
||||
className="glass-menu w-60 rounded-xl p-2 text-xs animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.popover} w-60 p-2 text-xs animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{rows.map((r) => (
|
||||
<div
|
||||
|
||||
@@ -11,6 +11,7 @@ import { IconButton } from "./ui/IconButton";
|
||||
import { Chip, DismissibleChip } from "./ui/Chip";
|
||||
import { chipClass } from "./ui/chipStyles";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
|
||||
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
|
||||
@@ -175,7 +176,7 @@ function SuggestedChipsLane({
|
||||
{/* Hover: the overflowed suggestions, each still one-tap accept / × dismiss. */}
|
||||
<PopoverSurface
|
||||
popover={overflowPop}
|
||||
className="glass-menu max-w-[18rem] rounded-xl p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.popover} max-w-[18rem] p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{suggestions.slice(visible).map((s) => (
|
||||
<SuggestedChip
|
||||
@@ -227,7 +228,7 @@ function TagEditorMenu({
|
||||
checked={on}
|
||||
onSelect={() => onToggle(tg.id)}
|
||||
label={tg.name}
|
||||
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
|
||||
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
@@ -458,7 +459,7 @@ export function ChannelTagsControl({
|
||||
{/* Hover: reveal every attached tag when some are collapsed into "+N". */}
|
||||
<PopoverSurface
|
||||
popover={overflowPop}
|
||||
className="glass-menu max-w-[18rem] rounded-xl p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.popover} max-w-[18rem] p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{attached.map((tg) => (
|
||||
<TagChip
|
||||
@@ -473,7 +474,7 @@ export function ChannelTagsControl({
|
||||
{/* Click: the shared editor (toggle existing + create inline). */}
|
||||
<PopoverSurface
|
||||
popover={editPop}
|
||||
className="glass-menu w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
<TagEditorMenu
|
||||
userTags={userTags}
|
||||
|
||||
@@ -18,6 +18,7 @@ import ChatThread from "./ChatThread";
|
||||
import Overlay from "./ui/Overlay";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The Messenger-style floating chat dock: open conversations live here, bottom-right, across
|
||||
// page navigation (state persisted per user). Always mounted (for human users) so it also owns
|
||||
@@ -75,7 +76,9 @@ export default function ChatDock({ meId }: { meId: number }) {
|
||||
function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] glass rounded-2xl flex flex-col overflow-hidden">
|
||||
<div
|
||||
className={`${SURFACE.panel} pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] flex flex-col overflow-hidden`}
|
||||
>
|
||||
{/* One-shot attention flash on a new message; keyed by the counter so it replays each time. */}
|
||||
{chat.flash > 0 && (
|
||||
<div
|
||||
|
||||
@@ -20,6 +20,7 @@ import { notifyActionError } from "../lib/actionErrors";
|
||||
import { Button } from "./ui/Button";
|
||||
import { CheckRow } from "./ui/NativeControls";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
|
||||
// backend — adding a key there makes it appear here automatically). Edits are drafted and
|
||||
@@ -195,7 +196,7 @@ export default function ConfigPanel() {
|
||||
</PageToolbar>
|
||||
<div className="px-4 pb-24 pt-3 max-w-3xl w-full mx-auto">
|
||||
{activeGroup && (
|
||||
<div className="glass rounded-2xl p-4 mb-4">
|
||||
<div className={`${SURFACE.panel} p-4 mb-4`}>
|
||||
<div className="divide-y divide-border/60">
|
||||
{(data.groups[activeGroup] ?? []).map((item) => (
|
||||
<Field
|
||||
|
||||
@@ -8,6 +8,7 @@ import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilte
|
||||
import { Button } from "./ui/Button";
|
||||
import { SelectTrigger } from "./ui/SelectTrigger";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The feed rail's "Upload date" control: a single-select dropdown (the SortControl/FilterSelect pill,
|
||||
// but full-width for the rail) over "Any time" + the day presets + a "Custom range". Picking Custom
|
||||
@@ -110,7 +111,7 @@ export default function DateFilterSelect({
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("sidebar.datePresetLabel")}
|
||||
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.menu} min-w-52 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
<div role="group" aria-label={t("sidebar.datePresetLabel")}>
|
||||
{options.map((o) => {
|
||||
@@ -122,7 +123,7 @@ export default function DateFilterSelect({
|
||||
checked={on}
|
||||
onSelect={() => pick(o.value)}
|
||||
label={o.label}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -29,6 +29,7 @@ import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import IssueComposer from "./IssueComposer";
|
||||
import { useDebugRecording } from "./useDebugRecording";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The debug recorder (E16): Start → reproduce → Stop.
|
||||
//
|
||||
@@ -152,7 +153,7 @@ function RecorderPill() {
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("recorder.pill.label")}
|
||||
className={`relative glass rounded-full h-12 pl-3 sm:pl-4 pr-1.5 flex items-center gap-1.5 sm:gap-2 shadow-lg ${
|
||||
className={`relative ${SURFACE.floating} h-12 pl-3 sm:pl-4 pr-1.5 flex items-center gap-1.5 sm:gap-2 shadow-lg ${
|
||||
hidden ? "invisible" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -304,7 +305,9 @@ function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null)
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="absolute bottom-full left-0 mb-2 w-80 max-w-[calc(100vw-2rem)] glass rounded-xl p-3 flex flex-col gap-2 shadow-lg">
|
||||
<div
|
||||
className={`${SURFACE.popover} absolute bottom-full left-0 mb-2 w-80 max-w-[calc(100vw-2rem)] p-3 flex flex-col gap-2`}
|
||||
>
|
||||
<label htmlFor="debug-step-label" className="text-sm text-muted">
|
||||
{t("recorder.pill.stepPrompt", { n })}
|
||||
</label>
|
||||
|
||||
@@ -44,6 +44,7 @@ import { MetaEditModal, ErrorModal } from "./downloadModals";
|
||||
import { AdminSystem } from "./downloadAdmin";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { Input } from "./ui/Input";
|
||||
import { IconLink } from "./ui/LinkButton";
|
||||
|
||||
export default function DownloadCenter() {
|
||||
const me = useMe();
|
||||
@@ -159,13 +160,9 @@ export default function DownloadCenter() {
|
||||
if (!tabs.some((tb) => tb.id === tab)) setTab("queue");
|
||||
|
||||
const saveBtn = (job: DownloadJob) => (
|
||||
<a
|
||||
href={api.downloadFileUrl(job.id)}
|
||||
title={t("downloads.actions.download")}
|
||||
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg transition"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</a>
|
||||
<IconLink href={api.downloadFileUrl(job.id)} label={t("downloads.actions.download")}>
|
||||
<Download />
|
||||
</IconLink>
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,7 @@ import ChannelLink from "./ChannelLink";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { Button } from "./ui/Button";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The presentation shared by the channel manager's "excluded channel" tabs — Hidden and Blocked.
|
||||
// A responsive grid of proper channel cards (avatar + name + open-on-YouTube, via ChannelLink) each
|
||||
@@ -48,7 +49,7 @@ export default function ExcludedChannelList({
|
||||
<div
|
||||
key={c.id}
|
||||
{...navItemAttrs(i)}
|
||||
className="glass-card flex items-center gap-2 rounded-xl px-3 py-2"
|
||||
className={`${SURFACE.card} flex items-center gap-2 px-3 py-2`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<ChannelLink
|
||||
|
||||
@@ -7,6 +7,7 @@ import { api, HttpError } from "../lib/api";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device.
|
||||
// Self-contained — on success it updates the shared e2ee unlock state, so any observing view
|
||||
@@ -95,7 +96,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
|
||||
if (view === "reset")
|
||||
return (
|
||||
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
|
||||
<div className={`${SURFACE.panel} space-y-3 ${compact ? "p-3" : "p-4"}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-danger shrink-0" />
|
||||
<div className="font-semibold text-sm">{t("messages.resetTitle")}</div>
|
||||
@@ -133,7 +134,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
|
||||
<div className={`${SURFACE.panel} space-y-3 ${compact ? "p-3" : "p-4"}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="w-5 h-5 text-accent shrink-0" />
|
||||
<div className="font-semibold text-sm">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LANGUAGES, type LangCode } from "../i18n";
|
||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// Compact language picker (globe + current code). Presentational: the parent decides what
|
||||
// changing the language does (set it; persist server-side when signed in).
|
||||
@@ -67,10 +68,7 @@ export default function LanguageSwitcher({
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
className="glass-menu w-40 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<PopoverSurface popover={pop} className={`${SURFACE.menu} w-40 animate-[popIn_0.16s_ease]`}>
|
||||
{LANGUAGES.map((l) => (
|
||||
<PopoverItem
|
||||
key={l.code}
|
||||
|
||||
@@ -20,6 +20,7 @@ import NavGrid from "./ui/NavGrid";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
type View =
|
||||
| "list"
|
||||
@@ -146,7 +147,7 @@ function ConversationList({
|
||||
) : q.isLoading && !q.data ? (
|
||||
<LoadingState />
|
||||
) : items.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div>
|
||||
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>{t("messages.empty")}</div>
|
||||
) : (
|
||||
// A keyboard list (E11): `j`/`k` walk the conversations, Enter opens the one the name button
|
||||
// opens; the pop-out button beside it keeps its own Enter.
|
||||
@@ -248,7 +249,7 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
|
||||
) : q.isLoading ? (
|
||||
<LoadingState />
|
||||
) : users.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-10 text-center text-muted">
|
||||
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>
|
||||
{t("messages.noPeople")}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -21,6 +21,7 @@ import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
|
||||
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
|
||||
@@ -202,7 +203,7 @@ export default function NavSidebar({
|
||||
// The slot is exactly the card's width so the filter tab/bar docks flush against its right edge.
|
||||
<div className="relative shrink-0 w-14">
|
||||
<nav
|
||||
className="glass absolute top-0 bottom-0 left-0 z-rail rounded-2xl flex flex-col py-3 my-3 w-14 px-2"
|
||||
className={`${SURFACE.panel} absolute top-0 bottom-0 left-0 z-rail flex flex-col py-3 my-3 w-14 px-2`}
|
||||
aria-label={t("nav.primary")}
|
||||
>
|
||||
<div
|
||||
@@ -270,7 +271,7 @@ export default function NavSidebar({
|
||||
<PopoverSurface
|
||||
popover={acctPop}
|
||||
ariaLabel={name}
|
||||
className="glass w-60 rounded-xl p-3 animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.popover} w-60 p-3 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-border">
|
||||
<AvatarImg
|
||||
|
||||
@@ -45,6 +45,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { useNavigationActions } from "./navigationContext";
|
||||
import { useChannelsActions } from "./channelsContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The single notification center. "System" = durable, server-backed notifications (inbox
|
||||
// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with
|
||||
@@ -142,7 +143,7 @@ export default function NotificationsPanel() {
|
||||
{/* The inbox header (title + mark-all / clear-all) is fixed chrome; only the list scrolls. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-2 max-w-3xl w-full mx-auto">
|
||||
<div className="glass rounded-2xl p-4 flex items-center gap-3">
|
||||
<div className={`${SURFACE.panel} p-4 flex items-center gap-3`}>
|
||||
<Bell className="w-5 h-5 text-accent shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold">{t("inbox.title")}</div>
|
||||
@@ -182,7 +183,7 @@ export default function NotificationsPanel() {
|
||||
) : q.isLoading && !q.data && clientItems.length === 0 ? (
|
||||
<LoadingState />
|
||||
) : !hasAny ? (
|
||||
<div className="glass rounded-2xl p-10 text-center text-muted">
|
||||
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>
|
||||
<Bell className="w-8 h-8 mx-auto mb-3 opacity-40" />
|
||||
{t("inbox.empty")}
|
||||
</div>
|
||||
@@ -299,7 +300,7 @@ function NotificationRow({
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`glass-card rounded-xl p-3.5 flex items-start gap-3 transition ${
|
||||
className={`${SURFACE.card} p-3.5 flex items-start gap-3 transition ${
|
||||
n.read ? "opacity-70" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -385,7 +386,7 @@ function ClientActivityRow({
|
||||
const access = n.meta?.kind === "access-requests" ? n.meta : null;
|
||||
return (
|
||||
<div
|
||||
className={`glass-card rounded-xl p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`}
|
||||
className={`${SURFACE.card} p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useOverlayOrder } from "./ui/overlayOrder";
|
||||
import { useLayer } from "../lib/layerStack";
|
||||
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// A first-login wizard that walks the user through granting YouTube access one scope at a
|
||||
// time, each with a plain explanation and an up-front heads-up about Google's "unverified
|
||||
@@ -171,7 +172,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className="glass relative w-[min(94vw,460px)] rounded-2xl p-7 text-center outline-none animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]"
|
||||
className={`${SURFACE.panel} relative w-[min(94vw,460px)] p-7 text-center outline-none animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]`}
|
||||
>
|
||||
<IconButton
|
||||
size="md"
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Command } from "lucide-react";
|
||||
|
||||
import { SHORTCUTS, formatChord, openPalette } from "../lib/keymap";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import clsx from "clsx";
|
||||
import { fieldClass } from "./ui/fieldStyles";
|
||||
|
||||
// The visible way into the command palette (and, from there, the shortcut sheet). Ctrl/Cmd+K and
|
||||
// `h` had no entry point anywhere but a palette row — the one in-app pointer to the cheat sheet sat
|
||||
@@ -50,7 +52,12 @@ export function PaletteLauncher() {
|
||||
<Pressable
|
||||
onClick={openPalette}
|
||||
title={t("palette.open")}
|
||||
className="pointer-events-auto glass-menu flex h-11 items-center gap-2 rounded-2xl px-3 text-sm text-muted transition hover:text-fg"
|
||||
// The search box's own frame (a 40 glass pill with the field edge): on a page without a search
|
||||
// this is where the search would be, and it opens the same place.
|
||||
className={clsx(
|
||||
fieldClass({ size: "lg", pill: true, floating: true }),
|
||||
"pointer-events-auto flex items-center gap-2 text-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
<Command className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span>{t("palette.title")}</span>
|
||||
|
||||
@@ -64,6 +64,7 @@ import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// Experiment (branch experiment/inline-player): play the video in-app via the
|
||||
// YouTube IFrame Player API (not a bare embed) so we can read playback position
|
||||
@@ -592,7 +593,7 @@ export default function PlayerModal({
|
||||
<div
|
||||
ref={cardRef}
|
||||
tabIndex={-1}
|
||||
className="glass-card relative flex-1 min-w-0 max-h-full overflow-y-auto rounded-2xl outline-hidden"
|
||||
className={`${SURFACE.panel} relative flex-1 min-w-0 max-h-full overflow-y-auto outline-hidden`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Info } from "lucide-react";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { Button } from "./ui/Button";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The playlists module shows fewer lists than youtube.com does, and without a word about it that
|
||||
// reads as Siftlode being broken (the owner's ask, 2026-09-03: say it is YouTube's API, up front).
|
||||
@@ -36,7 +37,7 @@ export default function PlaylistLimitsHint() {
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("playlists.limits.title")}
|
||||
className="glass-menu w-80 max-w-[calc(100vw-2rem)] rounded-xl p-3.5 text-xs leading-relaxed text-fg animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.popover} w-80 max-w-[calc(100vw-2rem)] p-3.5 text-xs leading-relaxed text-fg animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
<p className="mb-2 font-semibold">{t("playlists.limits.title")}</p>
|
||||
<ul className="list-disc space-y-1.5 pl-4 text-muted">
|
||||
|
||||
@@ -46,6 +46,7 @@ import NavGrid from "./ui/NavGrid";
|
||||
import { Chip } from "./ui/Chip";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
||||
@@ -553,7 +554,7 @@ export default function PlexBrowse() {
|
||||
{personCards.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
className="glass-card flex items-center gap-1 rounded-xl py-1.5 pl-1.5 pr-2.5"
|
||||
className={`${SURFACE.card} flex items-center gap-1 py-1.5 pl-1.5 pr-2.5`}
|
||||
>
|
||||
{/* The photo + name open the actor's filmography page; the ✕ removes the filter. */}
|
||||
<Pressable
|
||||
|
||||
@@ -11,6 +11,7 @@ import Modal from "./ui/Modal";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// Admin-only "manage this movie's collections" dialog (Collections P2). Lists the library's EDITABLE
|
||||
// collections with an in/out toggle (add/remove the movie, written back to Plex), plus a field to
|
||||
@@ -190,7 +191,7 @@ export default function PlexCollectionEditor({
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className="glass-card flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm"
|
||||
className={`${SURFACE.card} flex items-center gap-2 px-2 py-1.5 text-sm`}
|
||||
>
|
||||
<Pressable
|
||||
role="checkbox"
|
||||
@@ -241,7 +242,7 @@ export default function PlexCollectionEditor({
|
||||
{eligible.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="glass-card flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm"
|
||||
className={`${SURFACE.card} flex items-center gap-2 px-2 py-1.5 text-sm`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{c.title}</span>
|
||||
<span className="shrink-0 text-xs text-muted">
|
||||
|
||||
@@ -25,6 +25,8 @@ import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Input } from "./ui/Input";
|
||||
import { useReturnFocus } from "./ui/useReturnFocus";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { Badge } from "./ui/Badge";
|
||||
|
||||
// The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the
|
||||
// ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external,
|
||||
@@ -294,13 +296,10 @@ export default function PlexCollectionView({
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold leading-tight">{d.title}</h1>
|
||||
{readOnly && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full bg-surface px-2 py-0.5 text-2xs text-muted"
|
||||
title={t("plex.collection.readOnly")}
|
||||
>
|
||||
<Lock className="h-3 w-3" />
|
||||
<Badge title={t("plex.collection.readOnly")}>
|
||||
<Lock />
|
||||
{t("plex.collection.readOnly")}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -478,7 +477,7 @@ function AddItemPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-card mb-6 rounded-2xl p-4">
|
||||
<div className={`${SURFACE.panel} mb-6 p-4`}>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-muted" />
|
||||
<Input
|
||||
|
||||
@@ -18,6 +18,8 @@ import { navItemAttrs } from "../lib/listNav";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { Badge } from "./ui/Badge";
|
||||
|
||||
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
|
||||
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
|
||||
@@ -196,7 +198,7 @@ function FactsPanel({
|
||||
});
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-5">
|
||||
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
|
||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.facts.title")}</h2>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
|
||||
{rows.map((r) => (
|
||||
@@ -221,13 +223,13 @@ function KeywordsPanel({
|
||||
const { t } = useTranslation();
|
||||
if (keywords.length === 0) return null;
|
||||
return (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-5">
|
||||
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
|
||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.keywords")}</h2>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{keywords.map((k) => (
|
||||
<Filterable
|
||||
key={k}
|
||||
className="rounded-full bg-surface px-2.5 py-1 text-xs text-muted hover:text-accent"
|
||||
tag
|
||||
title={onFilter ? t("plex.info.filterKeyword", { name: k }) : undefined}
|
||||
onClick={onFilter ? () => onFilter({ keywords: [k] }) : undefined}
|
||||
>
|
||||
@@ -251,7 +253,7 @@ function CrewPanel({
|
||||
const { t } = useTranslation();
|
||||
if (crew.length === 0) return null;
|
||||
return (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-5">
|
||||
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
|
||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
|
||||
{/* Two columns inside the (now narrow) side box — viewport media-queries can't see the box width,
|
||||
so a fixed small column count keeps names from over-spreading. */}
|
||||
@@ -291,16 +293,11 @@ function PlaybackChip({ detail, overlay }: { detail: PlexItemDetail; overlay: bo
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs ${
|
||||
overlay ? "bg-white/10 text-white/90" : "bg-surface text-muted"
|
||||
}`}
|
||||
title={t(`plex.playable.${cls}`)}
|
||||
>
|
||||
<Badge onDark={overlay} title={t(`plex.playable.${cls}`)}>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${PLAYABLE_TINT[cls] ?? "bg-gray-400"}`} />
|
||||
<span className="font-medium">{t(`plex.filter.playableOpt.${cls}`)}</span>
|
||||
{sub && <span className="opacity-70">{sub}</span>}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -416,7 +413,7 @@ 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-3 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"}>
|
||||
<div className={overlay ? "" : `${SURFACE.panel} 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">
|
||||
@@ -557,7 +554,8 @@ export default function PlexInfo({
|
||||
{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"}`}
|
||||
tag
|
||||
onDark={overlay}
|
||||
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
|
||||
>
|
||||
{g}
|
||||
@@ -653,7 +651,7 @@ export default function PlexInfo({
|
||||
|
||||
{/* 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" : `${SURFACE.panel} p-4 sm:p-5`}>
|
||||
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
||||
{t("plex.info.cast")}
|
||||
</h2>
|
||||
@@ -729,7 +727,7 @@ 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 key={col.id} className={overlay ? "mt-6" : `${SURFACE.panel} 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}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a
|
||||
// set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist.
|
||||
@@ -153,7 +154,7 @@ export default function PlexPlaylistAdd({
|
||||
aria-checked={all ? true : partial ? "mixed" : false}
|
||||
onClick={() => toggle(p)}
|
||||
disabled={busy}
|
||||
className={`glass-card flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm disabled:opacity-50 ${
|
||||
className={`${SURFACE.card} flex w-full items-center gap-2 px-2 py-1.5 text-sm disabled:opacity-50 ${
|
||||
all ? "text-accent" : ""
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "./ui/sortableA11y";
|
||||
import { Input } from "./ui/Input";
|
||||
import { useReturnFocus } from "./ui/useReturnFocus";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
|
||||
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
|
||||
@@ -147,7 +148,7 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`flex items-center gap-2.5 rounded-lg ${dense ? "px-1.5 py-1" : "glass-card p-1.5"}`}
|
||||
className={`flex items-center gap-2.5 ${dense ? "rounded-lg px-1.5 py-1" : `${SURFACE.card} p-1.5`}`}
|
||||
>
|
||||
<DragHandle
|
||||
label={t("plex.playlist.reorderItem", { title: ep.title })}
|
||||
@@ -202,11 +203,7 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
|
||||
};
|
||||
const dense = ctx.layout === "tree";
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="glass-card flex items-center gap-3 rounded-xl p-2"
|
||||
>
|
||||
<div ref={setNodeRef} style={style} className={`${SURFACE.card} flex items-center gap-3 p-2`}>
|
||||
<DragHandle
|
||||
label={t("plex.playlist.reorderItem", { title: item.title })}
|
||||
{...attributes}
|
||||
@@ -256,7 +253,7 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
|
||||
: t("plex.playlist.seasonUnknown");
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="glass-card overflow-hidden rounded-xl">
|
||||
<div ref={setNodeRef} style={style} className={`${SURFACE.card} overflow-hidden`}>
|
||||
{/* Group header — drag moves the whole show; chevron collapses; X removes the whole show. */}
|
||||
<div className="flex items-center gap-2.5 p-2">
|
||||
<DragHandle
|
||||
@@ -524,7 +521,7 @@ export default function PlexPlaylistView({
|
||||
{t("plex.backToLibrary")}
|
||||
</Button>
|
||||
|
||||
<div className="glass rounded-2xl p-4 sm:p-6">
|
||||
<div className={`${SURFACE.panel} p-4 sm:p-6`}>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
{renaming ? (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { qk } from "../lib/queryKeys";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { availablePlexFields, healPlexField, plexFieldLabelKey } from "../lib/plexSearch";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The Plex header search box: the shared SearchBar enriched with the S2 search scope-pill (a
|
||||
// GitHub-style field selector inside the box) and an instant typeahead of the top matches attached
|
||||
@@ -139,7 +140,7 @@ export default function PlexSearchBox() {
|
||||
id={listId}
|
||||
role="listbox"
|
||||
aria-label={t("plex.suggest.aria")}
|
||||
className="glass-menu absolute left-0 right-0 top-full z-20 mt-2 max-h-[70vh] overflow-y-auto rounded-2xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.menu} absolute left-0 right-0 top-full z-20 mt-2 max-h-[70vh] overflow-y-auto animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{suggestions.length === 0 && suggestQ.isFetching && (
|
||||
<div className="px-3 py-2 text-sm text-muted">{t("plex.loading")}</div>
|
||||
@@ -154,7 +155,7 @@ export default function PlexSearchBox() {
|
||||
onMouseDown={(e) => e.preventDefault()} // keep input focus through the click
|
||||
onMouseEnter={() => setHi(i)}
|
||||
onClick={() => pick(i)}
|
||||
className={`flex w-full items-center gap-3 rounded-xl p-1.5 transition ${
|
||||
className={`flex w-full items-center gap-3 rounded-lg p-1.5 transition ${
|
||||
hi === i ? "bg-border/60" : "hover:bg-border/40"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -12,6 +12,7 @@ import FilterSelect from "./ui/FilterSelect";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { CheckRow } from "./ui/NativeControls";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
|
||||
|
||||
@@ -111,10 +112,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
||||
{t("downloads.profiles.builtin")}
|
||||
</div>
|
||||
{builtins.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg glass-card"
|
||||
>
|
||||
<div key={p.id} className={`${SURFACE.card} flex items-center gap-2 text-sm px-3 py-1.5`}>
|
||||
<Lock className="w-3.5 h-3.5 text-muted shrink-0" />
|
||||
<span className="flex-1 truncate">{p.name}</span>
|
||||
</div>
|
||||
@@ -125,10 +123,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
)}
|
||||
{mine.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg glass-card"
|
||||
>
|
||||
<div key={p.id} className={`${SURFACE.card} flex items-center gap-2 text-sm px-3 py-1.5`}>
|
||||
<span className="flex-1 truncate">{p.name}</span>
|
||||
<IconButton onClick={() => startEdit(p)} label={t("downloads.actions.rename")}>
|
||||
<Pencil />
|
||||
|
||||
@@ -5,19 +5,21 @@ import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { useSavedViews } from "./savedViewsContext";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { SelectTrigger } from "./ui/SelectTrigger";
|
||||
|
||||
// The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as
|
||||
// their trigger: click → a popover list of the user's saved views (apply on click, the active one
|
||||
// checked) + a "Manage views…" entry that opens the Personal Views modal, where the editing lives.
|
||||
export default function SavedViewsPopover({
|
||||
trigger,
|
||||
className,
|
||||
toolbar = false,
|
||||
ariaLabel,
|
||||
placement = "bottom-start",
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
className?: string;
|
||||
/** The toolbar's view chip: the A5 dropdown trigger around `trigger`, instead of a glyph. */
|
||||
toolbar?: boolean;
|
||||
ariaLabel?: string;
|
||||
placement?: "bottom-start" | "bottom-end";
|
||||
}) {
|
||||
@@ -28,16 +30,16 @@ export default function SavedViewsPopover({
|
||||
const label = ariaLabel ?? t("views.saved");
|
||||
return (
|
||||
<>
|
||||
{/* A caller-shaped trigger (the toolbar's view chip) is a surface; the default one a glyph. */}
|
||||
{className ? (
|
||||
<Pressable
|
||||
{toolbar ? (
|
||||
<SelectTrigger
|
||||
context="toolbar"
|
||||
open={open}
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={label}
|
||||
className={className}
|
||||
>
|
||||
{trigger}
|
||||
</Pressable>
|
||||
</SelectTrigger>
|
||||
) : (
|
||||
<IconButton
|
||||
variant="ghost-accent"
|
||||
@@ -51,7 +53,7 @@ export default function SavedViewsPopover({
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={label}
|
||||
className="glass-menu min-w-56 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.menu} min-w-56 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{views.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-2xs leading-snug text-muted">{t("views.empty")}</p>
|
||||
@@ -67,7 +69,7 @@ export default function SavedViewsPopover({
|
||||
setOpen(false);
|
||||
}}
|
||||
label={v.name}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
|
||||
v.id === activeViewId ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
@@ -84,7 +86,7 @@ export default function SavedViewsPopover({
|
||||
// Opens a dialog: close first, or the menu's focus return steals its focus (closeThenAct).
|
||||
onSelect={() => closeThenAct(() => setOpen(false), openModal)}
|
||||
label={t("views.manage")}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-muted transition hover:text-fg"
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm text-muted transition hover:text-fg"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="flex-1">{t("views.manage")}</span>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { notifyActionError } from "../lib/actionErrors";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
const POLL_MS = 4000;
|
||||
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
|
||||
@@ -171,7 +172,7 @@ function JobRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-card rounded-xl p-3 flex items-center gap-3">
|
||||
<div className={`${SURFACE.card} p-3 flex items-center gap-3`}>
|
||||
<StatusDot k={statusKey(job)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium flex items-center gap-1.5 flex-wrap">
|
||||
@@ -296,7 +297,7 @@ function MaintenanceSettings({
|
||||
const dirty = String(m.revalidate_batch) !== val;
|
||||
|
||||
return (
|
||||
<div className="glass-card rounded-xl p-4">
|
||||
<div className={`${SURFACE.card} p-4`}>
|
||||
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||
<Database className="w-4 h-4 text-muted shrink-0" />
|
||||
<Tooltip hint={t("scheduler.maintenance.batchHint")}>
|
||||
@@ -355,7 +356,7 @@ function Stat({
|
||||
sub?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-card rounded-xl p-3">
|
||||
<div className={`${SURFACE.card} p-3`}>
|
||||
<div className="flex items-center gap-2 text-muted text-xs">
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
<Tooltip hint={hint ?? ""}>
|
||||
@@ -485,7 +486,7 @@ export default function Scheduler() {
|
||||
return (
|
||||
<div className="p-4 max-w-4xl w-full mx-auto space-y-4">
|
||||
{/* Status strip */}
|
||||
<div className="glass rounded-2xl p-4 flex flex-wrap items-center gap-3">
|
||||
<div className={`${SURFACE.panel} p-4 flex flex-wrap items-center gap-3`}>
|
||||
<Activity className="w-5 h-5 text-accent shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold">{t("scheduler.title")}</div>
|
||||
@@ -620,7 +621,7 @@ export default function Scheduler() {
|
||||
<div className="text-xs uppercase tracking-wide text-muted mb-2">
|
||||
{t("scheduler.quotaTitle")}
|
||||
</div>
|
||||
<div className="glass-card rounded-xl p-4">
|
||||
<div className={`${SURFACE.card} p-4`}>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Gauge className="w-4 h-4 text-muted shrink-0" />
|
||||
<span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bookmark, ChevronDown } from "lucide-react";
|
||||
import { Bookmark } from "lucide-react";
|
||||
import SavedViewsPopover from "./SavedViewsPopover";
|
||||
import { useSavedViews } from "./savedViewsContext";
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function SelectedViewChip() {
|
||||
return (
|
||||
<SavedViewsPopover
|
||||
ariaLabel={t("views.saved")}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border border-border px-3 py-1 text-sm text-fg transition hover:border-accent/60"
|
||||
toolbar
|
||||
trigger={
|
||||
<>
|
||||
<Bookmark className="h-3.5 w-3.5 shrink-0 text-accent" aria-hidden="true" />
|
||||
@@ -26,7 +26,6 @@ export default function SelectedViewChip() {
|
||||
aria-label={t("views.modified")}
|
||||
/>
|
||||
)}
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useMe } from "../lib/useMe";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { Slider } from "./ui/NativeControls";
|
||||
import { Input, Textarea } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The Settings page edits server-persisted preferences. Each change applies locally for instant
|
||||
// preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the
|
||||
@@ -71,7 +72,7 @@ export default function SettingsPanel() {
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||
<div className="glass rounded-2xl overflow-hidden">
|
||||
<div className={`${SURFACE.panel} overflow-hidden`}>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
{/* Tab rail: a scrolling row above the content on a phone, the side rail from `sm` up (see
|
||||
Tabs). Active is a clear accent pill. */}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { setLanguage, type LangCode } from "../i18n";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// First-run install wizard. Shown by App.tsx while the instance is unconfigured. The one-time
|
||||
// setup token comes from the URL the app printed to the container logs (?token=…); every step
|
||||
@@ -158,7 +159,7 @@ export default function SetupWizard() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-2xl p-6">
|
||||
<div className={`${SURFACE.panel} p-6`}>
|
||||
{step === "intro" && (
|
||||
<Section title={t("setup.intro.title")} desc={t("setup.intro.body")} />
|
||||
)}
|
||||
@@ -330,7 +331,7 @@ function Section({
|
||||
function TokenError() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="max-w-md glass rounded-2xl p-6 text-center">
|
||||
<div className={`${SURFACE.panel} max-w-md p-6 text-center`}>
|
||||
<h2 className="text-lg font-semibold">{t("setup.token.title")}</h2>
|
||||
<p className="text-sm text-muted leading-relaxed mt-2">{t("setup.token.body")}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -12,9 +12,10 @@ import { formatDate } from "../lib/format";
|
||||
import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../lib/api";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { Input, InnerInput } from "./ui/Input";
|
||||
import { CheckRow } from "./ui/NativeControls";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import UserPicker from "./UserPicker";
|
||||
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
|
||||
|
||||
// --- share with a registered user (ACL) -----------------------------------------------------
|
||||
@@ -25,27 +26,13 @@ const NO_RECIPIENTS: ShareRecipient[] = [];
|
||||
|
||||
function UserShare({ job }: { job: DownloadJob }) {
|
||||
const { t } = useTranslation();
|
||||
const [q, setQ] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const recipients = useQuery({ queryKey: qk.shareRecipients(), queryFn: api.shareRecipients });
|
||||
const list = recipients.data ?? NO_RECIPIENTS;
|
||||
const filtered = useMemo(() => {
|
||||
const s = q.trim().toLowerCase();
|
||||
if (!s) return list.slice(0, 8);
|
||||
return list
|
||||
.filter(
|
||||
(u) =>
|
||||
u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s),
|
||||
)
|
||||
.slice(0, 8);
|
||||
}, [q, list]);
|
||||
|
||||
const share = useMutation({
|
||||
mutationFn: (email: string) => api.shareDownload(job.id, email),
|
||||
onSuccess: (r) => {
|
||||
notify({ level: "success", message: t("downloads.share.done", { email: r.shared_with }) });
|
||||
setQ("");
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (e) => notifyActionError(e, t("downloads.share.failed")),
|
||||
});
|
||||
@@ -56,36 +43,15 @@ function UserShare({ job }: { job: DownloadJob }) {
|
||||
<UserPlus className="w-4 h-4 text-muted" /> {t("downloads.share.userSection")}
|
||||
</div>
|
||||
<p className="text-xs text-muted mb-2">{t("downloads.share.userHint")}</p>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="w-full"
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
// Close on blur, but after a tick so a click on a dropdown row still registers.
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
placeholder={t("downloads.share.pickPlaceholder")}
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
|
||||
{filtered.map((u) => (
|
||||
<Pressable
|
||||
key={u.id}
|
||||
layer
|
||||
onClick={() => share.mutate(u.email)}
|
||||
disabled={share.isPending}
|
||||
className="w-full px-3 py-2 text-sm flex flex-col"
|
||||
>
|
||||
<span className="truncate">{u.display_name || u.email}</span>
|
||||
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<UserPicker
|
||||
users={list}
|
||||
loading={recipients.isLoading}
|
||||
onPick={(u) => {
|
||||
if (!share.isPending) share.mutate(u.email);
|
||||
}}
|
||||
label={t("downloads.share.userSection")}
|
||||
placeholder={t("downloads.share.pickPlaceholder")}
|
||||
/>
|
||||
{recipients.data && list.length === 0 && (
|
||||
<p className="text-xs text-muted mt-1">{t("downloads.share.noUsers")}</p>
|
||||
)}
|
||||
@@ -142,7 +108,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg glass-card p-2.5 space-y-2">
|
||||
<div className={`${SURFACE.card} p-2.5 space-y-2`}>
|
||||
<div className="flex items-center gap-2 rounded-md focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
|
||||
<Link2 className="w-4 h-4 text-muted shrink-0" />
|
||||
<InnerInput className="text-xs text-muted truncate" readOnly value={fullUrl} />
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useMe } from "../lib/useMe";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
const RANGES = [7, 30, 90] as const;
|
||||
type StatsTab = "overview" | "system";
|
||||
@@ -275,7 +276,7 @@ function SystemStats() {
|
||||
) : (
|
||||
<>
|
||||
{/* Daily totals (instance-wide, Pacific days) */}
|
||||
<div className="glass-card rounded-xl p-3 mb-4">
|
||||
<div className={`${SURFACE.card} p-3 mb-4`}>
|
||||
<div className="text-xs text-muted mb-2">
|
||||
{t("stats.dailyTotal", {
|
||||
count: data.range_days,
|
||||
@@ -324,7 +325,7 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
|
||||
const actions = Object.entries(row.by_action).sort((a, b) => b[1] - a[1]);
|
||||
const isSystem = row.user_id === null;
|
||||
return (
|
||||
<div className="glass-card rounded-xl p-3">
|
||||
<div className={`${SURFACE.card} p-3`}>
|
||||
<Pressable
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// Per-user status (not the global catalog): shows the number of videos available to *this*
|
||||
// user, how many of *their* channels are still being fetched, and how many lack full
|
||||
@@ -124,7 +125,7 @@ export default function SyncStatus({
|
||||
// Short label only: the popover already explains the numbers, and the long one overflowed
|
||||
// the viewport edge from here.
|
||||
title={countsText}
|
||||
className="glass-menu glass-hover relative flex items-center gap-1.5 px-2.5 h-11 rounded-2xl text-xs text-muted"
|
||||
className={`${SURFACE.floating} glass-hover relative flex h-10 items-center gap-1.5 px-3 text-xs text-muted`}
|
||||
>
|
||||
{active ? (
|
||||
<Loader2 className="w-3.5 h-3.5 shrink-0 animate-spin text-accent" />
|
||||
@@ -140,7 +141,7 @@ export default function SyncStatus({
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={countsText}
|
||||
className="glass-menu w-64 rounded-xl p-3 text-xs text-muted animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.popover} w-64 p-3 text-xs text-muted animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
<div
|
||||
title={t("header.sync.countTooltip")}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Chip } from "./ui/Chip";
|
||||
import { chipClass } from "./ui/chipStyles";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// A single-line row of toggle filter chips that collapses its overflow into a trailing "+N" pill whose
|
||||
// popover holds the rest (still toggleable) — the same compression the feed's applied-filter row uses
|
||||
@@ -121,7 +122,7 @@ export default function TagFilterChips({
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={moreLabel}
|
||||
className="glass-menu flex max-w-[22rem] flex-wrap items-center gap-1.5 rounded-xl p-2 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.popover} flex max-w-[22rem] flex-wrap items-center gap-1.5 p-2 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{chips.slice(visible).map((c) => (
|
||||
<TagChip key={c.key} chip={c} />
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a
|
||||
// hover popover on the count listing the tagged channels (each a link that focuses it in the
|
||||
@@ -73,10 +74,7 @@ function TagRow({
|
||||
<IconButton variant="danger" onClick={onDelete} label={t("tagManager.delete")}>
|
||||
<Trash2 />
|
||||
</IconButton>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
className="glass-menu w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<PopoverSurface popover={pop} className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}>
|
||||
<div className="text-2xs uppercase tracking-wide text-muted px-2 py-1">
|
||||
{t("tagManager.onChannels")}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import Combobox from "./ui/Combobox";
|
||||
|
||||
// Pick one registered user by name or email: the download quota's "any user" picker and the share
|
||||
// dialog's recipient. Both used to hand-roll an input over an absolute list that only a mouse could
|
||||
// reach (no arrow keys, no listbox for a screen reader, closed by a blur timer); this is the shared
|
||||
// Combobox in single-select, which brings all of that. A pick is an ACTION here, not a value to show,
|
||||
// so nothing stays selected: the field clears for the next one.
|
||||
export interface PickableUser {
|
||||
id: number;
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
}
|
||||
|
||||
export default function UserPicker<U extends PickableUser>({
|
||||
users,
|
||||
onPick,
|
||||
label,
|
||||
placeholder,
|
||||
loading,
|
||||
}: {
|
||||
users: readonly U[];
|
||||
onPick: (u: U) => void;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const byId = new Map(users.map((u) => [String(u.id), u]));
|
||||
return (
|
||||
<Combobox
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
multiple={false}
|
||||
loading={loading}
|
||||
selected={[]}
|
||||
// The label carries both, so the client filter matches a name OR an email.
|
||||
options={users.map((u) => ({
|
||||
value: String(u.id),
|
||||
label: u.display_name ? `${u.display_name} · ${u.email}` : u.email,
|
||||
}))}
|
||||
onChange={(next) => {
|
||||
const u = next[0] != null ? byId.get(next[0]) : undefined;
|
||||
if (u) onPick(u);
|
||||
}}
|
||||
renderOption={(o) => {
|
||||
const u = byId.get(o.value);
|
||||
return (
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate">{u?.display_name || u?.email}</span>
|
||||
{u?.display_name && <span className="truncate text-xs text-muted">{u.email}</span>}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { isPlainLeftClick } from "../lib/nav";
|
||||
import ChannelNameLink from "./ChannelNameLink";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Button } from "./ui/Button";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
function Actions({
|
||||
video,
|
||||
@@ -517,7 +518,7 @@ function VideoCard({
|
||||
// re-revealing the ambient backdrop behind them, which reads as the background flickering. The
|
||||
// cards never did this because glass-card always paints. No hover lift here: a 900px-wide row
|
||||
// jumping as the pointer crosses it is a lot more restless than a card doing it.
|
||||
const rowShell = "cv-row group glass-card glass-hover relative rounded-xl transition";
|
||||
const rowShell = `cv-row group ${SURFACE.card} glass-hover relative transition`;
|
||||
|
||||
if (spec.family === "row") {
|
||||
// A table without being one. Every column is a FIXED width, which is what makes the same datum
|
||||
@@ -647,7 +648,7 @@ function VideoCard({
|
||||
{...menuProps?.(video)}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||
className={clsx(
|
||||
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150",
|
||||
`cv-card group ${SURFACE.card} glass-hover transition-all duration-150`,
|
||||
"flex flex-col",
|
||||
spec.dense ? "p-2" : "p-2.5",
|
||||
watched && "opacity-55",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { markReported } from "../lib/api/core";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// mm:ss.d timecode <-> seconds.
|
||||
function tc(s: number): string {
|
||||
@@ -567,7 +568,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
|
||||
{/* selected-segment editor */}
|
||||
{sel && (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm rounded-lg glass-card px-3 py-2">
|
||||
<div className={`${SURFACE.card} flex flex-wrap items-center gap-2 text-sm px-3 py-2`}>
|
||||
<span className="font-medium">{t("editor.segment", { n: selIdx + 1 })}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -10,6 +10,8 @@ import Avatar from "./ui/Avatar";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { Badge } from "./ui/Badge";
|
||||
|
||||
// The feed card's "more than the card face" hover infobox (E8·S3, decision D3): full (un-clamped)
|
||||
// title, channel, views·duration·date, a description snippet, and the channel's tags. It hangs off a
|
||||
@@ -112,7 +114,7 @@ export default function VideoInfoCard({
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("card.info")}
|
||||
className="glass w-85 max-w-[92vw] overflow-y-auto rounded-lg px-3.5 py-3 animate-[fadeIn_0.12s_ease]"
|
||||
className={`${SURFACE.popover} w-85 max-w-[92vw] overflow-y-auto px-3.5 py-3 animate-[fadeIn_0.12s_ease]`}
|
||||
style={{ maxHeight: "min(26rem, var(--fit-max-h, 26rem))" }}
|
||||
>
|
||||
<p className="text-sm font-medium leading-snug wrap-break-word">{video.title}</p>
|
||||
@@ -135,9 +137,7 @@ export default function VideoInfoCard({
|
||||
{tagNames.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{tagNames.map((tg) => (
|
||||
<span key={tg.id} className="glass-card px-2 py-0.5 rounded-full text-2xs">
|
||||
{tg.name}
|
||||
</span>
|
||||
<Badge key={tg.id}>{tg.name}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import clsx from "clsx";
|
||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { SelectTrigger } from "./ui/SelectTrigger";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
export interface ViewOption<T extends string> {
|
||||
id: T;
|
||||
@@ -76,7 +77,7 @@ export default function ViewSwitcher<T extends string>({
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={label}
|
||||
className="glass-menu min-w-44 p-1.5 rounded-xl animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.menu} min-w-44 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{/* The modes are one exclusive choice, so the radio items need a group to scope their
|
||||
checked-ness to — otherwise they read as independent checkable items. */}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
||||
import { LS, readJSON, writeJSON } from "../lib/storage";
|
||||
import { send } from "../lib/http";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { LinkButton } from "./ui/LinkButton";
|
||||
|
||||
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
|
||||
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
|
||||
@@ -265,7 +267,7 @@ export default function WatchPage() {
|
||||
|
||||
{status === "password" && (
|
||||
<div className="flex-1 grid place-items-center">
|
||||
<div className="w-full max-w-sm rounded-2xl glass p-6">
|
||||
<div className={`${SURFACE.panel} w-full max-w-sm p-6`}>
|
||||
<div className="flex items-center gap-2 font-medium mb-4">
|
||||
<Lock className="w-4 h-4 text-accent" /> {T.passwordTitle}
|
||||
</div>
|
||||
@@ -305,7 +307,7 @@ export default function WatchPage() {
|
||||
gating the button on allow_download only mirrors the owner's ACL, matching the media
|
||||
branch's download affordance below. */
|
||||
<div className="flex-1 grid place-items-center">
|
||||
<div className="w-full max-w-md rounded-2xl glass p-6 text-center">
|
||||
<div className={`${SURFACE.panel} w-full max-w-md p-6 text-center`}>
|
||||
<div className="mx-auto w-16 h-16 rounded-xl bg-surface grid place-items-center mb-4">
|
||||
<FileIcon className="w-8 h-8 text-accent" />
|
||||
</div>
|
||||
@@ -318,12 +320,9 @@ export default function WatchPage() {
|
||||
.join(" · ")}
|
||||
</div>
|
||||
{meta.allow_download && (
|
||||
<a
|
||||
href={meta.file_url}
|
||||
className="mt-5 inline-flex items-center gap-1.5 rounded-lg bg-surface hover:bg-card px-4 py-2 text-sm font-medium transition"
|
||||
>
|
||||
<Download className="w-4 h-4" /> {T.download}
|
||||
</a>
|
||||
<LinkButton href={meta.file_url} icon={<Download />} className="mt-5">
|
||||
{T.download}
|
||||
</LinkButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -397,12 +396,9 @@ export default function WatchPage() {
|
||||
{T.repeat}
|
||||
</Chip>
|
||||
{meta.allow_download && (
|
||||
<a
|
||||
href={meta.file_url}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-surface hover:bg-card px-3 py-2 text-sm font-medium transition"
|
||||
>
|
||||
<Download className="w-4 h-4" /> {T.download}
|
||||
</a>
|
||||
<LinkButton href={meta.file_url} icon={<Download />}>
|
||||
{T.download}
|
||||
</LinkButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,8 @@ import { MODE_URL, type Mode } from "./welcomeMode";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { LinkButton } from "./ui/LinkButton";
|
||||
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
const PASSWORD_MIN = 10;
|
||||
@@ -198,7 +200,7 @@ const FEATURES: { key: string; icon: LucideIcon }[] = [
|
||||
function FeatureCard({ icon: Icon, k }: { icon: LucideIcon; k: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="glass-card rounded-2xl p-5">
|
||||
<div className={`${SURFACE.panel} p-5`}>
|
||||
<div className="w-10 h-10 rounded-xl bg-accent/15 text-accent grid place-items-center mb-3">
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
@@ -225,7 +227,7 @@ function Preview({
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (failed) {
|
||||
return (
|
||||
<div className={`glass-card rounded-2xl overflow-hidden ${className ?? ""}`}>
|
||||
<div className={`${SURFACE.card} overflow-hidden ${className ?? ""}`}>
|
||||
<div className="w-full h-full grid place-items-center bg-linear-to-br from-accent/10 to-card text-muted text-sm p-6 text-center">
|
||||
{label}
|
||||
</div>
|
||||
@@ -236,7 +238,7 @@ function Preview({
|
||||
<Pressable
|
||||
onClick={() => onOpen?.(src, label)}
|
||||
aria-label={label}
|
||||
className={`group relative glass-card rounded-2xl overflow-hidden block cursor-zoom-in ${className ?? ""}`}
|
||||
className={`group relative ${SURFACE.card} overflow-hidden block cursor-zoom-in ${className ?? ""}`}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
@@ -566,7 +568,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
const StateIcon = blocked ? BLOCKED_ICON[blocked.reason] : deadReset ? Link2Off : null;
|
||||
|
||||
return (
|
||||
<div className="glass rounded-2xl p-6 sm:p-7 w-full max-w-md lg:justify-self-end">
|
||||
<div className={`${SURFACE.panel} p-6 sm:p-7 w-full max-w-md lg:justify-self-end`}>
|
||||
<h2 className="text-lg font-semibold mb-1 flex items-center gap-2.5">
|
||||
{StateIcon && (
|
||||
<span className="w-8 h-8 rounded-xl bg-warn/15 text-warn grid place-items-center shrink-0">
|
||||
@@ -748,12 +750,9 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
{googleEnabled && (
|
||||
<a
|
||||
href="/auth/login"
|
||||
className="block text-center px-4 py-2.5 rounded-xl text-sm font-semibold bg-card border border-border hover:border-accent transition"
|
||||
>
|
||||
<LinkButton href="/auth/login" size="lg" fullWidth>
|
||||
{t("welcome.auth.google")}
|
||||
</a>
|
||||
</LinkButton>
|
||||
)}
|
||||
{!showDemo ? (
|
||||
<Button
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Chip } from "./ui/Chip";
|
||||
import { Button } from "./ui/Button";
|
||||
import CountBadge from "./ui/CountBadge";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The feed rail's "Year" control (item 8 / E8·S2): quick chips for the most recent years plus a
|
||||
// "More…" popover listing ALL years with per-year counts, sortable by year or count. Selecting a year
|
||||
@@ -133,7 +134,7 @@ export default function YearFilter({
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("sidebar.year.moreLabel")}
|
||||
className="glass-menu min-w-44 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.menu} min-w-44 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 border-b border-border px-1 pb-2">
|
||||
<span className="text-2xs text-muted">{t("sidebar.year.sortBy")}</span>
|
||||
@@ -173,7 +174,7 @@ export default function YearFilter({
|
||||
setOpen(false);
|
||||
}}
|
||||
label={String(y.year)}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -11,9 +11,10 @@ import { Button } from "./ui/Button";
|
||||
import { StatusPill } from "./downloadRow";
|
||||
import { jobTitle } from "./downloadTitle";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { Input } from "./ui/Input";
|
||||
import { CheckRow } from "./ui/NativeControls";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import UserPicker from "./UserPicker";
|
||||
|
||||
// The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job
|
||||
// list. Split out of DownloadCenter (self-contained, admin-only surface).
|
||||
@@ -137,52 +138,17 @@ function QuotaModal({
|
||||
// footprint list below only shows users who already have files).
|
||||
function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [q, setQ] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
|
||||
const list = (users.data ?? []).filter((u) => !u.is_demo);
|
||||
const filtered = useMemo(() => {
|
||||
const s = q.trim().toLowerCase();
|
||||
const base = s
|
||||
? list.filter(
|
||||
(u) =>
|
||||
u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s),
|
||||
)
|
||||
: list;
|
||||
return base.slice(0, 8);
|
||||
}, [q, list]);
|
||||
const list = useMemo(() => (users.data ?? []).filter((u) => !u.is_demo), [users.data]);
|
||||
return (
|
||||
<div className="relative w-full sm:w-72">
|
||||
<Input
|
||||
className="w-full"
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
<div className="w-full sm:w-72">
|
||||
<UserPicker
|
||||
users={list}
|
||||
loading={users.isLoading}
|
||||
onPick={(u) => onPick({ id: u.id, email: u.email })}
|
||||
label={t("downloads.admin.quotaPickPlaceholder")}
|
||||
placeholder={t("downloads.admin.quotaPickPlaceholder")}
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
|
||||
{filtered.map((u) => (
|
||||
<Pressable
|
||||
key={u.id}
|
||||
layer
|
||||
onClick={() => {
|
||||
onPick({ id: u.id, email: u.email });
|
||||
setQ("");
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm flex flex-col"
|
||||
>
|
||||
<span className="truncate">{u.display_name || u.email}</span>
|
||||
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -205,7 +171,7 @@ export function AdminSystem() {
|
||||
s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap"),
|
||||
],
|
||||
].map(([label, val]) => (
|
||||
<div key={label} className="rounded-xl glass-card p-3">
|
||||
<div key={label} className={`${SURFACE.card} p-3`}>
|
||||
<div className="text-xs text-muted">{label}</div>
|
||||
<div className="text-lg font-semibold mt-0.5">{val}</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { failCopyKeys } from "../lib/downloadFailure";
|
||||
import { jobTitle } from "./downloadTitle";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// Download-row presentation + the small shared primitives (status pill, icon button, usage bar),
|
||||
// split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue,
|
||||
@@ -226,7 +227,7 @@ export function DownloadRow({
|
||||
return (
|
||||
<div
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex, jobTitle(job)))}
|
||||
className="flex gap-3 p-2.5 rounded-xl glass-card glass-hover transition"
|
||||
className={`${SURFACE.card} flex gap-3 p-2.5 glass-hover transition`}
|
||||
>
|
||||
<Thumb job={job} />
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -323,7 +324,7 @@ export function UsageBar() {
|
||||
const pct =
|
||||
u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100);
|
||||
return (
|
||||
<div className="rounded-xl glass-card p-3 mb-4">
|
||||
<div className={`${SURFACE.card} p-3 mb-4`}>
|
||||
<div className="flex justify-between text-sm mb-1.5">
|
||||
<span className="font-medium">{t("downloads.usage.title")}</span>
|
||||
<span className="text-muted">
|
||||
|
||||
@@ -13,6 +13,7 @@ import { setLanguage, type LangCode } from "../../i18n";
|
||||
import { formatDate } from "../../lib/format";
|
||||
import { send } from "../../lib/http";
|
||||
import LanguageSwitcher from "../LanguageSwitcher";
|
||||
import { SURFACE } from "../ui/surfaces";
|
||||
|
||||
// The day the text last changed materially — bump it with every edit of either body.
|
||||
const LAST_UPDATED = "2026-09-17";
|
||||
@@ -86,7 +87,7 @@ export default function LegalLayout({ title, children }: { title: string; childr
|
||||
{/* The landing page's own picker, so the public pages switch language one way. */}
|
||||
<LanguageSwitcher value={i18n.language as LangCode} onChange={setLanguage} />
|
||||
</div>
|
||||
<div className="glass rounded-2xl p-8">
|
||||
<div className={`${SURFACE.panel} p-8`}>
|
||||
<h1 className="text-2xl font-semibold">{title}</h1>
|
||||
<p className="text-xs text-muted mt-1 mb-6">
|
||||
{t("legal.lastUpdated", { date: formatDate(LAST_UPDATED, i18n.language) })}
|
||||
|
||||
@@ -15,6 +15,7 @@ import NavGrid from "./ui/NavGrid";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
|
||||
// collections have no movie runtime).
|
||||
@@ -108,7 +109,7 @@ function NewCollectionPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-card mb-6 rounded-2xl p-4">
|
||||
<div className={`${SURFACE.panel} mb-6 p-4`}>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold">{t("plex.collection.newCollection")}</h3>
|
||||
<IconButton size="md" onClick={onClose} label={t("common.close")}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { navItemAttrs } from "../lib/listNav";
|
||||
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
|
||||
// state). Rendered by the browse grid, the person page, and the series show/season views.
|
||||
@@ -79,7 +80,7 @@ export function PlexPosterCard({
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer"
|
||||
className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200 cursor-pointer`}
|
||||
>
|
||||
<img
|
||||
src={card.thumb}
|
||||
@@ -260,7 +261,7 @@ export function SeasonCard({
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer"
|
||||
className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200 cursor-pointer`}
|
||||
>
|
||||
<img
|
||||
src={se.thumb}
|
||||
@@ -348,7 +349,7 @@ export function CastStrip({
|
||||
const clickable = !!(onOpenPerson || onFilter);
|
||||
return (
|
||||
<section
|
||||
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
|
||||
className={`${SURFACE.panel} p-4 sm:p-5 mb-6`}
|
||||
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||
>
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
|
||||
@@ -423,7 +424,7 @@ export function RelatedStrip({
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section
|
||||
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
|
||||
className={`${SURFACE.panel} p-4 sm:p-5 mb-6`}
|
||||
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||
>
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
|
||||
@@ -436,7 +437,9 @@ export function RelatedStrip({
|
||||
onClick={() => onOpen(r.id)}
|
||||
className="group w-32.5 shrink-0"
|
||||
>
|
||||
<div className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200">
|
||||
<div
|
||||
className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200`}
|
||||
>
|
||||
<img
|
||||
src={r.thumb}
|
||||
alt=""
|
||||
|
||||
@@ -12,6 +12,9 @@ import { useDismiss } from "../lib/useDismiss";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { Badge } from "./ui/Badge";
|
||||
import { Chip } from "./ui/Chip";
|
||||
|
||||
// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages,
|
||||
// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two
|
||||
@@ -25,13 +28,27 @@ export function Filterable({
|
||||
onClick,
|
||||
title,
|
||||
className,
|
||||
tag = false,
|
||||
onDark = false,
|
||||
children,
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
title?: string;
|
||||
className?: string;
|
||||
/** A genre / keyword tag rather than a run of text: an edged chip when it filters, a flat badge
|
||||
* when it doesn't (E28 S3d: edged = pressable). */
|
||||
tag?: boolean;
|
||||
onDark?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (tag)
|
||||
return onClick ? (
|
||||
<Chip variant="token" onClick={onClick} title={title}>
|
||||
{children}
|
||||
</Chip>
|
||||
) : (
|
||||
<Badge onDark={onDark}>{children}</Badge>
|
||||
);
|
||||
if (!onClick) return <span className={className}>{children}</span>;
|
||||
return (
|
||||
<Pressable
|
||||
@@ -99,7 +116,7 @@ export function DetailCustomizeMenu({
|
||||
{open && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="glass-menu absolute right-0 top-full z-menu mt-1 w-52 rounded-xl p-1.5 text-sm"
|
||||
className={`${SURFACE.menu} absolute right-0 top-full z-menu mt-1 w-52 text-sm`}
|
||||
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
|
||||
>
|
||||
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
|
||||
@@ -225,7 +242,7 @@ export function DetailHero({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
|
||||
<div className={`${SURFACE.panel} relative p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5`}>
|
||||
<div className="absolute right-3 top-3 z-base">{customizeMenu}</div>
|
||||
<Pressable
|
||||
onClick={onPosterClick}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { Chip } from "./ui/Chip";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
|
||||
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
|
||||
@@ -186,7 +187,7 @@ export function PlexPersonView({
|
||||
) : (
|
||||
<div className="p-4">
|
||||
{/* Hero: headshot + name + TMDB bio/birth facts (E5 S6) + a "filter the library" action. */}
|
||||
<section className="glass-card rounded-2xl p-4 sm:p-5 mb-6">
|
||||
<section className={`${SURFACE.panel} p-4 sm:p-5 mb-6`}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
|
||||
{d.photo ? (
|
||||
@@ -445,7 +446,7 @@ export function PlexShowView({
|
||||
{show.genres.map((g) => (
|
||||
<Filterable
|
||||
key={g}
|
||||
className="text-2xs rounded-full bg-surface px-2 py-0.5 text-muted"
|
||||
tag
|
||||
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
|
||||
>
|
||||
{g}
|
||||
@@ -501,7 +502,7 @@ export function PlexShowView({
|
||||
|
||||
{/* Seasons — in a glassy panel like the movie info-page strips. */}
|
||||
<section
|
||||
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
|
||||
className={`${SURFACE.panel} p-4 sm:p-5 mb-6`}
|
||||
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||
>
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.series.seasons")}</h2>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
||||
import { closeThenAct, useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { Pressable } from "./Pressable";
|
||||
import { SURFACE } from "./surfaces";
|
||||
|
||||
// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
|
||||
// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
|
||||
@@ -17,7 +18,7 @@ import { Pressable } from "./Pressable";
|
||||
|
||||
/** Shared surface paint for every menu built on this file — the ⋯ menu, its submenus, and
|
||||
* `ContextMenu`. One constant so a context menu can never look like a different component. */
|
||||
export const MENU_SURFACE = "glass-menu min-w-52 rounded-xl p-1.5 animate-[popIn_0.16s_ease]";
|
||||
export const MENU_SURFACE = `${SURFACE.menu} min-w-52 animate-[popIn_0.16s_ease]`;
|
||||
// No `disabled:opacity-50` on the row itself: a disabled row may carry a `note`, and opacity on the
|
||||
// row would dim the one line that explains it. The icon and the label dim instead (`dim`).
|
||||
const ROW =
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable } from "./Pressable";
|
||||
import { SURFACE } from "./surfaces";
|
||||
|
||||
// A floating "back to top" button that fades in once the current page's scroll container is
|
||||
// scrolled past a threshold, and smooth-scrolls it back to the top. Every page scrolls inside a
|
||||
@@ -56,7 +57,7 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
|
||||
<Pressable
|
||||
align="center"
|
||||
onClick={toTop}
|
||||
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center size-12 rounded-full glass-card glass-hover text-fg hover:text-accent transition-all duration-200 ${
|
||||
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center size-12 ${SURFACE.floating} glass-hover text-fg hover:text-accent transition-all duration-200 ${
|
||||
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import clsx from "clsx";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// A static label (a topic, a keyword, a genre, a status): flat and tonal, with NO edge (owner, E28 S3d).
|
||||
// Every chip that does something has an edge, so "edged = pressable, flat = just says" holds at a
|
||||
// glance; a static tag painted like a chip is clicked and does nothing. The tint is the text colour
|
||||
// at 8%, so it reads on the dark schemes and on white alike; `onDark` is the always-dark player's.
|
||||
export function Badge({
|
||||
onDark = false,
|
||||
title,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
onDark?: boolean;
|
||||
title?: string;
|
||||
/** Layout only (margin); the paint is the primitive's. */
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
title={title}
|
||||
className={clsx(
|
||||
"inline-flex h-5 max-w-full shrink-0 items-center gap-1 truncate rounded-full px-2 text-xs whitespace-nowrap [&>svg]:size-3 [&>svg]:shrink-0",
|
||||
onDark ? "bg-white/10 text-white/90" : "bg-fg/8 text-muted",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { ButtonHTMLAttributes, ReactNode, RefAttributes } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { FocusSafeButton } from "./FocusSafeButton";
|
||||
import { buttonClass, isLinkVariant, type ButtonSize, type ButtonVariant } from "./buttonStyles";
|
||||
|
||||
// The ONE action-button primitive: a labelled action (Save, Cancel, Subscribe, Delete, "Show all").
|
||||
// docs/DESIGN.md decides its look (A1 shape, B1 ladder, B3 state layers); this file is where that
|
||||
@@ -14,77 +15,6 @@ import { FocusSafeButton } from "./FocusSafeButton";
|
||||
// No focus-visible here on purpose: @layer base in index.css already rings every <button> for keyboard
|
||||
// users. Adding one would double the ring.
|
||||
|
||||
type ButtonVariant =
|
||||
| "primary" // solid accent — the main affirmative action
|
||||
| "danger" // solid danger — destructive confirm
|
||||
| "warn" // solid warn — the action of a warning bar (reload for a new version)
|
||||
| "outline" // bordered, quiet — secondary action (Full history, Subscribe, Cancel-with-border)
|
||||
| "outline-accent" // bordered + accent-tinted — an affirmative secondary (Activate, Approve)
|
||||
| "outline-danger" // neutral at rest, danger on hover — a row-level Delete
|
||||
| "outline-warn" // bordered + warn-tinted — a reversible restriction that is currently ON
|
||||
| "ghost" // borderless — tertiary/dismiss (dialog Cancel, close)
|
||||
| "glass" // glass card — the Plex/overlay action buttons (A1: glass is a variant, not a hand-built one)
|
||||
| "overlay" // white text on the always-dark player chrome, whatever the theme
|
||||
| "media" // dark translucent on a picture — "From the start" on a card (C5); always round
|
||||
| "danger-soft" // bordered AND danger-coloured at rest — a destructive action that shouldn't shout
|
||||
| "link" // inline accent text — "Show all", "Learn more", a resend-mail prompt inside a sentence
|
||||
| "link-muted"; // inline quiet text that brightens on hover — "Clear", "Show less", "Skip"
|
||||
|
||||
type ButtonSize =
|
||||
| "xs" // 24 — inside a table row
|
||||
| "sm" // 28 — toolbars and panel headers; lines up with the search pill and the dropdown trigger
|
||||
| "md" // 32 — forms and dialog footers
|
||||
| "lg" // 40 — a page's single hero call to action (Welcome, onboarding), nowhere else
|
||||
| "none"; // no box of its own — the link variants' default
|
||||
|
||||
// B3: every hover and press is a LAYER (the `state-*` utilities in index.css), never an opacity dim or
|
||||
// a `bg-card` fill, which vanish on a light card. The utilities already stand down on a disabled, busy
|
||||
// or pressed button; the text-colour hovers here carry the same guard as a variant.
|
||||
const ACT = "enabled:not-aria-disabled:";
|
||||
const VARIANTS: Record<ButtonVariant, string> = {
|
||||
primary: "font-medium state-filled",
|
||||
danger: "font-medium state-danger",
|
||||
warn: "font-medium state-warn",
|
||||
outline: `font-medium border border-border text-muted state-layer ${ACT}hover:text-fg`,
|
||||
"outline-accent": "font-medium border border-accent/40 text-accent state-layer",
|
||||
// Neutral until you reach for it. NOT `danger-soft`, which is already red at rest — the difference
|
||||
// is whether the button should advertise the destruction before the pointer arrives.
|
||||
"outline-danger": `font-medium border border-border text-muted state-layer ${ACT}hover:text-danger ${ACT}hover:border-danger/40`,
|
||||
"outline-warn": "font-medium border border-warn/40 text-warn state-layer",
|
||||
ghost: `font-medium text-muted state-layer ${ACT}hover:text-fg`,
|
||||
glass: `font-medium glass-card glass-hover ${ACT}hover:text-accent`,
|
||||
// The player chrome is dark in the light theme too: a theme-fg state layer would be a dark wash.
|
||||
overlay: `font-medium text-white/80 ${ACT}hover:text-white ${ACT}hover:bg-white/15`,
|
||||
media: `font-medium bg-black/60 text-white ${ACT}hover:bg-black/75`,
|
||||
"danger-soft": `font-medium border border-danger/60 text-danger state-layer ${ACT}hover:border-danger`,
|
||||
link: `font-semibold text-accent ${ACT}hover:underline`,
|
||||
"link-muted": `font-medium text-muted ${ACT}hover:text-fg`,
|
||||
};
|
||||
|
||||
// A pressed toggle (`pressed`) is the solid accent in every variant (B3: "selected stays the solid
|
||||
// accent"); the layer utilities leave it alone, so it stays solid under the pointer too.
|
||||
const PRESSED =
|
||||
"aria-pressed:bg-accent aria-pressed:text-accent-fg aria-pressed:border-accent aria-pressed:no-underline";
|
||||
|
||||
// B1, the whole ladder: explicit heights, so a toolbar's `sm` button, search pill, dropdown trigger and
|
||||
// small icon button all line up at 28. A call site picks a rung; it does not invent one. rem-derived, so
|
||||
// the Settings text-size slider (--font-scale) grows the box with the label. A1: r8 on every rung; a
|
||||
// button on a picture, or nested in a pill (the header search's Go), is a pill instead (A2: round
|
||||
// means "floats over something", and a pill's inner button follows its radius).
|
||||
const SIZES: Record<ButtonSize, string> = {
|
||||
xs: "h-6 px-2 text-xs gap-1 whitespace-nowrap",
|
||||
sm: "h-7 px-2.5 text-sm gap-1.5 whitespace-nowrap",
|
||||
md: "h-8 px-3 text-sm gap-1.5 whitespace-nowrap",
|
||||
lg: "h-10 px-4 text-base gap-2 whitespace-nowrap",
|
||||
none: "gap-1.5",
|
||||
};
|
||||
|
||||
// The font-weight is owned by each VARIANT, not by BASE: `link` wants semibold (that is what a text
|
||||
// link looks like everywhere in this app) and the action buttons want medium. Two font-* utilities on
|
||||
// one element is a coin flip without tailwind-merge, so exactly one has to emit it — see the test.
|
||||
const BASE =
|
||||
"inline-flex shrink-0 items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed [&>svg]:size-4 [&>svg]:shrink-0";
|
||||
|
||||
interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
/** Defaults to "md", except for the link variants, whose default is "none" (a link owns no box). */
|
||||
@@ -110,8 +40,6 @@ interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
type NamedSomehow = { label: string } | { "aria-label": string } | { title: string };
|
||||
export type ButtonProps = ButtonOwnProps & ({ children: ReactNode } | NamedSomehow);
|
||||
|
||||
const isLink = (v: ButtonVariant) => v === "link" || v === "link-muted";
|
||||
|
||||
/** The shared action button. `type` defaults to "button" so it never accidentally submits a form. */
|
||||
const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button(
|
||||
{
|
||||
@@ -137,11 +65,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
|
||||
// A link must not inherit the action-button box, so its size default differs. Resolved here rather
|
||||
// than by asking every call site to pass `size="none"` alongside a link variant — a pairing everyone
|
||||
// would eventually forget, which is how a "link" grows a button's box.
|
||||
const resolvedSize: ButtonSize = size ?? (isLink(variant) ? "none" : "md");
|
||||
// The coarse-pointer target (DESIGN.md A3) lives HERE, not in the call sites. A link is excluded on
|
||||
// purpose — it is a run of text inside a sentence, and a 44px-tall inline link would wreck the
|
||||
// paragraph it sits in rather than make anything easier to hit.
|
||||
const touchTarget = !isLink(variant);
|
||||
const resolvedSize: ButtonSize = size ?? (isLinkVariant(variant) ? "none" : "md");
|
||||
const leading = loading ? <Loader2 className="animate-spin" /> : icon;
|
||||
return (
|
||||
<FocusSafeButton
|
||||
@@ -156,13 +80,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
|
||||
aria-pressed={pressed}
|
||||
title={label}
|
||||
className={clsx(
|
||||
BASE,
|
||||
VARIANTS[variant],
|
||||
pressed !== undefined && PRESSED,
|
||||
SIZES[resolvedSize],
|
||||
resolvedSize !== "none" && (round || variant === "media" ? "rounded-full" : "rounded-lg"),
|
||||
touchTarget && "touch-target",
|
||||
fullWidth && "w-full",
|
||||
buttonClass({ variant, size: resolvedSize, round, fullWidth, pressed }),
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
import { useScrollFade } from "../../lib/useScrollFade";
|
||||
import { InnerInput } from "./Input";
|
||||
import { fieldClass } from "./fieldStyles";
|
||||
import { SURFACE } from "./surfaces";
|
||||
|
||||
// The universal combobox: a react-select / Overseerr-style "combi input" — selected values are
|
||||
// removable chips that live INSIDE a text field you type into, and a checklist of options opens below.
|
||||
@@ -240,7 +241,7 @@ export default function Combobox({
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={label}
|
||||
className="glass-menu w-full rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.menu} w-full animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{/* The scroll box owns the height cap AND the scroll-fade mask (ref+style on the same element). */}
|
||||
<div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar">
|
||||
@@ -262,7 +263,7 @@ export default function Combobox({
|
||||
disabled={o.disabled}
|
||||
onSelect={() => commit(o.value)}
|
||||
label={o.label ?? o.value}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { alignClass } from "./tableAlign";
|
||||
import { filterRows, type Column, type FilterMap } from "../../lib/tableColumns";
|
||||
import { navItemAttrs, useListNav } from "../../lib/listNav";
|
||||
import { useMediaQuery } from "../../lib/useMediaQuery";
|
||||
import { SURFACE } from "./surfaces";
|
||||
|
||||
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
|
||||
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
|
||||
@@ -323,7 +324,7 @@ export default function DataTable<T>({
|
||||
key={rowKey(row)}
|
||||
{...rowProps?.(row)}
|
||||
{...(keyboardNav && navItemAttrs(i))}
|
||||
className={`glass-card rounded-xl p-3 ${rowClassName?.(row) ?? ""}`}
|
||||
className={`${SURFACE.card} p-3 ${rowClassName?.(row) ?? ""}`}
|
||||
>
|
||||
{columns
|
||||
.filter((c) => c.cardPrimary)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check, RotateCcw, Save } from "lucide-react";
|
||||
import { Button } from "./Button";
|
||||
import { SURFACE } from "./surfaces";
|
||||
|
||||
// The Save / Discard bar shown while an edited draft has unsaved changes (Settings prefs,
|
||||
// admin Configuration). Two visual variants share one state machine + markup:
|
||||
@@ -38,7 +39,7 @@ export function DraftSaveBar({
|
||||
const saving = state === "saving";
|
||||
const floating = variant === "floating";
|
||||
const wrap = floating
|
||||
? "fixed bottom-4 left-1/2 -translate-x-1/2 z-rail glass rounded-2xl flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]"
|
||||
? `fixed bottom-4 left-1/2 -translate-x-1/2 z-rail ${SURFACE.panel} flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]`
|
||||
: "flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40";
|
||||
// The floating variant is fixed to the viewport but renders from a page inside the page
|
||||
// scroller, so it goes to <body> — otherwise the scroller's stacking context (its edge-fade
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Check } from "lucide-react";
|
||||
import { SelectTrigger } from "./SelectTrigger";
|
||||
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
import { SURFACE } from "./surfaces";
|
||||
|
||||
// A compact "labeled pill → dropdown" filter control: the trigger reads `Label: value ▾`, and clicking
|
||||
// it opens a menu of options (facet counts optional). It replaces a wall of individual toolbar chips
|
||||
@@ -107,7 +108,7 @@ export default function FilterSelect({
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={label}
|
||||
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.menu} min-w-52 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{/* Single = one exclusive choice (radios), multi = independent toggles (checkboxes). Both are
|
||||
menuitem-family roles scoped by a group, so AT announces the checked state — the surface is a
|
||||
@@ -124,7 +125,7 @@ export default function FilterSelect({
|
||||
onSelect={() => commit(o.value)}
|
||||
label={o.label}
|
||||
data-testid={testId ? `${testId}-${o.value}` : undefined}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { FocusSafeButton } from "./FocusSafeButton";
|
||||
import { iconButtonClass, type IconButtonSize, type IconButtonVariant } from "./buttonStyles";
|
||||
|
||||
// A button that is only a glyph (docs/DESIGN.md A2): two sizes, r8, and a name it cannot ship without
|
||||
// (`label` is required: it is the accessible name and the tooltip). On a picture it is round and dark
|
||||
@@ -10,55 +11,6 @@ import { FocusSafeButton } from "./FocusSafeButton";
|
||||
// pill field it sits inside — `round`), and nothing else. The glyph
|
||||
// is sized here, so a row of them never mixes 14 and 16px icons.
|
||||
|
||||
type IconButtonVariant =
|
||||
| "ghost" // the default: quiet glyph, a state layer on hover
|
||||
| "ghost-accent" // an accent glyph: the action it stands for is already in effect (queued, requested)
|
||||
| "primary" // solid accent — the one affirmative action of a compact form (Send, Save view)
|
||||
| "outline" // bordered — an icon action that stands on its own in a form or a header
|
||||
| "outline-accent" // bordered + accent — an affirmative row action (Approve)
|
||||
| "outline-danger" // bordered, danger on hover — a row's destructive action beside bordered siblings
|
||||
| "outline-warn" // bordered + warn — a reversible restriction that is currently ON (Suspended)
|
||||
| "danger" // quiet at rest, danger on hover — a row's delete/remove
|
||||
| "danger-soft" // bordered AND danger at rest — a destructive restriction that is ON (Blocked)
|
||||
| "glass" // glass card — beside the glass Buttons on a Plex page
|
||||
| "media" // round, dark translucent — on a thumbnail or a poster
|
||||
| "overlay"; // white glyph on the always-dark player chrome, whatever the theme
|
||||
|
||||
type IconButtonSize =
|
||||
| "sm" // 28 — rows and dense toolbars
|
||||
| "md" // 32 — on its own
|
||||
| "lg" // 48 — only the Play in the middle of a picture (C5: a not-started card)
|
||||
| "player"; // 36 with a 20px glyph, 44 on touch (A3) — the Plex player's control row (C5)
|
||||
|
||||
const ACT = "enabled:not-aria-disabled:";
|
||||
const VARIANTS: Record<IconButtonVariant, string> = {
|
||||
ghost: `text-muted state-layer ${ACT}hover:text-fg`,
|
||||
"ghost-accent": "text-accent state-layer",
|
||||
primary: "state-filled",
|
||||
outline: `border border-border text-muted state-layer ${ACT}hover:text-fg`,
|
||||
"outline-accent": "border border-accent/40 text-accent state-layer",
|
||||
"outline-danger": `border border-border text-muted state-layer ${ACT}hover:text-danger ${ACT}hover:border-danger/40`,
|
||||
"outline-warn": "border border-warn/40 text-warn state-layer",
|
||||
danger: `text-muted state-layer ${ACT}hover:text-danger`,
|
||||
"danger-soft": `border border-danger/60 text-danger state-layer ${ACT}hover:border-danger`,
|
||||
glass: `glass-card glass-hover ${ACT}hover:text-accent`,
|
||||
// The player chrome and a thumbnail are dark in the light theme too, so these two paint with
|
||||
// white and black, not with the theme's fg (whose state layer would be a dark wash on a dark video).
|
||||
media: `bg-black/60 text-white ${ACT}hover:bg-black/75`,
|
||||
overlay: `text-white/80 ${ACT}hover:text-white ${ACT}hover:bg-white/15`,
|
||||
};
|
||||
|
||||
// An icon toggle that is on shows it in the glyph (watched, saved, a selected track), not as a solid
|
||||
// fill: a solid accent square in a row of quiet glyphs outshouts the row's primary action.
|
||||
const PRESSED = "aria-pressed:text-accent";
|
||||
|
||||
const SIZES: Record<IconButtonSize, string> = {
|
||||
sm: "size-7 [&>svg]:size-4",
|
||||
md: "size-8 [&>svg]:size-4",
|
||||
lg: "size-12 [&>svg]:size-6",
|
||||
player: "size-9 [&>svg]:size-5",
|
||||
};
|
||||
|
||||
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
||||
/** The accessible name and the tooltip. Required: the button renders no text. */
|
||||
label: string;
|
||||
@@ -100,14 +52,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
||||
aria-label={label}
|
||||
aria-pressed={pressed}
|
||||
title={noTitle ? undefined : label}
|
||||
className={clsx(
|
||||
"inline-grid shrink-0 place-items-center transition touch-target aria-expanded:text-accent disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
|
||||
VARIANTS[variant],
|
||||
round || variant === "media" ? "rounded-full" : "rounded-lg",
|
||||
pressed !== undefined && PRESSED,
|
||||
SIZES[size],
|
||||
className,
|
||||
)}
|
||||
className={clsx(iconButtonClass({ variant, size, round, pressed }), className)}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" /> : children}
|
||||
|
||||
@@ -6,8 +6,9 @@ import { formatNumber } from "../../lib/format";
|
||||
// the pill shows "1M+" and the tooltip carries the precise number.
|
||||
const OVERFLOW_AT = 1_000_000;
|
||||
|
||||
// A compact, FIXED-WIDTH count pill for a toolbar: the filtered item count in the same glass pill as
|
||||
// the filter dropdowns, minus the chevron and the interactivity. The fixed width is the point — a
|
||||
// A compact, FIXED-WIDTH count pill for a toolbar: the filtered item count at the toolbar's 28, flat
|
||||
// and tonal like a `Badge` — no edge, because it does nothing (E28 S3d: edged = pressable, flat = just
|
||||
// says; it used to wear the filter dropdowns' glass pill and read as one more). The fixed width is the point — a
|
||||
// changing number must never shove the controls beside it, so the box is sized to the widest value
|
||||
// ("999,999 items") and the content is centred within it. A count of a million or more collapses to
|
||||
// "1M+", with the exact figure in the title tooltip. Deliberately generic ("item(s)", dynamic plural)
|
||||
@@ -27,7 +28,7 @@ export default function ItemCount({ count }: { count?: number }) {
|
||||
<span
|
||||
data-testid="item-count"
|
||||
title={count == null ? undefined : formatNumber(count)}
|
||||
className="glass-card inline-flex shrink-0 items-center justify-center gap-1.5 rounded-full border border-border px-3 py-1 text-sm text-muted min-w-[9rem] whitespace-nowrap"
|
||||
className="inline-flex h-7 min-w-[9rem] shrink-0 items-center justify-center gap-1.5 rounded-full bg-fg/8 px-3 text-sm whitespace-nowrap text-muted"
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
<span className="tabular-nums">{label}</span>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import clsx from "clsx";
|
||||
import type { AnchorHTMLAttributes, ReactNode } from "react";
|
||||
import {
|
||||
buttonClass,
|
||||
iconButtonClass,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
type IconButtonSize,
|
||||
type IconButtonVariant,
|
||||
} from "./buttonStyles";
|
||||
|
||||
// A link that looks like a button (a download, a sign-in that leaves the app). It stays an <a> — the
|
||||
// browser's own open-in-new-tab, copy-link and download behaviour, and the right role for a screen
|
||||
// reader — and wears the `Button` / `IconButton` face from buttonStyles, so it can't drift from the
|
||||
// buttons beside it. A link in a sentence is a plain `<a>` (or `Button variant="link"` for an action).
|
||||
|
||||
type Anchor = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "className" | "children">;
|
||||
|
||||
export function LinkButton({
|
||||
variant = "outline",
|
||||
size = "md",
|
||||
icon,
|
||||
fullWidth,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: Anchor & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
/** Leading icon (a lucide element; sized here). */
|
||||
icon?: ReactNode;
|
||||
fullWidth?: boolean;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
className={clsx(buttonClass({ variant, size, fullWidth, element: "link" }), className)}
|
||||
{...rest}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** The glyph-only form: `label` is the accessible name and the tooltip, as on `IconButton`. */
|
||||
export function IconLink({
|
||||
label,
|
||||
variant = "ghost",
|
||||
size = "sm",
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: Anchor & {
|
||||
label: string;
|
||||
variant?: IconButtonVariant;
|
||||
size?: IconButtonSize;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={clsx(iconButtonClass({ variant, size, element: "link" }), className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { trapTab, useDialogChrome } from "./dialogFocus";
|
||||
import { IconButton } from "./IconButton";
|
||||
import Toaster from "./Toaster";
|
||||
import { useOverlayOrder } from "./overlayOrder";
|
||||
import { SURFACE } from "./surfaces";
|
||||
|
||||
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
|
||||
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
|
||||
@@ -114,8 +115,8 @@ export default function Modal({
|
||||
tabIndex={-1}
|
||||
className={
|
||||
drawer
|
||||
? `glass relative w-full ${maxWidth} h-full overflow-y-auto rounded-none sm:rounded-l-2xl outline-none overlay-drawer`
|
||||
: `glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`
|
||||
? `${SURFACE.drawer} relative w-full ${maxWidth} h-full overflow-y-auto rounded-none sm:rounded-l-2xl outline-none overlay-drawer`
|
||||
: `${SURFACE.panel} relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 p-5 pb-3">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSortableItem } from "./sortableA11y";
|
||||
import { DragHandle } from "./DragHandle";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { Pressable } from "./Pressable";
|
||||
import { SURFACE } from "./surfaces";
|
||||
|
||||
// One "island" card inside a SidePanel: a titled glass-card, and — in the panel's edit mode — a drag
|
||||
// handle (reorder) and a hide toggle. Generalized from the feed sidebar's WidgetCard so all three
|
||||
@@ -55,7 +56,7 @@ export default function PanelGroup({
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`glass-card rounded-xl ${
|
||||
className={`${SURFACE.card} ${
|
||||
isDragging ? "relative z-base shadow-2xl ring-1 ring-accent" : ""
|
||||
} ${hidden && editing ? "opacity-50" : ""}`}
|
||||
>
|
||||
|
||||
@@ -36,13 +36,16 @@ domain-free.
|
||||
|
||||
## What lives here
|
||||
|
||||
| area | components |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
|
||||
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
||||
| area | components |
|
||||
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
|
||||
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the |
|
||||
|
||||
B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a
|
||||
link wearing the button face) over `buttonStyles`, `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
||||
|
||||
## The size contract (E10 S1c)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useScrollFade } from "../../lib/useScrollFade";
|
||||
import { useHoverFocusWithin } from "../../lib/useHoverFocusWithin";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { Pressable } from "./Pressable";
|
||||
import { SURFACE } from "./surfaces";
|
||||
|
||||
// The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read as
|
||||
// one system, and the same peek-open model as the nav rail: unpinned it sits as a small tab docked
|
||||
@@ -77,7 +78,7 @@ export default function SidePanel({
|
||||
{...peek.hoverHandlers}
|
||||
onClick={onToggleCollapse}
|
||||
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
|
||||
className={`glass glass-hover absolute top-3 left-0 z-paneltab flex flex-col items-center gap-2 px-1.5 py-3 rounded-l-none rounded-r-xl transition-opacity ${
|
||||
className={`${SURFACE.edgeTab} glass-hover absolute top-3 left-0 z-paneltab flex flex-col items-center gap-2 px-1.5 py-3 transition-opacity ${
|
||||
expanded ? "opacity-0" : ""
|
||||
} ${count > 0 ? "ring-1 ring-accent/40" : ""}`}
|
||||
>
|
||||
@@ -98,7 +99,7 @@ export default function SidePanel({
|
||||
{expanded && (
|
||||
<aside
|
||||
{...peek.hoverHandlers}
|
||||
className="glass absolute top-3 bottom-3 left-0 z-panel w-64 rounded-2xl flex flex-col overflow-hidden"
|
||||
className={`${SURFACE.panel} absolute top-3 bottom-3 left-0 z-panel w-64 flex flex-col overflow-hidden`}
|
||||
>
|
||||
{/* Row 1 — identity only: pin, icon, title (flexes + truncates last), count, customize.
|
||||
Module actions live on their own row below so the title always has room. */}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SelectTrigger } from "./SelectTrigger";
|
||||
import { pickSort, type SortOption, type SortState } from "../../lib/columnSort";
|
||||
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
import { SURFACE } from "./surfaces";
|
||||
|
||||
// The one sort control for every content surface (YT feed, Plex library, channel manager + its card
|
||||
// view, channel discovery, playlists): a labeled dropdown pill matching FilterSelect — `Label: key ↓ ▾`
|
||||
@@ -93,7 +94,7 @@ export default function SortControl({
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={label}
|
||||
className="glass-menu min-w-56 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.menu} min-w-56 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{/* One exclusive choice of sort key (radios), scoped by a group so AT announces which is
|
||||
checked — the surface is a menu (see the mode above), never a listbox with orphaned options. */}
|
||||
@@ -107,7 +108,7 @@ export default function SortControl({
|
||||
setOpen(false); // "no sort" is a final choice — nothing left to direct
|
||||
}}
|
||||
label={defaultLabel}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
|
||||
active === null ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
@@ -126,7 +127,7 @@ export default function SortControl({
|
||||
checked={on}
|
||||
onSelect={() => onChange(pickSort(options, active, o.key))} // keep open to set direction
|
||||
label={o.label}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useOverlayOrder } from "./overlayOrder";
|
||||
import { LEVEL_STYLE } from "./toastLevels";
|
||||
import { Button } from "./Button";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { SURFACE } from "./surfaces";
|
||||
|
||||
// Overlay hosts. The app's own Toaster sits in the content column, and that column is a stacking
|
||||
// context (its `@container` implies layout containment) — so anything portalled to <body> at the
|
||||
@@ -158,7 +159,7 @@ export default function Toaster({
|
||||
? undefined
|
||||
: "status"
|
||||
}
|
||||
className="toast-card glass pointer-events-auto relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.popover} toast-card pointer-events-auto relative overflow-hidden px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useSyncExternalStore } from "react";
|
||||
import { PopoverSurface } from "./AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
import { hintsEnabled, subscribeHints } from "../../lib/hints";
|
||||
import { SURFACE } from "./surfaces";
|
||||
|
||||
type Side = "top" | "bottom" | "left" | "right";
|
||||
|
||||
@@ -50,7 +51,7 @@ export default function Tooltip({
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
passthrough
|
||||
className="glass w-max max-w-60 px-2.5 py-1.5 rounded-lg text-xs leading-snug text-fg font-normal normal-case tracking-normal animate-[fadeIn_0.12s_ease]"
|
||||
className={`${SURFACE.tooltip} w-max max-w-60 px-2.5 py-1.5 text-xs leading-snug text-fg font-normal normal-case tracking-normal animate-[fadeIn_0.12s_ease]`}
|
||||
>
|
||||
{hint}
|
||||
</PopoverSurface>
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
// The paint of the two button primitives (docs/DESIGN.md A1, A2, B1, B3), apart from the components so
|
||||
// a LINK can wear exactly the same face (`LinkButton`): a download or a sign-in that navigates is an
|
||||
// <a>, and a hand-painted look-alike beside the real buttons is how the two drifted. The hover guard is
|
||||
// per element: `enabled:` never matches an <a>, so a link's hovers drop it (it has no disabled state).
|
||||
const ACT_BUTTON = "enabled:not-aria-disabled:";
|
||||
const ACT_LINK = "not-aria-disabled:";
|
||||
|
||||
export type ButtonVariant =
|
||||
| "primary" // solid accent — the main affirmative action
|
||||
| "danger" // solid danger — destructive confirm
|
||||
| "warn" // solid warn — the action of a warning bar (reload for a new version)
|
||||
| "outline" // bordered, quiet — secondary action (Full history, Subscribe, Cancel-with-border)
|
||||
| "outline-accent" // bordered + accent-tinted — an affirmative secondary (Activate, Approve)
|
||||
| "outline-danger" // neutral at rest, danger on hover — a row-level Delete
|
||||
| "outline-warn" // bordered + warn-tinted — a reversible restriction that is currently ON
|
||||
| "ghost" // borderless — tertiary/dismiss (dialog Cancel, close)
|
||||
| "glass" // glass card — the Plex/overlay action buttons (A1: glass is a variant, not a hand-built one)
|
||||
| "overlay" // white text on the always-dark player chrome, whatever the theme
|
||||
| "media" // dark translucent on a picture — "From the start" on a card (C5); always round
|
||||
| "danger-soft" // bordered AND danger-coloured at rest — a destructive action that shouldn't shout
|
||||
| "link" // inline accent text — "Show all", "Learn more", a resend-mail prompt inside a sentence
|
||||
| "link-muted"; // inline quiet text that brightens on hover — "Clear", "Show less", "Skip"
|
||||
|
||||
export type ButtonSize =
|
||||
| "xs" // 24 — inside a table row
|
||||
| "sm" // 28 — toolbars and panel headers; lines up with the search pill and the dropdown trigger
|
||||
| "md" // 32 — forms and dialog footers
|
||||
| "lg" // 40 — a page's single hero call to action (Welcome, onboarding), nowhere else
|
||||
| "none"; // no box of its own — the link variants' default
|
||||
|
||||
// B3: every hover and press is a LAYER (the `state-*` utilities in index.css), never an opacity dim or
|
||||
// a `bg-card` fill, which vanish on a light card. The utilities already stand down on a disabled, busy
|
||||
// or pressed button; the text-colour hovers here carry the same guard as a variant.
|
||||
const buttonVariants = (ACT: string): Record<ButtonVariant, string> => ({
|
||||
primary: "font-medium state-filled",
|
||||
danger: "font-medium state-danger",
|
||||
warn: "font-medium state-warn",
|
||||
outline: `font-medium border border-border text-muted state-layer ${ACT}hover:text-fg`,
|
||||
"outline-accent": "font-medium border border-accent/40 text-accent state-layer",
|
||||
// Neutral until you reach for it. NOT `danger-soft`, which is already red at rest — the difference
|
||||
// is whether the button should advertise the destruction before the pointer arrives.
|
||||
"outline-danger": `font-medium border border-border text-muted state-layer ${ACT}hover:text-danger ${ACT}hover:border-danger/40`,
|
||||
"outline-warn": "font-medium border border-warn/40 text-warn state-layer",
|
||||
ghost: `font-medium text-muted state-layer ${ACT}hover:text-fg`,
|
||||
glass: `font-medium glass-card glass-hover ${ACT}hover:text-accent`,
|
||||
// The player chrome is dark in the light theme too: a theme-fg state layer would be a dark wash.
|
||||
overlay: `font-medium text-white/80 ${ACT}hover:text-white ${ACT}hover:bg-white/15`,
|
||||
media: `font-medium bg-black/60 text-white ${ACT}hover:bg-black/75`,
|
||||
"danger-soft": `font-medium border border-danger/60 text-danger state-layer ${ACT}hover:border-danger`,
|
||||
link: `font-semibold text-accent ${ACT}hover:underline`,
|
||||
"link-muted": `font-medium text-muted ${ACT}hover:text-fg`,
|
||||
});
|
||||
|
||||
// A pressed toggle (`pressed`) is the solid accent in every variant (B3: "selected stays the solid
|
||||
// accent"); the layer utilities leave it alone, so it stays solid under the pointer too.
|
||||
const PRESSED =
|
||||
"aria-pressed:bg-accent aria-pressed:text-accent-fg aria-pressed:border-accent aria-pressed:no-underline";
|
||||
|
||||
// B1, the whole ladder: explicit heights, so a toolbar's `sm` button, search pill, dropdown trigger and
|
||||
// small icon button all line up at 28. A call site picks a rung; it does not invent one. rem-derived, so
|
||||
// the Settings text-size slider (--font-scale) grows the box with the label. A1: r8 on every rung; a
|
||||
// button on a picture, or nested in a pill (the header search's Go), is a pill instead (A2: round
|
||||
// means "floats over something", and a pill's inner button follows its radius).
|
||||
const SIZES: Record<ButtonSize, string> = {
|
||||
xs: "h-6 px-2 text-xs gap-1 whitespace-nowrap",
|
||||
sm: "h-7 px-2.5 text-sm gap-1.5 whitespace-nowrap",
|
||||
md: "h-8 px-3 text-sm gap-1.5 whitespace-nowrap",
|
||||
lg: "h-10 px-4 text-base gap-2 whitespace-nowrap",
|
||||
none: "gap-1.5",
|
||||
};
|
||||
|
||||
// The font-weight is owned by each VARIANT, not by BASE: `link` wants semibold (that is what a text
|
||||
// link looks like everywhere in this app) and the action buttons want medium. Two font-* utilities on
|
||||
// one element is a coin flip without tailwind-merge, so exactly one has to emit it — see the test.
|
||||
const BASE =
|
||||
"inline-flex shrink-0 items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed [&>svg]:size-4 [&>svg]:shrink-0";
|
||||
|
||||
export type IconButtonVariant =
|
||||
| "ghost" // the default: quiet glyph, a state layer on hover
|
||||
| "ghost-accent" // an accent glyph: the action it stands for is already in effect (queued, requested)
|
||||
| "primary" // solid accent — the one affirmative action of a compact form (Send, Save view)
|
||||
| "outline" // bordered — an icon action that stands on its own in a form or a header
|
||||
| "outline-accent" // bordered + accent — an affirmative row action (Approve)
|
||||
| "outline-danger" // bordered, danger on hover — a row's destructive action beside bordered siblings
|
||||
| "outline-warn" // bordered + warn — a reversible restriction that is currently ON (Suspended)
|
||||
| "danger" // quiet at rest, danger on hover — a row's delete/remove
|
||||
| "danger-soft" // bordered AND danger at rest — a destructive restriction that is ON (Blocked)
|
||||
| "glass" // glass card — beside the glass Buttons on a Plex page
|
||||
| "media" // round, dark translucent — on a thumbnail or a poster
|
||||
| "overlay"; // white glyph on the always-dark player chrome, whatever the theme
|
||||
|
||||
export type IconButtonSize =
|
||||
| "sm" // 28 — rows and dense toolbars
|
||||
| "md" // 32 — on its own
|
||||
| "lg" // 48 — only the Play in the middle of a picture (C5: a not-started card)
|
||||
| "player"; // 36 with a 20px glyph, 44 on touch (A3) — the Plex player's control row (C5)
|
||||
|
||||
const iconVariants = (ACT: string): Record<IconButtonVariant, string> => ({
|
||||
ghost: `text-muted state-layer ${ACT}hover:text-fg`,
|
||||
"ghost-accent": "text-accent state-layer",
|
||||
primary: "state-filled",
|
||||
outline: `border border-border text-muted state-layer ${ACT}hover:text-fg`,
|
||||
"outline-accent": "border border-accent/40 text-accent state-layer",
|
||||
"outline-danger": `border border-border text-muted state-layer ${ACT}hover:text-danger ${ACT}hover:border-danger/40`,
|
||||
"outline-warn": "border border-warn/40 text-warn state-layer",
|
||||
danger: `text-muted state-layer ${ACT}hover:text-danger`,
|
||||
"danger-soft": `border border-danger/60 text-danger state-layer ${ACT}hover:border-danger`,
|
||||
glass: `glass-card glass-hover ${ACT}hover:text-accent`,
|
||||
// The player chrome and a thumbnail are dark in the light theme too, so these two paint with
|
||||
// white and black, not with the theme's fg (whose state layer would be a dark wash on a dark video).
|
||||
media: `bg-black/60 text-white ${ACT}hover:bg-black/75`,
|
||||
overlay: `text-white/80 ${ACT}hover:text-white ${ACT}hover:bg-white/15`,
|
||||
});
|
||||
|
||||
// An icon toggle that is on shows it in the glyph (watched, saved, a selected track), not as a solid
|
||||
// fill: a solid accent square in a row of quiet glyphs outshouts the row's primary action.
|
||||
const ICON_PRESSED = "aria-pressed:text-accent";
|
||||
|
||||
const ICON_SIZES: Record<IconButtonSize, string> = {
|
||||
sm: "size-7 [&>svg]:size-4",
|
||||
md: "size-8 [&>svg]:size-4",
|
||||
lg: "size-12 [&>svg]:size-6",
|
||||
player: "size-9 [&>svg]:size-5",
|
||||
};
|
||||
|
||||
const BUTTON_VARIANTS = { button: buttonVariants(ACT_BUTTON), link: buttonVariants(ACT_LINK) };
|
||||
const ICON_VARIANTS = { button: iconVariants(ACT_BUTTON), link: iconVariants(ACT_LINK) };
|
||||
|
||||
export const isLinkVariant = (v: ButtonVariant) => v === "link" || v === "link-muted";
|
||||
|
||||
export function buttonClass({
|
||||
variant,
|
||||
size,
|
||||
round = false,
|
||||
fullWidth = false,
|
||||
pressed,
|
||||
element = "button",
|
||||
}: {
|
||||
variant: ButtonVariant;
|
||||
size: ButtonSize;
|
||||
round?: boolean;
|
||||
fullWidth?: boolean;
|
||||
pressed?: boolean;
|
||||
element?: "button" | "link";
|
||||
}) {
|
||||
return clsx(
|
||||
BASE,
|
||||
BUTTON_VARIANTS[element][variant],
|
||||
pressed !== undefined && PRESSED,
|
||||
SIZES[size],
|
||||
size !== "none" && (round || variant === "media" ? "rounded-full" : "rounded-lg"),
|
||||
// The coarse-pointer target (DESIGN.md A3) lives HERE, not in the call sites. A link variant is
|
||||
// excluded on purpose — it is a run of text inside a sentence, and a 44px-tall inline link would
|
||||
// wreck the paragraph it sits in rather than make anything easier to hit.
|
||||
!isLinkVariant(variant) && "touch-target",
|
||||
fullWidth && "w-full",
|
||||
);
|
||||
}
|
||||
|
||||
export function iconButtonClass({
|
||||
variant,
|
||||
size,
|
||||
round = false,
|
||||
pressed,
|
||||
element = "button",
|
||||
}: {
|
||||
variant: IconButtonVariant;
|
||||
size: IconButtonSize;
|
||||
round?: boolean;
|
||||
pressed?: boolean;
|
||||
element?: "button" | "link";
|
||||
}) {
|
||||
return clsx(
|
||||
"inline-grid shrink-0 place-items-center transition touch-target aria-expanded:text-accent disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
|
||||
ICON_VARIANTS[element][variant],
|
||||
round || variant === "media" ? "rounded-full" : "rounded-lg",
|
||||
pressed !== undefined && ICON_PRESSED,
|
||||
ICON_SIZES[size],
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { FocusSafeButton } from "./FocusSafeButton";
|
||||
import { SURFACE } from "./surfaces";
|
||||
|
||||
// Shared form/settings primitives, factored out of the many panels that each re-declared
|
||||
// them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged.
|
||||
@@ -51,7 +52,7 @@ export function Section({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={card ? "glass rounded-2xl p-4 mb-4" : "mb-6"}>
|
||||
<div className={card ? `${SURFACE.panel} p-4 mb-4` : "mb-6"}>
|
||||
<div className={`text-xs uppercase tracking-wide text-muted ${card ? "mb-3" : "mb-2"}`}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// B5 (docs/DESIGN.md): one glass class and one radius per surface ROLE, so corners nest and no site
|
||||
// drifts its own alpha. A call site names the role and adds only its layout (padding, width, flex);
|
||||
// the radius comes from here, never beside it (no tailwind-merge: two `rounded-*` is a coin flip). A
|
||||
// raw `glass` / `glass-card` / `glass-menu` class anywhere else is a lint error (`local/surface-role`).
|
||||
//
|
||||
// What floats over undimmed content (a menu, a popover, a tooltip, a toast) is `.glass-menu`, the
|
||||
// near-opaque tier, so it stays readable over any thumbnail; what sits in the page is `.glass-card`;
|
||||
// a dialog, a drawer, a docked or side panel and a page section is `.glass`.
|
||||
export const SURFACE = {
|
||||
/** A dialog, a side panel, a dock, a page section: r16. */
|
||||
panel: "glass rounded-2xl",
|
||||
/** A dialog that slides in from the edge: square on a phone, r16 on its open side. */
|
||||
drawer: "glass rounded-none sm:rounded-l-2xl",
|
||||
/** A card or a row card in the page (a video, a user, a download): r12. */
|
||||
card: "glass-card rounded-xl",
|
||||
/** A menu or a listbox: r12, 4px padding, so its r8 rows nest (12 − 4). */
|
||||
menu: "glass-menu rounded-xl p-1",
|
||||
/** Floating content that is not a list of choices (a hover card, a status popover, a toast): r12. */
|
||||
popover: "glass-menu rounded-xl",
|
||||
/** A tooltip: r8. */
|
||||
tooltip: "glass-menu rounded-lg",
|
||||
/** A control bar floating over the page (the debug recorder's bar): a pill (A2: round floats). */
|
||||
floating: "glass-menu rounded-full",
|
||||
/** The pull tab on a side panel's edge: square on the wall side, r12 on the open side. */
|
||||
edgeTab: "glass rounded-r-xl",
|
||||
/** A full-width sticky band (the design lab's header): square. */
|
||||
band: "glass",
|
||||
} as const;
|
||||
|
||||
export type SurfaceRole = keyof typeof SURFACE;
|
||||
@@ -12,6 +12,7 @@ import { Button } from "./Button";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { Pressable } from "./Pressable";
|
||||
import { Input } from "./Input";
|
||||
import { SURFACE } from "./surfaces";
|
||||
|
||||
// The column-header machinery — the sortable header row with its filter funnel + popover — lifted
|
||||
// OUT of DataTable so a non-DataTable body can render under the same headers. The column contract
|
||||
@@ -93,7 +94,7 @@ function FilterFunnel<T>({
|
||||
popover={pop}
|
||||
ariaLabel={col.header}
|
||||
initialFocus={isText ? inputRef : undefined}
|
||||
className="glass-menu w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.popover} w-52 p-2 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{f.kind === "text" && (
|
||||
<Input
|
||||
@@ -113,7 +114,7 @@ function FilterFunnel<T>({
|
||||
keepSelectedPaint
|
||||
onSelect={() => onApply(col.key, "")}
|
||||
label={t("datatable.all")}
|
||||
className={`text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
|
||||
className={`text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
|
||||
!filterOn ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
@@ -127,7 +128,7 @@ function FilterFunnel<T>({
|
||||
keepSelectedPaint
|
||||
onSelect={() => onApply(col.key, o.value)}
|
||||
label={o.label}
|
||||
className={`text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
|
||||
className={`text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
|
||||
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
@@ -153,7 +154,7 @@ function FilterFunnel<T>({
|
||||
onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value])
|
||||
}
|
||||
label={o.label}
|
||||
className={`flex w-full items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
|
||||
className={`flex w-full items-center gap-2 text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user