feat(design): every button on its family, the chip primitive, the lint rule (E28 S3b2)
The second half of the button sweep: the remaining hand-rolled buttons in
feature components, the design lab and the components/ui composites now
stand on Button, IconButton, Pressable, Chip or DragHandle.
- Chip + DismissibleChip (filter / token / tag / suggest / ghost, 24px
floor, 44 on touch) replace five chip geometries; FilterChipGroup
renders Chips; overflow lanes measure a mirror painted by chipClass.
- The Plex control row is IconButton's player rung (36, 20px glyph,
round, overlay; 44 on touch); the YT modal bar is on the sm rung with
pressed cycle toggles and names that survive a folded label.
- New variants: IconButton ghost-accent, danger-soft; Button warn on a
new state-warn utility. The .btn-{tone} recipes and btnCls are gone.
- Fixed on the way: membership rows (collection editor, Plex playlist
add) announce checked/mixed; a one-letter tag chip was 21px wide;
descriptionLinks moved out of lib/ (it renders components).
- local/button-primitive: a raw <button>/<FocusSafeButton> outside the
control-primitive files is a lint error (contract-tested, seen red).
This commit is contained in:
@@ -92,7 +92,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
||||
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
||||
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
||||
| FE button | `ui/Button` (a `SIZES` rung) · `IconButton` · `Pressable` (a clickable card/row) | a hand-rolled `<button className="px-3 py-2 …">` |
|
||||
| FE button | `ui/Button` (a `SIZES` rung) · `IconButton` · `Pressable` (a clickable card/row) · `Chip` · `DragHandle` | a raw `<button>` outside `components/ui/` (lint-enforced) |
|
||||
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
||||
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
||||
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
||||
|
||||
+15
-6
@@ -130,16 +130,24 @@ Also in force:
|
||||
- **One primitive per button family** (E28 S3b): a labelled action is `Button` (a rung from the ladder,
|
||||
plus `lg` 40 for a page's single hero call to action, and nothing else); a glyph is `IconButton` (a
|
||||
required `label`); a card, row, tile or menu row whose content is the control is `Pressable`; a filter or
|
||||
tag chip is the chip family; a sortable row's grip is `DragHandle`. On the always-dark player chrome both
|
||||
`Button` and `IconButton` use the `overlay` variant (white, a white layer), whatever the theme. A pressed
|
||||
toggle is `pressed`: the solid accent on a `Button`, the accent glyph on an `IconButton`. A hand-rolled
|
||||
`<button className="px-3 …">` is a violation. A text-less button must have an accessible name, and the
|
||||
type enforces it. [WCAG 4.1.2]
|
||||
tag chip is `Chip`; a sortable row's grip is `DragHandle`. On the always-dark player chrome both
|
||||
`Button` and `IconButton` use the `overlay` variant (white, a white layer), whatever the theme; the Plex
|
||||
control row is IconButton's `player` rung (36, a 20px glyph, round, 44 on touch). A pressed toggle is
|
||||
`pressed`: the solid accent on a `Button`, the accent glyph on an `IconButton`. A raw `<button>` outside
|
||||
`components/ui/` is a lint error. A text-less button must have an accessible name, and the type enforces
|
||||
it. [WCAG 4.1.2]
|
||||
- **The chip family** (`Chip`, E28 S3b): rounded-full, never under 24 (A3 grows it to 44 on touch).
|
||||
`filter` is a selectable option, solid accent when `pressed` (also the "+N" and "More…" triggers, lit
|
||||
while their popover is open); `token` is an applied narrowing, the accent tint, the whole chip removing
|
||||
it; `tag` is a user's tag, the solid accent; `suggest` is a suggestion not yet taken (dashed accent);
|
||||
`ghost` the quiet "add a tag" (dashed neutral). A chip whose label is not the control but carries its
|
||||
own × is `DismissibleChip`: two sibling controls at most, never a button in a button. An overflowing chip
|
||||
lane measures a hidden mirror painted with `chipClass`, so the mirror cannot drift from the chips.
|
||||
- **Unavailable with a reason** stays focusable: `aria-disabled` plus the reason in the label or tooltip,
|
||||
never native `disabled`, which drops it from the tab order with its explanation. [WCAG 2.1.1]
|
||||
- A choice between a few values is `SegmentedToggle`; a long list or a user's own list is `FilterSelect`.
|
||||
Never a native `<select>` (lint-enforced).
|
||||
- A filter chip goes through `FilterChipGroup`.
|
||||
- A single-select row of filter chips goes through `FilterChipGroup` (its options are `Chip`s).
|
||||
- A button that starts slow work shows `loading` (a spinner, `aria-busy`) and can't be pressed twice.
|
||||
[Nielsen #1]
|
||||
|
||||
@@ -313,4 +321,5 @@ Wherever there is text, the user can select it and copy it, and no click action
|
||||
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
|
||||
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
|
||||
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
|
||||
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
|
||||
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
|
||||
|
||||
@@ -82,6 +82,48 @@ const focusSafeDisabled = {
|
||||
};
|
||||
},
|
||||
};
|
||||
// E28 S3b: one primitive per button family (docs/DESIGN.md). 268 hand-rolled buttons in 79 files had
|
||||
// drifted into as many looks, heights and hover styles; they were swept onto ui/Button, IconButton,
|
||||
// Pressable, Chip and DragHandle, and this keeps the next one out. The raw element lives only in the
|
||||
// files that ARE a control primitive — not in the rest of components/ui/, whose composites (a pager, a
|
||||
// side panel, a toast) are built from the primitives like any feature component.
|
||||
const BUTTON_PRIMITIVES = new Set([
|
||||
"FocusSafeButton.tsx", // the focus-keeping <button> everything stands on
|
||||
"Button.tsx",
|
||||
"IconButton.tsx",
|
||||
"Pressable.tsx",
|
||||
"Chip.tsx",
|
||||
"DragHandle.tsx",
|
||||
"AnchoredPopover.tsx", // the menu row: floating-ui's roving focus reads its native `disabled`
|
||||
"SegmentedToggle.tsx",
|
||||
"Tabs.tsx",
|
||||
"form.tsx", // Switch
|
||||
"PriorityStepper.tsx",
|
||||
"SortControl.tsx",
|
||||
"FilterSelect.tsx", // the dropdown trigger (A5, S3c)
|
||||
"Combobox.tsx", // the field's own chips and chevron (A4, S3c)
|
||||
]);
|
||||
const buttonPrimitive = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
messages: {
|
||||
raw: "A raw <{{name}}> outside the control primitives: use ui/Button (a labelled action), IconButton (a glyph), Pressable (a card or row that is the control), Chip or DragHandle.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const path = context.filename.split("\\").join("/");
|
||||
const file = path.slice(path.lastIndexOf("/") + 1);
|
||||
if (path.includes("/src/components/ui/") && BUTTON_PRIMITIVES.has(file)) return {};
|
||||
return {
|
||||
JSXOpeningElement(node) {
|
||||
if (node.name.type !== "JSXIdentifier") return;
|
||||
const name = node.name.name;
|
||||
if (name === "button" || name === "FocusSafeButton")
|
||||
context.report({ node, messageId: "raw", data: { name } });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
// The words a screen reader or a tooltip says are user-facing text like any other, and the HU/EN
|
||||
// parity rule covers them too. Five `aria-label="remove"`-style literals survived every lane (the
|
||||
// Plex playlist rows, a filter chip, the player's reset) and a whole opt-in panel was English only —
|
||||
@@ -157,6 +199,7 @@ const localRules = {
|
||||
"sortable-a11y": sortableA11y,
|
||||
"jsx-comment-text": jsxCommentText,
|
||||
"focus-safe-disabled": focusSafeDisabled,
|
||||
"button-primitive": buttonPrimitive,
|
||||
"literal-a11y-text": literalA11yText,
|
||||
},
|
||||
};
|
||||
@@ -211,6 +254,7 @@ export default tseslint.config(
|
||||
"react-hooks/refs": "off",
|
||||
"local/jsx-comment-text": "error",
|
||||
"local/focus-safe-disabled": "error",
|
||||
"local/button-primitive": "error",
|
||||
"local/literal-a11y-text": "error",
|
||||
"local/sortable-a11y": "error",
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
@@ -372,6 +416,8 @@ export default tseslint.config(
|
||||
"no-restricted-globals": "off",
|
||||
"no-restricted-properties": "off",
|
||||
"local/literal-a11y-text": "off",
|
||||
// A test renders a raw button as a stand-in trigger or a focus target, not as product UI.
|
||||
"local/button-primitive": "off",
|
||||
},
|
||||
},
|
||||
prettier,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { copyText } from "../lib/clipboard";
|
||||
import { formatDateTime } from "../lib/format";
|
||||
import { invalidateUserAccess } from "../lib/adminUsers";
|
||||
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// 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).
|
||||
@@ -196,24 +196,28 @@ export default function AccountPanel({
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{a.can_activate && (
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
fullWidth
|
||||
icon={<Check />}
|
||||
loading={activate.isPending}
|
||||
onClick={() => activate.mutate()}
|
||||
disabled={activate.isPending}
|
||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
|
||||
className="border border-accent/40 text-accent"
|
||||
>
|
||||
<Check className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 text-left">{t("users.account.activate")}</span>
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
fullWidth
|
||||
icon={<Send />}
|
||||
loading={resend.isPending}
|
||||
onClick={() => resend.mutate()}
|
||||
// The demo account's address is never confirmed, and the server refuses to mail it — as
|
||||
// it refuses it a set-password link below (deep-review E11 round 3).
|
||||
disabled={user.is_demo || !a.email_configured || a.email_verified || resend.isPending}
|
||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs disabled:opacity-45 transition"
|
||||
disabled={user.is_demo || !a.email_configured || a.email_verified}
|
||||
>
|
||||
<Send className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 text-left">{t("users.account.resend")}</span>
|
||||
{/* Says WHY it is unavailable instead of just greying out — the no-mail case is the
|
||||
whole reason the link below exists, so naming it here is the explanation. */}
|
||||
@@ -223,18 +227,21 @@ export default function AccountPanel({
|
||||
{a.email_configured && a.email_verified && (
|
||||
<span className="text-muted">{t("users.account.alreadyConfirmed")}</span>
|
||||
)}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
fullWidth
|
||||
icon={<KeyRound />}
|
||||
loading={issue.isPending}
|
||||
disabled={user.is_demo}
|
||||
onClick={() => issue.mutate()}
|
||||
disabled={issue.isPending || user.is_demo}
|
||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
|
||||
className="border border-accent/40 text-accent"
|
||||
>
|
||||
<KeyRound className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 text-left">
|
||||
{link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")}
|
||||
</span>
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{link && (
|
||||
@@ -243,13 +250,14 @@ export default function AccountPanel({
|
||||
<div className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-3 py-2 text-2xs font-mono truncate">
|
||||
{link.url}
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="glass"
|
||||
icon={copied ? <Check /> : <Copy />}
|
||||
onClick={copy}
|
||||
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs border border-accent/40 text-accent transition"
|
||||
className="border border-accent/40 text-accent"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? t("users.account.copied") : t("users.account.copy")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-2xs text-muted leading-relaxed">{t("users.account.linkNote")}</p>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,9 @@ import { decideChipLayout } from "../lib/chipOverflow";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Chip } from "./ui/Chip";
|
||||
import { chipClass } from "./ui/chipStyles";
|
||||
|
||||
// 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
|
||||
@@ -22,23 +25,19 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
// the "fit some + N more" treatment used on wider lanes.
|
||||
const NARROW_PX = 420;
|
||||
|
||||
const chipCls =
|
||||
"chip group inline-flex items-center gap-1 pl-2.5 pr-1.5 py-1 text-sm whitespace-nowrap hover:border-accent hover:bg-accent/20 transition";
|
||||
|
||||
function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
<Chip
|
||||
variant="token"
|
||||
size="md"
|
||||
data-testid={testId}
|
||||
data-filter-key={f.key}
|
||||
onClick={f.remove}
|
||||
title={t("feed.chips.remove", { name: f.label })}
|
||||
aria-label={t("feed.chips.remove", { name: f.label })}
|
||||
className={chipCls}
|
||||
removeLabel={t("feed.chips.remove", { name: f.label })}
|
||||
>
|
||||
<span className="max-w-56 truncate">{f.label}</span>
|
||||
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
|
||||
</button>
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,18 +68,16 @@ function OverflowPopover({
|
||||
const name = ariaLabel ?? label;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
<Chip
|
||||
variant="token"
|
||||
size="md"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={name}
|
||||
className={`chip inline-flex shrink-0 items-center gap-1 px-2.5 py-1 text-sm whitespace-nowrap transition ${
|
||||
open ? "border-accent text-accent" : "hover:border-accent"
|
||||
}`}
|
||||
icon={icon ? <Filter className="size-3.5" aria-hidden="true" /> : undefined}
|
||||
>
|
||||
{icon && <Filter className="w-3.5 h-3.5 shrink-0" aria-hidden="true" />}
|
||||
{label}
|
||||
</button>
|
||||
</Chip>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={name}
|
||||
@@ -101,17 +98,17 @@ function OverflowPopover({
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 border-t border-border px-1 pt-1.5">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="link-muted"
|
||||
data-testid="active-filter-clear-all"
|
||||
onClick={() => {
|
||||
onClearAll();
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-xs text-muted hover:text-fg underline underline-offset-2 transition"
|
||||
className="text-xs underline underline-offset-2"
|
||||
>
|
||||
{t("sidebar.clearAll")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
@@ -191,13 +188,13 @@ export default function ActiveFilterChips({
|
||||
ref={(el) => {
|
||||
chipRefs.current[i] = el;
|
||||
}}
|
||||
className={chipCls}
|
||||
className={chipClass("token", "md") + " pr-1.5"}
|
||||
>
|
||||
<span className="max-w-56 truncate">{f.label}</span>
|
||||
<X className="w-3 h-3 shrink-0" />
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
))}
|
||||
<span ref={plusRef} className="chip inline-flex items-center gap-1 px-2.5 py-1 text-sm">
|
||||
<span ref={plusRef} className={chipClass("token", "md")}>
|
||||
+{n}
|
||||
</span>
|
||||
<span ref={clearRef} className="ml-0.5 text-sm underline underline-offset-2">
|
||||
@@ -230,13 +227,14 @@ export default function ActiveFilterChips({
|
||||
/>
|
||||
)}
|
||||
{layout.mode === "all" && n > 1 && (
|
||||
<button
|
||||
<Button
|
||||
variant="link-muted"
|
||||
data-testid="active-filter-clear-all"
|
||||
onClick={onClearAll}
|
||||
className="ml-0.5 shrink-0 text-sm text-muted hover:text-fg underline underline-offset-2 transition"
|
||||
className="ml-0.5 text-sm underline underline-offset-2"
|
||||
>
|
||||
{t("sidebar.clearAll")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { Copy, Radio, Search, X } from "lucide-react";
|
||||
import { Copy, Radio, Search } from "lucide-react";
|
||||
|
||||
import { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
@@ -33,6 +33,7 @@ import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
import { Section } from "./ui/form";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { DismissibleChip } from "./ui/Chip";
|
||||
|
||||
// Admin → Szervernapló: the server's stored log (`log_events`, E22.6). One list, newest first; a line
|
||||
// opens in place to its full record, and its request id / job id narrow the list to that request or
|
||||
@@ -421,18 +422,9 @@ function PivotChip({
|
||||
onClear: () => void;
|
||||
}) {
|
||||
return (
|
||||
<span className="chip py-0.5 pl-2.5 pr-1">
|
||||
<span className="font-mono">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
aria-label={clearLabel}
|
||||
title={clearLabel}
|
||||
className="rounded-full p-0.5 hover:bg-accent/20"
|
||||
>
|
||||
<X className="h-3 w-3" aria-hidden />
|
||||
</button>
|
||||
</span>
|
||||
<DismissibleChip onDismiss={onClear} dismissLabel={clearLabel} className="font-mono">
|
||||
{label}
|
||||
</DismissibleChip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -449,7 +441,7 @@ function RowDetail({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pivotCls =
|
||||
"font-mono text-accent underline decoration-dotted underline-offset-2 hover:decoration-solid";
|
||||
"font-mono underline decoration-dotted underline-offset-2 hover:decoration-solid";
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5 font-sans text-xs">
|
||||
@@ -465,14 +457,14 @@ function RowDetail({
|
||||
<>
|
||||
<dt>{t("adminLogs.fields.request")}</dt>
|
||||
<dd>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="link"
|
||||
className={pivotCls}
|
||||
title={t("adminLogs.showRequest")}
|
||||
onClick={() => onRequest(row.request_id!)}
|
||||
>
|
||||
{row.request_id}
|
||||
</button>
|
||||
</Button>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
@@ -480,14 +472,14 @@ function RowDetail({
|
||||
<>
|
||||
<dt>{t("adminLogs.fields.job")}</dt>
|
||||
<dd>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="link"
|
||||
className={pivotCls}
|
||||
title={t("adminLogs.showJob")}
|
||||
onClick={() => onJob(row.job_id!)}
|
||||
>
|
||||
{row.job_id}
|
||||
</button>
|
||||
</Button>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ import Pager from "./ui/Pager";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import { useChannelLayout } from "./channelsContext";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but
|
||||
// that they don't subscribe to. Subscribing here only creates the subscription + enriches
|
||||
@@ -108,16 +108,16 @@ export default function ChannelDiscovery({
|
||||
canWrite ? t("channels.discovery.subscribeHint") : t("channels.discovery.needWriteHint")
|
||||
}
|
||||
>
|
||||
<FocusSafeButton
|
||||
// Busy blocks the click, not the focus (FocusSafeButton).
|
||||
<Button
|
||||
variant="glass"
|
||||
size="xs"
|
||||
icon={<UserPlus />}
|
||||
onClick={() => onSubscribe(c)}
|
||||
disabled={!canWrite}
|
||||
busy={subscribe.isPending}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 aria-disabled:opacity-50 transition"
|
||||
loading={subscribe.isPending}
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
{t("channels.discovery.subscribe")}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ExternalLink } from "lucide-react";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import { channelYouTubeUrl } from "../lib/format";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// Avatar + channel name + "open on YouTube" link, shared by the Channel manager and the
|
||||
// discovery list (and anywhere else that lists a channel). When `onView` is given the name
|
||||
@@ -28,7 +29,7 @@ export default function ChannelLink({
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Avatar src={thumbnailUrl} fallback={title ?? ""} className="w-8 h-8 rounded-full shrink-0" />
|
||||
{onView ? (
|
||||
<button
|
||||
<Pressable
|
||||
onClick={onView}
|
||||
// The channel's "open" element: in a keyboard-walkable list (lib/listNav) Enter on the row or
|
||||
// card clicks this.
|
||||
@@ -46,10 +47,10 @@ export default function ChannelLink({
|
||||
}}
|
||||
// title = the full name, so a truncated fluid-column name is still readable on hover.
|
||||
title={name}
|
||||
className="text-sm font-medium truncate hover:text-accent text-left min-w-0"
|
||||
className="text-sm font-medium truncate hover:text-accent min-w-0"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
</Pressable>
|
||||
) : (
|
||||
<span title={name} className="text-sm font-medium truncate min-w-0">
|
||||
{name}
|
||||
|
||||
@@ -38,7 +38,6 @@ import { ChannelTagsControl } from "./ChannelTags";
|
||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
|
||||
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
|
||||
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
|
||||
@@ -357,14 +356,15 @@ export default function ChannelPage({
|
||||
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
<Button
|
||||
variant="media"
|
||||
data-testid="channel-back"
|
||||
icon={<ArrowLeft />}
|
||||
onClick={closeChannel}
|
||||
className="absolute top-5 left-7 sm:left-9 z-base inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-xs"
|
||||
className="absolute top-5 left-7 sm:left-9 z-base backdrop-blur-xs"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("channel.back")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 sm:px-6">
|
||||
@@ -489,39 +489,32 @@ export default function ChannelPage({
|
||||
</Button>
|
||||
)}
|
||||
{!me.is_demo && (
|
||||
<FocusSafeButton
|
||||
<IconButton
|
||||
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
|
||||
// light up on hover, as if a click would do something.
|
||||
size="md"
|
||||
variant={ch?.blocked ? "danger-soft" : "outline-danger"}
|
||||
onClick={() => block.mutate()}
|
||||
busy={block.isPending}
|
||||
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||
aria-label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
|
||||
ch?.blocked
|
||||
? "border-danger text-danger"
|
||||
: "border-border text-muted not-aria-disabled:hover:text-danger not-aria-disabled:hover:border-danger"
|
||||
}`}
|
||||
loading={block.isPending}
|
||||
label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||
>
|
||||
<Ban className="w-4 h-4" />
|
||||
</FocusSafeButton>
|
||||
<Ban />
|
||||
</IconButton>
|
||||
)}
|
||||
{/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel
|
||||
(mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos
|
||||
from the feed. EyeOff = currently hidden (click to unhide). */}
|
||||
{ch?.subscribed && !me.is_demo && (
|
||||
<FocusSafeButton
|
||||
<IconButton
|
||||
size="md"
|
||||
variant={ch.hidden ? "outline-accent" : "outline"}
|
||||
onClick={() => toggleHidden.mutate()}
|
||||
busy={toggleHidden.isPending}
|
||||
loading={toggleHidden.isPending}
|
||||
label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
|
||||
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
|
||||
aria-label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
|
||||
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
|
||||
ch.hidden
|
||||
? "border-accent text-accent"
|
||||
: "border-border text-muted not-aria-disabled:hover:text-fg not-aria-disabled:hover:border-fg/40"
|
||||
}`}
|
||||
>
|
||||
{ch.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</FocusSafeButton>
|
||||
{ch.hidden ? <EyeOff /> : <Eye />}
|
||||
</IconButton>
|
||||
)}
|
||||
{!me.is_demo &&
|
||||
!ch?.blocked &&
|
||||
@@ -585,20 +578,15 @@ export default function ChannelPage({
|
||||
/>
|
||||
{!ch?.subscribed && nextToken && (
|
||||
<div className="flex justify-center pb-6">
|
||||
<button
|
||||
<Button
|
||||
// Busy blocks the click, not the focus (see Block above).
|
||||
onClick={() => !exploring && runExplore(nextToken)}
|
||||
aria-disabled={exploring || undefined}
|
||||
aria-busy={exploring || undefined}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border not-aria-disabled:hover:bg-surface aria-disabled:opacity-50"
|
||||
variant="outline"
|
||||
icon={<RefreshCw />}
|
||||
loading={exploring}
|
||||
onClick={() => runExplore(nextToken)}
|
||||
>
|
||||
{exploring ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
)}
|
||||
{t("channel.loadMore")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { type MyStatus } from "../lib/api";
|
||||
import { formatEta, formatNumber } from "../lib/format";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// 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
|
||||
@@ -77,15 +78,15 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="glass"
|
||||
size="sm"
|
||||
icon={<BarChart3 aria-hidden="true" />}
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-muted transition"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" aria-hidden="true" />
|
||||
{t("channels.stats.label")}
|
||||
</button>
|
||||
</Button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("channels.stats.label")}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Check, Plus, Sparkles, X } from "lucide-react";
|
||||
import { Check, Plus, Sparkles } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Tag } from "../lib/api";
|
||||
import { PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
|
||||
@@ -8,6 +8,8 @@ import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Chip, DismissibleChip } from "./ui/Chip";
|
||||
import { chipClass } from "./ui/chipStyles";
|
||||
|
||||
// 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:
|
||||
@@ -25,20 +27,15 @@ interface TagChipProps {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// `block truncate` on the name, not the chip: the chip is inline-flex, where text-overflow cannot bite.
|
||||
function TagChip({ tag, onClick, title }: TagChipProps) {
|
||||
const cls =
|
||||
"chip-solid shrink-0 text-2xs font-medium px-1.5 py-0.5 rounded-full max-w-36 truncate";
|
||||
const name = <span className="block max-w-32 truncate">{tag.name}</span>;
|
||||
return onClick ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`${cls} hover:opacity-80 transition`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
<Chip variant="tag" size="xs" onClick={onClick} title={title}>
|
||||
{name}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className={cls}>{tag.name}</span>
|
||||
<span className={chipClass("tag", "xs")}>{name}</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,33 +60,22 @@ function SuggestedChip({
|
||||
const { t } = useTranslation();
|
||||
const pct = Math.round(suggestion.confidence * 100);
|
||||
return (
|
||||
<span className="shrink-0 inline-flex items-center rounded-full border border-dashed border-accent/50 text-accent/90 text-2xs font-medium pl-1 pr-0.5 py-0.5 max-w-36">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAccept}
|
||||
title={t("channels.tags.suggestedHint", { pct })}
|
||||
aria-label={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
|
||||
className="inline-flex items-center gap-1 min-w-0 hover:text-accent transition"
|
||||
>
|
||||
<Sparkles className="w-2.5 h-2.5 shrink-0" />
|
||||
<span className="truncate">{suggestion.tag.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
title={t("channels.tags.dismissSuggestion")}
|
||||
aria-label={t("channels.tags.dismissSuggestion")}
|
||||
className="shrink-0 ml-0.5 rounded-full text-accent/60 hover:text-danger transition"
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
<DismissibleChip
|
||||
variant="suggest"
|
||||
size="xs"
|
||||
className="max-w-36 font-medium"
|
||||
icon={<Sparkles />}
|
||||
onClick={onAccept}
|
||||
bodyTitle={t("channels.tags.suggestedHint", { pct })}
|
||||
bodyLabel={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
|
||||
onDismiss={onDismiss}
|
||||
dismissLabel={t("channels.tags.dismissSuggestion")}
|
||||
>
|
||||
{suggestion.tag.name}
|
||||
</DismissibleChip>
|
||||
);
|
||||
}
|
||||
|
||||
const GHOST_PILL =
|
||||
"shrink-0 text-3xs px-1.5 py-0.5 rounded-full border border-dashed border-accent/50 text-accent/90";
|
||||
|
||||
/** A single-line lane of suggested ghost chips that collapses its overflow into a "+N" pill (hover
|
||||
* reveals the rest, still accept/dismiss-able), reusing the SAME fit machinery as the attached-chip
|
||||
* lane. Rendered in the untagged-channel case only, so it owns the full tags-cell width — this is what
|
||||
@@ -158,7 +144,7 @@ function SuggestedChipsLane({
|
||||
<SuggestedChip suggestion={s} onAccept={() => {}} onDismiss={() => {}} />
|
||||
</span>
|
||||
))}
|
||||
<span ref={pillRef} className={GHOST_PILL}>
|
||||
<span ref={pillRef} className={chipClass("suggest", "xs")}>
|
||||
+{suggestions.length}
|
||||
</span>
|
||||
</div>
|
||||
@@ -173,15 +159,15 @@ function SuggestedChipsLane({
|
||||
/>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
<Chip
|
||||
variant="suggest"
|
||||
size="xs"
|
||||
ref={overflowPop.refs.setReference}
|
||||
type="button"
|
||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||
className={`${GHOST_PILL} hover:text-accent transition`}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
+{hidden}
|
||||
</button>
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -379,8 +365,8 @@ export function ChannelTagsControl({
|
||||
placement: "bottom",
|
||||
});
|
||||
|
||||
const pillClass =
|
||||
"shrink-0 text-3xs px-1.5 py-0.5 rounded-full bg-card text-muted border border-border";
|
||||
// The "+N" is a filter-look chip (neutral): the tags themselves are the accent.
|
||||
const pillClass = chipClass("filter", "xs");
|
||||
|
||||
return (
|
||||
// When `bounded`, a FIXED width keeps the cell's footprint constant under `table-layout:auto`: a
|
||||
@@ -394,21 +380,18 @@ export function ChannelTagsControl({
|
||||
{/* The add trigger LEADS the row — a stable left anchor that never moves as chips are added
|
||||
(they grow to its right), so its editor popover can't drift. Compact icon in the width-
|
||||
bounded manager cell; a labelled "+ Tag" pill where there's free width (the detail header). */}
|
||||
<button
|
||||
<Chip
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
ref={editPop.refs.setReference}
|
||||
type="button"
|
||||
title={t("channels.row.editTags")}
|
||||
aria-label={t("channels.row.editTags")}
|
||||
className={
|
||||
bounded
|
||||
? "shrink-0 w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
||||
: "shrink-0 inline-flex items-center gap-1 h-6 pl-1.5 pr-2 rounded-full border border-dashed border-border text-2xs font-medium text-muted hover:text-accent hover:border-accent transition"
|
||||
}
|
||||
icon={<Plus className="size-3" />}
|
||||
className={`font-medium ${bounded ? "w-6 justify-center px-0" : ""}`}
|
||||
{...editPop.getReferenceProps()}
|
||||
>
|
||||
<Plus className="w-3 h-3 shrink-0" />
|
||||
{!bounded && <span className="whitespace-nowrap">{t("channels.tags.add")}</span>}
|
||||
</button>
|
||||
{!bounded && t("channels.tags.add")}
|
||||
</Chip>
|
||||
|
||||
{/* Untagged + has suggestions → the ghost-chip lane (its own overflow "+N"); otherwise the
|
||||
attached-chip lane. The two are mutually exclusive: suggestions are computed for untagged
|
||||
@@ -457,15 +440,14 @@ export function ChannelTagsControl({
|
||||
/>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
<Chip
|
||||
size="xs"
|
||||
ref={overflowPop.refs.setReference}
|
||||
type="button"
|
||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||
className={`${pillClass} hover:text-fg transition`}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
+{hidden}
|
||||
</button>
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -524,12 +524,13 @@ export default function Channels() {
|
||||
</Tooltip>
|
||||
<TagFilterChips chips={tagFilterChips} className="flex-1" />
|
||||
{tagFilter.length > 0 && (
|
||||
<button
|
||||
<Button
|
||||
variant="link-muted"
|
||||
onClick={() => setFilters({ ...filters, tags: [] })}
|
||||
className="shrink-0 text-xs px-2 py-1 text-muted hover:text-accent transition"
|
||||
className="text-xs px-2"
|
||||
>
|
||||
{t("datatable.clear")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{/* Suggested-tags affordance (E8·S4): a sparkle count of the visible suggestions, with a
|
||||
one-click bulk-accept for the high-confidence ones. The per-row ghost chips handle the
|
||||
|
||||
@@ -16,6 +16,8 @@ import * as e2ee from "../lib/e2ee";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import ChatThread from "./ChatThread";
|
||||
import Overlay from "./ui/Overlay";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// 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
|
||||
@@ -82,9 +84,9 @@ function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-card/50">
|
||||
<button
|
||||
<Pressable
|
||||
onClick={() => toggleMinimize(chat.partnerId)}
|
||||
className="flex items-center gap-2 min-w-0 flex-1 text-left"
|
||||
className="flex items-center gap-2 min-w-0 flex-1"
|
||||
title={chat.minimized ? t("messages.expand") : t("messages.minimize")}
|
||||
>
|
||||
<Avatar
|
||||
@@ -93,21 +95,16 @@ function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
|
||||
className="w-7 h-7 rounded-full shrink-0 text-xs"
|
||||
/>
|
||||
<span className="font-semibold text-sm truncate">{chat.name}</span>
|
||||
</button>
|
||||
<button
|
||||
</Pressable>
|
||||
<IconButton
|
||||
onClick={() => toggleMinimize(chat.partnerId)}
|
||||
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
|
||||
aria-label={chat.minimized ? t("messages.expand") : t("messages.minimize")}
|
||||
label={chat.minimized ? t("messages.expand") : t("messages.minimize")}
|
||||
>
|
||||
{chat.minimized ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => closeChat(chat.partnerId)}
|
||||
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
|
||||
aria-label={t("messages.close")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
{chat.minimized ? <ChevronUp /> : <ChevronDown />}
|
||||
</IconButton>
|
||||
<IconButton onClick={() => closeChat(chat.partnerId)} label={t("messages.close")}>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
{!chat.minimized && (
|
||||
<div className="flex flex-col h-96">
|
||||
|
||||
@@ -17,7 +17,7 @@ import FilterSelect from "./ui/FilterSelect";
|
||||
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
|
||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// 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
|
||||
@@ -218,14 +218,15 @@ export default function ConfigPanel() {
|
||||
with no error anywhere — say so next to the fields that fix it. */}
|
||||
{!me.email_configured && <InlineNotice>{t("config.emailOff")}</InlineNotice>}
|
||||
<Tooltip hint={t("config.testEmailHint")}>
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
icon={<Send />}
|
||||
loading={testEmail.isPending}
|
||||
disabled={dirty}
|
||||
onClick={() => testEmail.mutate()}
|
||||
disabled={testEmail.isPending || dirty}
|
||||
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<Send className={`w-4 h-4 ${testEmail.isPending ? "animate-pulse" : ""}`} />
|
||||
{t("config.testEmail")}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
@@ -233,14 +234,15 @@ export default function ConfigPanel() {
|
||||
{activeGroup === "issues" && (
|
||||
<div className="mt-3 pt-3 border-t border-border/60">
|
||||
<Tooltip hint={t("config.issuesTestHint")}>
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
icon={<Plug />}
|
||||
loading={testIssues.isPending}
|
||||
disabled={dirty}
|
||||
onClick={() => testIssues.mutate()}
|
||||
disabled={testIssues.isPending || dirty}
|
||||
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<Plug className={`w-4 h-4 ${testIssues.isPending ? "animate-pulse" : ""}`} />
|
||||
{t("config.issuesTest")}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
|
||||
</div>
|
||||
@@ -249,14 +251,15 @@ export default function ConfigPanel() {
|
||||
{activeGroup === "plex" && (
|
||||
<div className="mt-3 pt-3 border-t border-border/60">
|
||||
<Tooltip hint={t("config.plexTestHint")}>
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
icon={<Plug />}
|
||||
loading={testPlex.isPending}
|
||||
disabled={dirty}
|
||||
onClick={() => testPlex.mutate()}
|
||||
disabled={testPlex.isPending || dirty}
|
||||
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<Plug className={`w-4 h-4 ${testPlex.isPending ? "animate-pulse" : ""}`} />
|
||||
{t("config.plexTest")}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
|
||||
{plexResult && (
|
||||
@@ -404,15 +407,14 @@ function Field({
|
||||
)}
|
||||
{showReset && !secretDisabled && (
|
||||
<Tooltip hint={t("config.resetHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant={item.secret && pendingSecretReset ? "link" : "link-muted"}
|
||||
icon={<RotateCcw className="size-3.5!" />}
|
||||
onClick={onResetField}
|
||||
className={`inline-flex items-center gap-1 text-xs transition ${
|
||||
item.secret && pendingSecretReset ? "text-accent" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
className="text-xs gap-1"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t("config.reset")}
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { type FeedFilters } from "../lib/api";
|
||||
import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// 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
|
||||
@@ -96,8 +98,7 @@ export default function DateFilterSelect({
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
<Pressable
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={`${t("sidebar.datePresetLabel")}: ${summary}`}
|
||||
@@ -106,9 +107,9 @@ export default function DateFilterSelect({
|
||||
}`}
|
||||
>
|
||||
<CalendarDays className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
<span className="flex-1 truncate text-left text-fg">{summary}</span>
|
||||
<span className="flex-1 truncate text-fg">{summary}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
</button>
|
||||
</Pressable>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
@@ -179,13 +180,13 @@ export default function DateFilterSelect({
|
||||
/>
|
||||
</label>
|
||||
{(filters.dateFrom || filters.dateTo) && (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="link-muted"
|
||||
onClick={() => setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })}
|
||||
className="text-2xs text-muted hover:text-accent self-end"
|
||||
className="text-2xs self-end"
|
||||
>
|
||||
{t("sidebar.clearDates")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -175,12 +175,9 @@ export default function DownloadCenter() {
|
||||
<div className="px-4 pt-2 max-w-4xl w-full mx-auto">
|
||||
<div className="flex items-center justify-between gap-3 mb-1">
|
||||
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
|
||||
<button
|
||||
onClick={() => setManage(true)}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
||||
>
|
||||
<Settings2 className="w-4 h-4" /> {t("downloads.page.manage")}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" icon={<Settings2 />} onClick={() => setManage(true)}>
|
||||
{t("downloads.page.manage")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted mb-4">{t("downloads.page.subtitle")}</p>
|
||||
|
||||
|
||||
@@ -77,13 +77,9 @@ export default function DownloadDialog({
|
||||
onChange={([v]) => v && setProfileId(Number(v))}
|
||||
options={profiles.map((p) => ({ value: String(p.id), label: p.name }))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setManage(true)}
|
||||
className="text-xs text-accent hover:underline mt-1 mb-4"
|
||||
>
|
||||
<Button variant="link" onClick={() => setManage(true)} className="text-xs mt-1 mb-4">
|
||||
{t("downloads.dialog.manage")}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<label className="block text-sm font-medium mb-1">{t("downloads.dialog.nameLabel")}</label>
|
||||
<input
|
||||
|
||||
@@ -3,6 +3,7 @@ import i18n from "../i18n";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { errorAttrs, log } from "../lib/log";
|
||||
import { reloadingForNewBuild } from "../lib/staleBuild";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -46,12 +47,9 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
{i18n.t("errors.boundary.title")}
|
||||
</div>
|
||||
<div className="text-sm mb-3">{i18n.t("errors.boundary.subtitle")}</div>
|
||||
<button
|
||||
onClick={() => location.reload()}
|
||||
className="text-accent hover:underline text-sm font-semibold"
|
||||
>
|
||||
<Button variant="link" onClick={() => location.reload()} className="text-sm">
|
||||
{i18n.t("errors.boundary.reload")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type ReactNode } from "react";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// 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
|
||||
@@ -58,15 +59,9 @@ export default function ExcludedChannelList({
|
||||
onView={onView ? () => onView(c) : undefined}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAction(c.id)}
|
||||
title={actionLabel}
|
||||
aria-label={actionLabel}
|
||||
className="shrink-0 inline-flex items-center gap-1 rounded-full border border-border px-2 py-1 text-xs text-muted transition hover:border-accent hover:text-accent"
|
||||
>
|
||||
{actionIcon}
|
||||
<Button variant="outline" size="xs" icon={actionIcon} onClick={() => onAction(c.id)}>
|
||||
{actionLabel}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</NavGrid>
|
||||
|
||||
@@ -42,6 +42,7 @@ import { PlexPosterCard } from "./plexCards";
|
||||
import { useNavigationActions } from "./navigationContext";
|
||||
import { usePlexActions } from "./plexContext";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// The Home surface (E24) — "Mit nézzek most?".
|
||||
//
|
||||
@@ -244,8 +245,7 @@ function ShelfTile({
|
||||
? t("home.shelf.unwatchedCount", { count: count ?? 0 })
|
||||
: t("home.shelf.itemCount", { count: count ?? 0 });
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<Pressable
|
||||
onClick={() => card.next && onPlay(card)}
|
||||
{...menuProps?.(card)}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||
@@ -257,7 +257,7 @@ function ShelfTile({
|
||||
// Shift+F10, the only keyboard route to the menu. Inert to the click (the handler guards),
|
||||
// alive to all three input paths.
|
||||
aria-disabled={!card.next}
|
||||
className="block w-full text-left rounded-xl overflow-hidden border border-border bg-surface transition-colors aria-disabled:opacity-60 [&:not([aria-disabled='true'])]:hover:border-accent"
|
||||
className="block w-full rounded-xl overflow-hidden border border-border bg-surface transition-colors aria-disabled:opacity-60 [&:not([aria-disabled='true'])]:hover:border-accent"
|
||||
>
|
||||
<span className="block aspect-video bg-card overflow-hidden">
|
||||
{card.thumb ? (
|
||||
@@ -268,7 +268,7 @@ function ShelfTile({
|
||||
<span className="block text-xs font-medium truncate">{card.title}</span>
|
||||
<span className="block text-2xs text-muted">{countLabel}</span>
|
||||
</span>
|
||||
</button>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -307,8 +307,7 @@ function Hero({
|
||||
{...pinMenu.triggerProps(card)}
|
||||
className="mb-6 rounded-xl border border-border bg-surface p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
<Pressable
|
||||
onClick={() => onPlay(card)}
|
||||
className={`relative block w-full overflow-hidden rounded-lg border border-border bg-card ${
|
||||
card.aspect === "poster" ? "aspect-[2/3] max-w-[12rem]" : "aspect-video"
|
||||
@@ -331,7 +330,7 @@ function Hero({
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</Pressable>
|
||||
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<p className="text-2xs font-semibold uppercase tracking-wider text-accent">{kicker}</p>
|
||||
|
||||
@@ -172,9 +172,9 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
{configured ? t("messages.unlock") : t("messages.setUp")}
|
||||
</Button>
|
||||
{configured && (
|
||||
<button onClick={toReset} className="block text-xs text-muted hover:text-fg">
|
||||
<Button variant="link-muted" onClick={toReset} className="text-xs">
|
||||
{t("messages.forgotPass")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{!configured && !compact && <p className="text-xs text-muted">{t("messages.setupWarn")}</p>}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Check, Globe } from "lucide-react";
|
||||
import { LANGUAGES, type LangCode } from "../i18n";
|
||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// Compact language picker (globe + current code). Presentational: the parent decides what
|
||||
// changing the language does (set it; persist server-side when signed in).
|
||||
@@ -42,15 +43,16 @@ export default function LanguageSwitcher({
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
<Pressable
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
title={current.label}
|
||||
aria-label={current.label}
|
||||
layer
|
||||
className={
|
||||
rail
|
||||
? "relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
: "flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-card transition"
|
||||
? "relative p-2 rounded-lg text-muted hover:text-fg transition"
|
||||
: "flex h-7 items-center gap-1.5 px-2.5 rounded-lg text-sm text-muted hover:text-fg transition"
|
||||
}
|
||||
>
|
||||
<Globe className={rail ? "w-5 h-5" : "w-4 h-4"} />
|
||||
@@ -63,7 +65,7 @@ export default function LanguageSwitcher({
|
||||
) : (
|
||||
<span className="text-xs font-semibold uppercase">{current.code}</span>
|
||||
)}
|
||||
</button>
|
||||
</Pressable>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
|
||||
import type { Level } from "../lib/log";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { LEVEL_TAG, logClock } from "./logLineParts";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// One log line as both log viewers draw it — "Saját napló" (this tab's ring) and the admin
|
||||
// diagnostics console (the server's stored log): time, level, event code, message, and the line opens
|
||||
@@ -39,13 +40,13 @@ export default function LogLine({
|
||||
className="border-b border-border/50 last:border-b-0"
|
||||
{...navItemAttrs(navIndex, `${event} ${msg}`)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
<Pressable
|
||||
layer={!open}
|
||||
data-nav-open
|
||||
aria-expanded={open}
|
||||
onClick={onToggle}
|
||||
title={title}
|
||||
className={`grid w-full grid-cols-[6.5em_3.5em_minmax(0,1fr)] items-baseline sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] gap-x-2 px-3 py-1 text-left hover:bg-card ${
|
||||
className={`grid w-full grid-cols-[6.5em_3.5em_minmax(0,1fr)] items-baseline sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] gap-x-2 px-3 py-1 ${
|
||||
open ? "bg-accent/10" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -55,7 +56,7 @@ export default function LogLine({
|
||||
{/* A phone has no room for a fourth column: the message takes a line of its own under the
|
||||
code, full width — sharing the code's line left it a few characters. */}
|
||||
<span className="col-span-3 truncate sm:col-span-1">{msg}</span>
|
||||
</button>
|
||||
</Pressable>
|
||||
{open && <div className="bg-accent/5 px-3 py-2 text-muted">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import { inputCls } from "./ui/form";
|
||||
import ChatThread from "./ChatThread";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
type View =
|
||||
| "list"
|
||||
@@ -158,10 +159,10 @@ function ConversationList({
|
||||
{...navItemAttrs(i, c.partner.name)}
|
||||
className="group flex items-center gap-1 rounded-xl hover:bg-card transition"
|
||||
>
|
||||
<button
|
||||
<Pressable
|
||||
data-nav-open=""
|
||||
onClick={() => onOpen(c)}
|
||||
className="flex items-center gap-3 p-2.5 text-left min-w-0 flex-1"
|
||||
className="flex items-center gap-3 p-2.5 min-w-0 flex-1"
|
||||
>
|
||||
<Avatar
|
||||
src={c.partner.avatar_url}
|
||||
@@ -192,16 +193,16 @@ function ConversationList({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</Pressable>
|
||||
{!isSystem && ready && (
|
||||
<button
|
||||
<IconButton
|
||||
size="md"
|
||||
onClick={() => openChat(c.partner)}
|
||||
title={t("messages.popOut")}
|
||||
aria-label={t("messages.popOut")}
|
||||
className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted reveal hover:text-accent hover:bg-bg transition"
|
||||
label={t("messages.popOut")}
|
||||
className="mr-1.5 reveal"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
<ExternalLink />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -253,10 +254,11 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{users.map((u) => (
|
||||
<button
|
||||
<Pressable
|
||||
key={u.id}
|
||||
layer
|
||||
onClick={() => onPick(u)}
|
||||
className="flex items-center gap-3 p-2.5 rounded-xl text-left hover:bg-card transition"
|
||||
className="flex items-center gap-3 p-2.5 rounded-xl"
|
||||
>
|
||||
<Avatar
|
||||
src={u.avatar_url}
|
||||
@@ -264,7 +266,7 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
|
||||
className="w-9 h-9 rounded-full shrink-0 text-sm"
|
||||
/>
|
||||
<span className="truncate font-medium">{u.name}</span>
|
||||
</button>
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -317,14 +319,13 @@ function PageThread({
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{!isSystem && ready && (
|
||||
<button
|
||||
<IconButton
|
||||
size="md"
|
||||
onClick={() => openChat({ id: partnerId, name, avatar_url: seedAvatar ?? null })}
|
||||
title={t("messages.popOut")}
|
||||
aria-label={t("messages.popOut")}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-accent hover:bg-card transition"
|
||||
label={t("messages.popOut")}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
<ExternalLink />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
<ChatThread meId={meId} partnerId={partnerId} isSystem={isSystem} />
|
||||
|
||||
@@ -11,6 +11,7 @@ import Toaster from "./ui/Toaster";
|
||||
import { useOverlayOrder } from "./ui/overlayOrder";
|
||||
import { useLayer } from "../lib/layerStack";
|
||||
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
// 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
|
||||
@@ -172,13 +173,14 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
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)]"
|
||||
>
|
||||
<button
|
||||
<IconButton
|
||||
size="md"
|
||||
onClick={close}
|
||||
className="absolute top-3 right-3 p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
|
||||
aria-label={t("onboarding.close")}
|
||||
className="absolute top-3 right-3"
|
||||
label={t("onboarding.close")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<X />
|
||||
</IconButton>
|
||||
|
||||
{step === "read" && (
|
||||
<>
|
||||
@@ -196,12 +198,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
</p>
|
||||
<ConsentHeadsUp />
|
||||
<div className="mt-5 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => beginGrant("read")}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold btn-primary transition"
|
||||
>
|
||||
<Youtube className="w-4 h-4" /> {t("onboarding.read.connect")}
|
||||
</button>
|
||||
<Button size="lg" icon={<Youtube />} onClick={() => beginGrant("read")}>
|
||||
{t("onboarding.read.connect")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="md" onClick={close}>
|
||||
{t("onboarding.read.skip")}
|
||||
</Button>
|
||||
@@ -254,21 +253,21 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
)}
|
||||
</p>
|
||||
{importSubs.isError && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={<RefreshCw />}
|
||||
loading={importSubs.isPending}
|
||||
onClick={() => importSubs.mutate()}
|
||||
className="mb-3 inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl text-sm bg-card border border-border hover:border-accent transition"
|
||||
className="mb-3"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" /> {t("onboarding.write.retryImport")}
|
||||
</button>
|
||||
{t("onboarding.write.retryImport")}
|
||||
</Button>
|
||||
)}
|
||||
<ConsentHeadsUp />
|
||||
<div className="mt-5 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => beginGrant("write")}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold btn-primary transition"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" /> {t("onboarding.write.enableUnsubscribe")}
|
||||
</button>
|
||||
<Button size="lg" icon={<ShieldCheck />} onClick={() => beginGrant("write")}>
|
||||
{t("onboarding.write.enableUnsubscribe")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
@@ -294,15 +293,16 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
<p className="text-sm text-muted leading-relaxed mt-2 mb-5">
|
||||
{t("onboarding.done.body")}
|
||||
</p>
|
||||
<button
|
||||
<Button
|
||||
size="lg"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
endOnboarding(false);
|
||||
onClose();
|
||||
}}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold btn-primary transition w-full"
|
||||
>
|
||||
{t("onboarding.done.done")}
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Command } from "lucide-react";
|
||||
|
||||
import { SHORTCUTS, formatChord, openPalette } from "../lib/keymap";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// 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
|
||||
@@ -30,15 +31,14 @@ export function PaletteKeycap() {
|
||||
const { t } = useTranslation();
|
||||
const cap = useCap();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<Pressable
|
||||
onClick={openPalette}
|
||||
aria-label={`${t("palette.open")} (${cap})`}
|
||||
title={`${t("palette.open")} (${cap})`}
|
||||
className="shrink-0 rounded-md border border-border px-1.5 py-0.5 font-mono text-3xs text-muted transition hover:border-accent/50 hover:text-fg @max-[640px]/appmain:hidden"
|
||||
>
|
||||
{cap}
|
||||
</button>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,8 +47,7 @@ export function PaletteLauncher() {
|
||||
const { t } = useTranslation();
|
||||
const cap = useCap();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<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"
|
||||
@@ -58,6 +57,6 @@ export function PaletteLauncher() {
|
||||
<span className="rounded-md border border-border px-1.5 py-0.5 font-mono text-3xs @max-[640px]/appmain:hidden">
|
||||
{cap}
|
||||
</span>
|
||||
</button>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BookmarkPlus, Check, Play, Save, Star, Trash2, X } from "lucide-react";
|
||||
import { BookmarkPlus, Check, Play, Save, Star, Trash2 } from "lucide-react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
@@ -23,6 +23,7 @@ import UndoToolbar from "./ui/UndoToolbar";
|
||||
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
|
||||
import { DragHandle } from "./ui/DragHandle";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { DismissibleChip } from "./ui/Chip";
|
||||
|
||||
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
|
||||
// reorder) plus a "save the current filters" input; the right column shows the selected view's name
|
||||
@@ -259,20 +260,14 @@ export default function PersonalViewsModal() {
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{captured.map((f) => (
|
||||
<span
|
||||
<DismissibleChip
|
||||
key={f.key}
|
||||
className="group inline-flex items-center gap-1 rounded-full bg-accent/10 py-0.5 pl-2 pr-1 text-2xs text-accent"
|
||||
size="xs"
|
||||
onDismiss={f.remove}
|
||||
dismissLabel={t("feed.chips.remove", { name: f.label })}
|
||||
>
|
||||
{f.label}
|
||||
<button
|
||||
onClick={f.remove}
|
||||
title={t("feed.chips.remove", { name: f.label })}
|
||||
aria-label={t("feed.chips.remove", { name: f.label })}
|
||||
className="rounded-full p-0.5 opacity-60 transition hover:bg-accent/20 hover:opacity-100"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
</DismissibleChip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
formatViews,
|
||||
relativeTime,
|
||||
} from "../lib/format";
|
||||
import { renderDescription } from "../lib/descriptionLinks";
|
||||
import { renderDescription } from "./descriptionLinks";
|
||||
import { fullscreenElement } from "../lib/fullscreen";
|
||||
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
@@ -748,9 +748,9 @@ export default function PlayerModal({
|
||||
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
|
||||
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-medium state-filled touch-target"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<ExternalLink className="size-4" />
|
||||
{t("player.openOnYoutube")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Info } from "lucide-react";
|
||||
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// 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).
|
||||
@@ -29,14 +30,9 @@ export default function PlaylistLimitsHint() {
|
||||
>
|
||||
<Info className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>{t("playlists.limits.hint")}</span>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
<Button variant="link" ref={pop.refs.setReference} {...pop.getReferenceProps()}>
|
||||
{t("playlists.limits.why")}
|
||||
</button>
|
||||
</Button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("playlists.limits.title")}
|
||||
|
||||
@@ -27,11 +27,9 @@ import {
|
||||
ExternalLink,
|
||||
FolderInput,
|
||||
History,
|
||||
GripVertical,
|
||||
ListPlus,
|
||||
Pencil,
|
||||
Play,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Upload,
|
||||
@@ -68,8 +66,8 @@ import { ColumnHead } from "./ui/tableHeader";
|
||||
import { alignClass } from "./ui/tableAlign";
|
||||
import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns";
|
||||
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
|
||||
import { DragHandle } from "./ui/DragHandle";
|
||||
|
||||
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
|
||||
// body that is still a drag-reorderable @dnd-kit list.
|
||||
@@ -146,21 +144,12 @@ function Row({
|
||||
// The one cell the column defs can't render: the handle needs this row's sortable
|
||||
// attributes/listeners. Disabled (not hidden) under a view, so the affordance stays
|
||||
// put and can explain itself instead of silently doing nothing.
|
||||
<button
|
||||
<DragHandle
|
||||
{...attributes}
|
||||
{...(dragDisabled ? {} : listeners)}
|
||||
// `disabled` would drop it from the tab order AND suppress the tooltip, so the
|
||||
// reason reached nobody. aria-disabled keeps it focusable and the name carries the why.
|
||||
aria-disabled={dragDisabled || undefined}
|
||||
onClick={(e) => dragDisabled && e.preventDefault()}
|
||||
title={dragDisabled ? dragHint : t("playlists.dragHandle")}
|
||||
aria-label={
|
||||
dragDisabled ? dragHint : t("playlists.dragHandleOf", { title: video.title })
|
||||
}
|
||||
className={`text-muted ${dragDisabled ? "opacity-30 cursor-not-allowed" : "hover:text-fg cursor-grab active:cursor-grabbing"}`}
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</button>
|
||||
unavailable={dragDisabled}
|
||||
label={dragDisabled ? dragHint : t("playlists.dragHandleOf", { title: video.title })}
|
||||
/>
|
||||
) : (
|
||||
col.render(video)
|
||||
)}
|
||||
@@ -896,17 +885,15 @@ export default function Playlists() {
|
||||
<>
|
||||
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
||||
{editable && (
|
||||
<button
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setRenameValue(detail.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
title={t("playlists.rename")}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="shrink-0 text-muted hover:text-fg"
|
||||
label={t("playlists.rename")}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<Pencil />
|
||||
</IconButton>
|
||||
)}
|
||||
{detail.yt_playlist_id && (
|
||||
<a
|
||||
@@ -932,37 +919,25 @@ export default function Playlists() {
|
||||
{t("playlists.unsynced")}
|
||||
</span>
|
||||
)}
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => shown.length && openPlayer(shown[0]!.id)}
|
||||
disabled={!shown.length}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
|
||||
icon={<Play className="fill-current" />}
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
|
||||
</FocusSafeButton>
|
||||
{t("playlists.playAll")}
|
||||
</Button>
|
||||
{editable && canWrite && (
|
||||
<FocusSafeButton
|
||||
<IconButton
|
||||
variant={detail.dirty ? "outline-accent" : "outline"}
|
||||
onClick={pushToYoutube}
|
||||
disabled={pushing}
|
||||
title={
|
||||
loading={pushing}
|
||||
label={
|
||||
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
|
||||
}
|
||||
aria-label={
|
||||
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
|
||||
}
|
||||
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
|
||||
detail.dirty
|
||||
? "border-accent text-accent hover:bg-accent/10"
|
||||
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
{pushing ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : linked ? (
|
||||
<Youtube className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</FocusSafeButton>
|
||||
{linked ? <Youtube /> : <Upload />}
|
||||
</IconButton>
|
||||
)}
|
||||
{linked && detail.dirty && (
|
||||
<IconButton
|
||||
@@ -1009,12 +984,13 @@ export default function Playlists() {
|
||||
{t("playlists.groupByChannel")}
|
||||
</label>
|
||||
{viewActive && (
|
||||
<button
|
||||
<Button
|
||||
variant="link-muted"
|
||||
onClick={clearView}
|
||||
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
|
||||
className="text-xs underline underline-offset-2"
|
||||
>
|
||||
{t("playlists.clearView")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{canSaveOrder && (
|
||||
<Button
|
||||
|
||||
@@ -23,6 +23,8 @@ import { useMe } from "../lib/useMe";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
import { usePlaylistActions } from "./usePlaylistActions";
|
||||
import PlaylistLimitsHint from "./PlaylistLimitsHint";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
type PlSort = {
|
||||
key: "custom" | "name" | "count" | "duration" | "recent";
|
||||
@@ -255,14 +257,14 @@ export default function PlaylistsRail({
|
||||
next && setPlSort({ ...plSort, key: next.key as PlSort["key"], dir: next.dir })
|
||||
}
|
||||
trailing={
|
||||
<button
|
||||
<IconButton
|
||||
variant="outline"
|
||||
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
|
||||
title={t("playlists.dirtyFirst")}
|
||||
aria-pressed={plSort.dirtyFirst}
|
||||
className="pill shrink-0 rounded-lg border p-1.5"
|
||||
label={t("playlists.dirtyFirst")}
|
||||
pressed={plSort.dirtyFirst}
|
||||
>
|
||||
<Pin className="h-4 w-4" />
|
||||
</button>
|
||||
<Pin />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -292,13 +294,12 @@ export default function PlaylistsRail({
|
||||
<>
|
||||
{playlists.length === 0 &&
|
||||
(listQuery.isError && !listQuery.data ? (
|
||||
<button
|
||||
type="button"
|
||||
<Pressable
|
||||
onClick={() => listQuery.refetch()}
|
||||
className="text-xs text-danger px-1 py-1 text-left hover:underline"
|
||||
className="text-xs text-danger px-1 py-1 hover:underline"
|
||||
>
|
||||
{t("common.loadError")} — {t("common.retry")}
|
||||
</button>
|
||||
</Pressable>
|
||||
) : listQuery.isLoading ? (
|
||||
<div className="text-xs text-muted px-1 py-1">{t("common.loading")}</div>
|
||||
) : (
|
||||
@@ -342,7 +343,7 @@ export default function PlaylistsRail({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
<Pressable
|
||||
key={pl.id}
|
||||
ref={(el) => {
|
||||
if (pl.id === selectedId) selectedRef.current = el;
|
||||
@@ -354,10 +355,9 @@ export default function PlaylistsRail({
|
||||
}}
|
||||
onClick={() => setSelectedId(pl.id)}
|
||||
{...railMenu.triggerProps(pl)}
|
||||
className={`${ROW_BASE} text-left ${
|
||||
pl.id === selectedId
|
||||
? "bg-accent/15 border-accent"
|
||||
: "border-transparent hover:bg-card/60"
|
||||
layer={pl.id !== selectedId}
|
||||
className={`${ROW_BASE} ${
|
||||
pl.id === selectedId ? "bg-accent/15 border-accent" : "border-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="w-8.5 h-8.5 rounded-md bg-card overflow-hidden shrink-0">
|
||||
@@ -380,7 +380,7 @@ export default function PlaylistsRail({
|
||||
` · ${formatRuntime(pl.total_duration_seconds)}`}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</Pressable>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,9 @@ import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
|
||||
import { dispatch, useKeyScope } from "../lib/keymap";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { Chip } from "./ui/Chip";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
||||
@@ -553,10 +556,10 @@ export default function PlexBrowse() {
|
||||
className="glass-card flex items-center gap-1 rounded-xl py-1.5 pl-1.5 pr-2.5"
|
||||
>
|
||||
{/* The photo + name open the actor's filmography page; the ✕ removes the filter. */}
|
||||
<button
|
||||
<Pressable
|
||||
onClick={() => onOpenPerson(p.name)}
|
||||
title={t("plex.info.viewPerson", { name: p.name })}
|
||||
className="group/person flex min-w-0 items-center gap-2.5 rounded-lg text-left"
|
||||
className="group/person flex min-w-0 items-center gap-2.5 rounded-lg"
|
||||
>
|
||||
{p.photo ? (
|
||||
<img
|
||||
@@ -578,15 +581,14 @@ export default function PlexBrowse() {
|
||||
{t("plex.people.count", { count: p.count })}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
</Pressable>
|
||||
<IconButton
|
||||
onClick={() => removeActor(p.name)}
|
||||
title={t("plex.people.remove")}
|
||||
aria-label={t("plex.people.remove")}
|
||||
className="ml-1 p-1 rounded-full text-muted hover:text-fg hover:bg-card transition"
|
||||
label={t("plex.people.remove")}
|
||||
className="ml-1"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -604,12 +606,9 @@ export default function PlexBrowse() {
|
||||
// query — say so and offer a one-click escape, instead of a bare "No matches".
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted">
|
||||
<span>{t("plex.noMatchesFiltered", { count: plexFilterCount(filters) })}</span>
|
||||
<button
|
||||
onClick={() => setFilters(EMPTY_PLEX_FILTERS)}
|
||||
className="rounded-full border border-border bg-card px-3 py-1 text-xs font-medium transition hover:border-accent hover:text-accent"
|
||||
>
|
||||
<Chip onClick={() => setFilters(EMPTY_PLEX_FILTERS)} className="font-medium">
|
||||
{t("plex.filter.clearAll")}
|
||||
</button>
|
||||
</Chip>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// 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
|
||||
@@ -191,10 +191,12 @@ export default function PlexCollectionEditor({
|
||||
key={c.id}
|
||||
className="glass-card flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm"
|
||||
>
|
||||
<FocusSafeButton
|
||||
<Pressable
|
||||
role="checkbox"
|
||||
aria-checked={inIt}
|
||||
onClick={() => toggle(c.id)}
|
||||
disabled={!!busy}
|
||||
className={`inline-flex min-w-0 flex-1 items-center gap-2 text-left disabled:opacity-50 ${
|
||||
className={`inline-flex min-w-0 flex-1 items-center gap-2 disabled:opacity-50 ${
|
||||
inIt ? "text-accent" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -209,14 +211,15 @@ export default function PlexCollectionEditor({
|
||||
<span className="shrink-0 text-xs text-muted">
|
||||
{t("plex.collEditor.count", { count: c.child_count ?? 0 })}
|
||||
</span>
|
||||
</FocusSafeButton>
|
||||
<FocusSafeButton
|
||||
</Pressable>
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="xs"
|
||||
onClick={() => removeCollection(c.id, c.title)}
|
||||
disabled={!!busy}
|
||||
className="shrink-0 rounded-sm px-1.5 py-0.5 text-xs text-muted hover:text-danger disabled:opacity-50"
|
||||
>
|
||||
{t("plex.collEditor.delete")}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
@@ -243,13 +246,14 @@ export default function PlexCollectionEditor({
|
||||
<span className="shrink-0 text-xs text-muted">
|
||||
{t("plex.collEditor.count", { count: c.child_count ?? 0 })}
|
||||
</span>
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
size="xs"
|
||||
onClick={() => markEditable(c.id)}
|
||||
disabled={!!busy}
|
||||
className="glass-card glass-hover shrink-0 rounded-sm px-2 py-0.5 text-xs disabled:opacity-50"
|
||||
>
|
||||
{t("plex.collEditor.makeEditable")}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,8 @@ import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
// 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,
|
||||
@@ -312,12 +313,14 @@ export default function PlexCollectionView({
|
||||
>
|
||||
{d.summary}
|
||||
</p>
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="mt-1 text-xs font-medium text-accent hover:underline"
|
||||
className="mt-1 text-xs"
|
||||
>
|
||||
{expanded ? t("plex.collection.less") : t("plex.collection.more")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -412,15 +415,15 @@ export default function PlexCollectionView({
|
||||
menuProps={pinMenu.triggerProps}
|
||||
/>
|
||||
{canEdit && (
|
||||
<FocusSafeButton
|
||||
<IconButton
|
||||
variant="media"
|
||||
onClick={() => doRemove(c)}
|
||||
disabled={busy}
|
||||
title={t("plex.collection.removeItem")}
|
||||
aria-label={t("plex.collection.removeItem")}
|
||||
className="glass-card glass-hover absolute right-1.5 top-1.5 z-base grid h-6 w-6 place-items-center rounded-full text-fg disabled:opacity-50"
|
||||
label={t("plex.collection.removeItem")}
|
||||
className="absolute right-1.5 top-1.5 z-base"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</FocusSafeButton>
|
||||
<X />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -475,13 +478,9 @@ function AddItemPanel({
|
||||
aria-label={t("plex.collection.addSearch")}
|
||||
className="glass-card flex-1 rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label={t("common.close")}
|
||||
className="glass-hover grid h-8 w-8 place-items-center rounded-lg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<IconButton size="md" onClick={onClose} label={t("common.close")}>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
<PlexLibraryPicker
|
||||
scope={scope}
|
||||
@@ -490,14 +489,15 @@ function AddItemPanel({
|
||||
renderAction={(c) => {
|
||||
const inColl = existing.has(c.id) || added.has(c.id);
|
||||
return (
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
size="xs"
|
||||
icon={inColl ? <Check /> : <Plus />}
|
||||
onClick={() => add(c)}
|
||||
disabled={inColl}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
||||
>
|
||||
{inColl ? <Check className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{inColl ? t("plex.collection.added") : t("plex.collection.add")}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PlexPinMenu } from "./ComfortPinMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
// The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE
|
||||
// /api/plex/discover call — Continue watching, Recently added, Browse by genre (tiles), Because you
|
||||
@@ -213,15 +214,13 @@ export default function PlexDiscover({
|
||||
ariaLabel={t("plex.discover.surprise")}
|
||||
cards={surprise}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
onClick={() => surpriseQ.refetch()}
|
||||
aria-label={t("plex.discover.reshuffle")}
|
||||
title={t("plex.discover.reshuffle")}
|
||||
className="rounded-full p-1 text-muted transition hover:text-accent"
|
||||
loading={surpriseQ.isFetching}
|
||||
label={t("plex.discover.reshuffle")}
|
||||
>
|
||||
<RotateCcw className={`h-3.5 w-3.5 ${surpriseQ.isFetching ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
<RotateCcw />
|
||||
</IconButton>
|
||||
}
|
||||
{...railProps}
|
||||
/>
|
||||
|
||||
@@ -82,6 +82,9 @@ import {
|
||||
anyBlockingLayerAbove,
|
||||
escapeHandled,
|
||||
} from "../lib/layerStack";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||
@@ -1231,13 +1234,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 text-white">
|
||||
<button
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
size="player"
|
||||
onMedia
|
||||
onClick={handleBack}
|
||||
className="p-1.5 rounded-lg hover:bg-white/15"
|
||||
title={t("plex.player.back")}
|
||||
label={t("plex.player.back")}
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<ArrowLeft />
|
||||
</IconButton>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold truncate">
|
||||
{detail?.kind === "episode" && detail.show_title ? detail.show_title : detail?.title}
|
||||
@@ -1270,9 +1275,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
|
||||
{/* Skip intro / credits (with the auto-skip countdown fill, if armed) */}
|
||||
{activeMarker && (
|
||||
<button
|
||||
<Button
|
||||
variant="media"
|
||||
onClick={() => doSkip(activeMarker)}
|
||||
className={`absolute right-6 bottom-28 overflow-hidden rounded-lg bg-white/90 px-4 py-2 text-sm font-medium text-black transition hover:bg-white ${
|
||||
className={`absolute right-6 bottom-28 overflow-hidden ${
|
||||
uiVisible ? "opacity-100" : "opacity-80"
|
||||
}`}
|
||||
>
|
||||
@@ -1295,7 +1301,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
? t("plex.player.skipIntro")
|
||||
: t("plex.player.skipCredits")}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Bottom controls */}
|
||||
@@ -1476,14 +1482,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
{/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. */}
|
||||
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
|
||||
<div className="relative">
|
||||
<button
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
size="player"
|
||||
onMedia
|
||||
ref={tracksTriggerRef}
|
||||
onClick={() => setTracksOpen((o) => !o)}
|
||||
aria-label={t("plex.player.tracks")}
|
||||
className={`p-1 hover:text-accent ${tracksOpen ? "text-accent" : ""}`}
|
||||
aria-expanded={tracksOpen}
|
||||
label={t("plex.player.tracks")}
|
||||
>
|
||||
<Languages className="w-5 h-5" />
|
||||
</button>
|
||||
<Languages />
|
||||
</IconButton>
|
||||
{tracksOpen && (
|
||||
<div
|
||||
ref={setTracksNode}
|
||||
@@ -1496,42 +1505,42 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
<>
|
||||
<PanelHead>{t("plex.player.audio")}</PanelHead>
|
||||
{detail.audio_streams.map((a) => (
|
||||
<button
|
||||
<Pressable
|
||||
key={a.ord}
|
||||
onClick={() => changeAudio(a.ord)}
|
||||
aria-pressed={(audioOrd ?? 0) === a.ord}
|
||||
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
|
||||
className={`block w-full truncate rounded px-2 py-1 hover:bg-white/10 ${
|
||||
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
</Pressable>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{textSubs.length > 0 && (
|
||||
<>
|
||||
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
|
||||
<button
|
||||
<Pressable
|
||||
onClick={() => changeSubtitle(null)}
|
||||
aria-pressed={subOrd == null}
|
||||
className={`block w-full rounded px-2 py-1 text-left hover:bg-white/10 ${
|
||||
className={`block w-full rounded px-2 py-1 hover:bg-white/10 ${
|
||||
subOrd == null ? "text-accent" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{t("plex.player.subOff")}
|
||||
</button>
|
||||
</Pressable>
|
||||
{textSubs.map((s) => (
|
||||
<button
|
||||
<Pressable
|
||||
key={s.ord}
|
||||
onClick={() => changeSubtitle(s.ord)}
|
||||
aria-pressed={subOrd === s.ord}
|
||||
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
|
||||
className={`block w-full truncate rounded px-2 py-1 hover:bg-white/10 ${
|
||||
subOrd === s.ord ? "text-accent" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
</Pressable>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
@@ -1543,14 +1552,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
{/* Gear = tuning, split into tabs (Sync / Playback / Subtitle). */}
|
||||
{detail && (
|
||||
<div className="relative">
|
||||
<button
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
size="player"
|
||||
onMedia
|
||||
ref={menuTriggerRef}
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
aria-label={t("plex.player.settings")}
|
||||
className={`p-1 hover:text-accent ${menuOpen ? "text-accent" : ""}`}
|
||||
aria-expanded={menuOpen}
|
||||
label={t("plex.player.settings")}
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
</button>
|
||||
<Settings />
|
||||
</IconButton>
|
||||
{menuOpen && (
|
||||
<PlexPlayerSettings
|
||||
menuRef={menuRef}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Slider, Toggle } from "./plexPlayerControls";
|
||||
import { clockParts, type PlexPlayerPrefs } from "../lib/plexPlayerPrefs";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
type SettingsTab = "sync" | "playback" | "subtitle" | "clock";
|
||||
|
||||
@@ -44,15 +45,17 @@ export function PlexPlayerSettings(props: {
|
||||
>
|
||||
<div className="mb-1.5 flex gap-1">
|
||||
{settingsTabs.map((tab) => (
|
||||
<button
|
||||
<Pressable
|
||||
key={tab.key}
|
||||
align="center"
|
||||
aria-pressed={activeTab === tab.key}
|
||||
onClick={() => setSettingsTab(tab.key)}
|
||||
className={`flex-1 whitespace-nowrap rounded px-2 py-1 text-2xs ${
|
||||
activeTab === tab.key ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -253,8 +256,10 @@ export function PlexPlayerSettings(props: {
|
||||
{prefs.clockDate && (
|
||||
<div className="flex gap-1 px-1 py-1">
|
||||
{(["hu", "en"] as const).map((st) => (
|
||||
<button
|
||||
<Pressable
|
||||
key={st}
|
||||
align="center"
|
||||
aria-pressed={prefs.clockDateStyle === st}
|
||||
onClick={() => patchPrefs({ clockDateStyle: st })}
|
||||
className={`flex-1 rounded px-2 py-1 text-2xs ${
|
||||
prefs.clockDateStyle === st
|
||||
@@ -263,7 +268,7 @@ export function PlexPlayerSettings(props: {
|
||||
}`}
|
||||
>
|
||||
{clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date}
|
||||
</button>
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// 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.
|
||||
@@ -146,11 +146,13 @@ export default function PlexPlaylistAdd({
|
||||
const all = n >= size;
|
||||
const partial = n > 0 && n < size;
|
||||
return (
|
||||
<FocusSafeButton
|
||||
<Pressable
|
||||
key={p.id}
|
||||
role="checkbox"
|
||||
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-left text-sm disabled:opacity-50 ${
|
||||
className={`glass-card flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm disabled:opacity-50 ${
|
||||
all ? "text-accent" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -175,7 +177,7 @@ export default function PlexPlaylistAdd({
|
||||
{t("plex.playlistAdd.count", { count: p.item_count })}
|
||||
</span>
|
||||
)}
|
||||
</FocusSafeButton>
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { availablePlexFields, healPlexField, plexFieldLabelKey } from "../lib/plexSearch";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// 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
|
||||
@@ -144,17 +145,16 @@ export default function PlexSearchBox() {
|
||||
<div className="px-3 py-2 text-sm text-muted">{t("plex.loading")}</div>
|
||||
)}
|
||||
{suggestions.map((c, i) => (
|
||||
<button
|
||||
<Pressable
|
||||
key={c.id}
|
||||
id={`${listId}-opt-${i}`}
|
||||
data-idx={i}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={hi === i}
|
||||
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 text-left transition ${
|
||||
className={`flex w-full items-center gap-3 rounded-xl p-1.5 transition ${
|
||||
hi === i ? "bg-border/60" : "hover:bg-border/40"
|
||||
}`}
|
||||
>
|
||||
@@ -173,7 +173,7 @@ export default function PlexSearchBox() {
|
||||
) : (
|
||||
<Film className="w-3.5 h-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
|
||||
@@ -4,7 +4,7 @@ import { usePlexCollectionTitles } from "../lib/usePlexCollectionTitles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { plexLang } from "../i18n";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
||||
import { ListMusic, Plus, SlidersHorizontal } from "lucide-react";
|
||||
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { type PanelLayout } from "../lib/panelLayout";
|
||||
@@ -20,6 +20,7 @@ import PersonalViewsModal from "./PersonalViewsModal";
|
||||
import { useSavedViews } from "./savedViewsContext";
|
||||
import { usePlex, usePlexActions } from "./plexContext";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { DismissibleChip } from "./ui/Chip";
|
||||
|
||||
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
|
||||
// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
|
||||
@@ -498,16 +499,13 @@ function CollectionPicker({
|
||||
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className="chip px-2 py-1 text-xs">
|
||||
<span className="truncate max-w-36">{label}</span>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="hover:opacity-70"
|
||||
aria-label={t("common.removeNamed", { name: label })}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
<DismissibleChip
|
||||
onDismiss={onRemove}
|
||||
dismissLabel={t("common.removeNamed", { name: label })}
|
||||
className="max-w-40"
|
||||
>
|
||||
{label}
|
||||
</DismissibleChip>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type Hls from "hls.js";
|
||||
import { X } from "lucide-react";
|
||||
import { buildStatRows, type FragMeter, type HlsLevelInfo, type StatRow } from "../lib/plexStats";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
// "Stats for nerds" debug overlay (E6 S2). Samples the live HTML5 <video> + hls.js state once a second
|
||||
// and renders the active mode, resolution, codec/bitrate, dropped frames, buffer, hls level and session
|
||||
@@ -133,13 +134,9 @@ export function PlexStatsOverlay({
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-white/70">
|
||||
{t("plex.player.stats.title")}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label={t("plex.player.close")}
|
||||
className="rounded p-0.5 text-white/60 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<IconButton variant="overlay" onClick={onClose} label={t("plex.player.close")}>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/* No aria-live here: the rows re-sample every second, so a live region would make a screen
|
||||
reader announce the changing values continuously and drown out real player announcements.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { hueOf, hueTileGradient } from "../lib/hueTile";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}),
|
||||
// the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable
|
||||
@@ -26,12 +27,11 @@ export default function PosterTile({
|
||||
const posters = (art ?? []).slice(0, 2);
|
||||
const band = hueTileGradient(hueOf(title));
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<Pressable
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||
className={`glass-hover group relative flex flex-col overflow-hidden rounded-xl text-left transition ${className ?? ""}`}
|
||||
className={`glass-hover group relative flex flex-col overflow-hidden rounded-xl transition ${className ?? ""}`}
|
||||
>
|
||||
{/* Top half: 2 montage posters, or the colour hash when a group has no art. */}
|
||||
<div
|
||||
@@ -60,6 +60,6 @@ export default function PosterTile({
|
||||
<div className="truncate text-sm font-semibold text-white drop-shadow">{title}</div>
|
||||
{subtitle != null && <div className="truncate text-2xs text-white/80">{subtitle}</div>}
|
||||
</div>
|
||||
</button>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { inputCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
|
||||
|
||||
@@ -129,20 +130,16 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
||||
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg glass-card"
|
||||
>
|
||||
<span className="flex-1 truncate">{p.name}</span>
|
||||
<button
|
||||
onClick={() => startEdit(p)}
|
||||
title={t("downloads.actions.rename")}
|
||||
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-fg"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
<IconButton onClick={() => startEdit(p)} label={t("downloads.actions.rename")}>
|
||||
<Pencil />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="danger"
|
||||
onClick={() => onDelete(p)}
|
||||
title={t("downloads.profiles.delete")}
|
||||
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-danger"
|
||||
label={t("downloads.profiles.delete")}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<Trash2 />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Bookmark, Check, Settings2 } from "lucide-react";
|
||||
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";
|
||||
|
||||
// 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
|
||||
@@ -26,15 +28,26 @@ export default function SavedViewsPopover({
|
||||
const label = ariaLabel ?? t("views.saved");
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={label}
|
||||
className={className ?? "shrink-0 flex text-accent transition hover:text-fg"}
|
||||
>
|
||||
{trigger}
|
||||
</button>
|
||||
{/* A caller-shaped trigger (the toolbar's view chip) is a surface; the default one a glyph. */}
|
||||
{className ? (
|
||||
<Pressable
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={label}
|
||||
className={className}
|
||||
>
|
||||
{trigger}
|
||||
</Pressable>
|
||||
) : (
|
||||
<IconButton
|
||||
variant="ghost-accent"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
label={label}
|
||||
>
|
||||
{trigger}
|
||||
</IconButton>
|
||||
)}
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={label}
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useSearchFocusKey } from "../lib/keymap";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { PaletteKeycap } from "./PaletteLauncher";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
// The floating search pill in the top header. One component for every module that searches
|
||||
// (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a
|
||||
@@ -92,28 +93,23 @@ export default function SearchBar({
|
||||
className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
onClick={() => onChange("")}
|
||||
title={t("header.clearSearch")}
|
||||
aria-label={t("header.clearSearch")}
|
||||
className="shrink-0 p-0.5 rounded-full text-muted transition hover:text-fg hover:bg-border/60"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<IconButton onClick={() => onChange("")} label={t("header.clearSearch")}>
|
||||
<X />
|
||||
</IconButton>
|
||||
)}
|
||||
<PaletteKeycap />
|
||||
{onGo && (
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={fire}
|
||||
disabled={goDisabled}
|
||||
title={goTitle ?? goLabel ?? t("header.goTip")}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:opacity-90 disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
{goIcon}
|
||||
<span className={goIcon ? "hidden md:inline" : undefined}>
|
||||
{goLabel ?? t("header.go")}
|
||||
</span>
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{below}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { setLanguage, type LangCode } from "../i18n";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { Button } from "./ui/Button";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
|
||||
// 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
|
||||
@@ -280,27 +279,23 @@ export default function SetupWizard() {
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
icon={<ChevronLeft />}
|
||||
onClick={() => go(-1)}
|
||||
disabled={stepIdx === 0 || busy}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1 px-3 py-2 rounded-xl text-sm disabled:opacity-40 transition"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
{t("setup.nav.back")}
|
||||
</FocusSafeButton>
|
||||
<FocusSafeButton
|
||||
</Button>
|
||||
<Button
|
||||
onClick={next}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm btn-primary font-medium shadow-md shadow-accent/20 disabled:opacity-50 transition"
|
||||
loading={busy}
|
||||
icon={step === "finish" ? <Check /> : undefined}
|
||||
className="shadow-md shadow-accent/20"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : step === "finish" ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : null}
|
||||
{step === "finish" ? t("setup.nav.finish") : t("setup.nav.next")}
|
||||
{step !== "finish" && !busy && <ChevronRight className="w-4 h-4" />}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import Modal from "./ui/Modal";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import { notify } from "../lib/notifications";
|
||||
@@ -14,7 +13,7 @@ import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../l
|
||||
import { inputCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
|
||||
|
||||
// --- share with a registered user (ACL) -----------------------------------------------------
|
||||
@@ -72,15 +71,16 @@ function UserShare({ job }: { job: DownloadJob }) {
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
|
||||
{filtered.map((u) => (
|
||||
<FocusSafeButton
|
||||
<Pressable
|
||||
key={u.id}
|
||||
layer
|
||||
onClick={() => share.mutate(u.email)}
|
||||
disabled={share.isPending}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-surface transition flex flex-col"
|
||||
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>}
|
||||
</FocusSafeButton>
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -227,15 +227,18 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
||||
)}
|
||||
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => savePass.mutate(newPass.trim())}
|
||||
disabled={savePass.isPending || !newPass.trim()}
|
||||
className="px-2 py-1 rounded-md text-xs font-semibold btn-primary disabled:opacity-40 transition"
|
||||
loading={savePass.isPending}
|
||||
disabled={!newPass.trim()}
|
||||
>
|
||||
{t("downloads.share.savePassword")}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
{link.has_password && (
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="xs"
|
||||
onClick={async () => {
|
||||
if (
|
||||
await confirm({
|
||||
@@ -248,10 +251,9 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
||||
savePass.mutate("");
|
||||
}}
|
||||
disabled={savePass.isPending}
|
||||
className="px-2 py-1 rounded-md text-xs text-muted hover:text-danger transition"
|
||||
>
|
||||
{t("downloads.share.removePassword")}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -336,20 +338,17 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted">{t("downloads.share.expiry")}:</span>
|
||||
{EXPIRY_DAYS.map((d) => (
|
||||
<button
|
||||
<Button
|
||||
key={String(d)}
|
||||
variant="outline"
|
||||
size="xs"
|
||||
pressed={expiryDays === d}
|
||||
onClick={() => setExpiryDays(d)}
|
||||
className={clsx(
|
||||
"px-2 py-1 rounded-md border text-xs transition",
|
||||
expiryDays === d
|
||||
? "border-accent bg-accent/10 text-accent"
|
||||
: "border-border text-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
{d === null
|
||||
? t("downloads.share.expiryNever")
|
||||
: t("downloads.share.days", { n: d })}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
|
||||
@@ -16,7 +16,8 @@ import Tabs from "./ui/Tabs";
|
||||
import { Section, SettingRow } from "./ui/form";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
const RANGES = [7, 30, 90] as const;
|
||||
type StatsTab = "overview" | "system";
|
||||
@@ -175,26 +176,28 @@ function Overview({ me }: { me: Me }) {
|
||||
|
||||
{!me.is_demo && (
|
||||
<Section title={t("stats.sync.actions")}>
|
||||
<Tooltip hint={t("stats.sync.syncSubscriptionsHint")}>
|
||||
<FocusSafeButton
|
||||
onClick={() => syncSubs.mutate()}
|
||||
disabled={syncSubs.isPending}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
||||
{t("stats.sync.syncSubscriptions")}
|
||||
</FocusSafeButton>
|
||||
</Tooltip>
|
||||
<Tooltip hint={t("stats.sync.backfillEverythingHint")}>
|
||||
<FocusSafeButton
|
||||
onClick={() => deepAll.mutate()}
|
||||
disabled={deepAll.isPending}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition mt-2"
|
||||
>
|
||||
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
|
||||
{t("stats.sync.backfillEverything")}
|
||||
</FocusSafeButton>
|
||||
</Tooltip>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Tooltip hint={t("stats.sync.syncSubscriptionsHint")}>
|
||||
<Button
|
||||
variant="glass"
|
||||
icon={<RefreshCw />}
|
||||
loading={syncSubs.isPending}
|
||||
onClick={() => syncSubs.mutate()}
|
||||
>
|
||||
{t("stats.sync.syncSubscriptions")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip hint={t("stats.sync.backfillEverythingHint")}>
|
||||
<Button
|
||||
variant="glass"
|
||||
icon={<History />}
|
||||
loading={deepAll.isPending}
|
||||
onClick={() => deepAll.mutate()}
|
||||
>
|
||||
{t("stats.sync.backfillEverything")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
@@ -227,15 +230,16 @@ function SystemStats() {
|
||||
{s && (
|
||||
<Section title={t("stats.sync.admin")}>
|
||||
<Tooltip hint={t("stats.sync.adminPauseHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="glass"
|
||||
icon={s.paused ? <Play /> : <Pause />}
|
||||
loading={pauseResume.isPending}
|
||||
onClick={() => pauseResume.mutate(s.paused)}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm transition"
|
||||
>
|
||||
{s.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
||||
{s.paused
|
||||
? t("stats.sync.resumeBackgroundSync")
|
||||
: t("stats.sync.pauseBackgroundSync")}
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Section>
|
||||
)}
|
||||
@@ -321,9 +325,10 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
|
||||
const isSystem = row.user_id === null;
|
||||
return (
|
||||
<div className="glass-card rounded-xl p-3">
|
||||
<button
|
||||
<Pressable
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="w-full flex items-center gap-3 text-left"
|
||||
aria-expanded={open}
|
||||
className="w-full flex items-center gap-3"
|
||||
>
|
||||
<span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
|
||||
{isSystem ? t("stats.system") : row.email}
|
||||
@@ -331,7 +336,7 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
|
||||
<span className="text-sm font-semibold tabular-nums shrink-0">
|
||||
{formatNumber(row.total)}
|
||||
</span>
|
||||
</button>
|
||||
</Pressable>
|
||||
{/* Proportion bar */}
|
||||
<div className="mt-2 h-1.5 bg-border/40 rounded-full overflow-hidden">
|
||||
<div
|
||||
|
||||
@@ -8,7 +8,9 @@ import { notifyActionError } from "../lib/actionErrors";
|
||||
import { formatViews } from "../lib/format";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// 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
|
||||
@@ -98,14 +100,13 @@ export default function SyncStatus({
|
||||
);
|
||||
|
||||
const pauseBtn = isAdmin && (data.paused || syncing > 0 || notFull > 0) && (
|
||||
<FocusSafeButton
|
||||
<IconButton
|
||||
onClick={() => toggle.mutate()}
|
||||
disabled={toggle.isPending}
|
||||
title={data.paused ? t("header.sync.resume") : t("header.sync.pause")}
|
||||
className="p-1 rounded-md hover:bg-card hover:text-fg transition"
|
||||
loading={toggle.isPending}
|
||||
label={data.paused ? t("header.sync.resume") : t("header.sync.pause")}
|
||||
>
|
||||
{data.paused ? <Play className="w-3.5 h-3.5" /> : <Pause className="w-3.5 h-3.5" />}
|
||||
</FocusSafeButton>
|
||||
{data.paused ? <Play /> : <Pause />}
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
// Chip: the headline count rides in the header; the state, the history link and pause live in a
|
||||
@@ -117,7 +118,7 @@ export default function SyncStatus({
|
||||
)} ${t("header.sync.total")}`;
|
||||
return (
|
||||
<div className="pointer-events-auto shrink-0">
|
||||
<button
|
||||
<Pressable
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
// Short label only: the popover already explains the numbers, and the long one overflowed
|
||||
@@ -134,7 +135,7 @@ export default function SyncStatus({
|
||||
{needsAttention && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-accent ring-2 ring-bg" />
|
||||
)}
|
||||
</button>
|
||||
</Pressable>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
@@ -161,17 +162,18 @@ export default function SyncStatus({
|
||||
)}
|
||||
{notFull > 0 && (
|
||||
<div className="flex items-center justify-between gap-1.5 pt-2">
|
||||
<button
|
||||
<Button
|
||||
variant="link-muted"
|
||||
icon={<History className="size-3.5!" />}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onGoToFullHistory();
|
||||
}}
|
||||
title={t("header.sync.fullHistoryTooltip")}
|
||||
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
|
||||
className="gap-1 underline decoration-dotted decoration-muted/40 underline-offset-4"
|
||||
>
|
||||
<History className="w-3.5 h-3.5" />
|
||||
{t("header.sync.withoutFullHistory", { count: notFull })}
|
||||
</button>
|
||||
</Button>
|
||||
{!showMain && pauseBtn}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { filterChipClass } from "./ui/FilterChipGroup";
|
||||
import { Chip } from "./ui/Chip";
|
||||
import { chipClass } from "./ui/chipStyles";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
|
||||
@@ -20,16 +21,11 @@ export interface FilterChip {
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function Chip({ chip, extra }: { chip: FilterChip; extra?: string }) {
|
||||
function TagChip({ chip }: { chip: FilterChip }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={chip.onToggle}
|
||||
aria-pressed={chip.active}
|
||||
className={`${filterChipClass}${extra ? ` ${extra}` : ""}`}
|
||||
>
|
||||
<Chip onClick={chip.onToggle} pressed={chip.active}>
|
||||
{chip.label}
|
||||
</button>
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,28 +99,22 @@ export default function TagFilterChips({
|
||||
}}
|
||||
className="inline-flex"
|
||||
>
|
||||
<span className={filterChipClass}>{c.label}</span>
|
||||
<span className={chipClass()}>{c.label}</span>
|
||||
</span>
|
||||
))}
|
||||
<span ref={pillRef} className={filterChipClass}>
|
||||
<span ref={pillRef} className={chipClass()}>
|
||||
+{chips.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
|
||||
{chips.slice(0, visible).map((c) => (
|
||||
<Chip key={c.key} chip={c} extra="shrink-0" />
|
||||
<TagChip key={c.key} chip={c} />
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
ref={pop.refs.setReference}
|
||||
type="button"
|
||||
aria-label={moreLabel}
|
||||
className={`${filterChipClass} shrink-0 ${open ? "border-accent text-accent" : ""}`}
|
||||
{...pop.getReferenceProps()}
|
||||
>
|
||||
<Chip ref={pop.refs.setReference} aria-label={moreLabel} {...pop.getReferenceProps()}>
|
||||
+{hidden}
|
||||
</button>
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -134,7 +124,7 @@ export default function TagFilterChips({
|
||||
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]"
|
||||
>
|
||||
{chips.slice(visible).map((c) => (
|
||||
<Chip key={c.key} chip={c} />
|
||||
<TagChip key={c.key} chip={c} />
|
||||
))}
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,8 @@ import Modal from "./ui/Modal";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// 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
|
||||
@@ -67,14 +69,9 @@ function TagRow({
|
||||
>
|
||||
{t("tagManager.channels", { count: tag.channel_count })}
|
||||
</span>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
title={t("tagManager.delete")}
|
||||
aria-label={t("tagManager.delete")}
|
||||
className="shrink-0 p-1.5 rounded-md text-muted hover:text-danger transition"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<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]"
|
||||
@@ -89,13 +86,14 @@ function TagRow({
|
||||
className="max-h-72 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{channels.map((ch) => (
|
||||
<button
|
||||
<Pressable
|
||||
key={ch.id}
|
||||
layer
|
||||
onClick={() => onPickChannel(ch.title)}
|
||||
className="w-full text-left text-sm px-2 py-1.5 rounded-md text-muted hover:text-fg hover:bg-card truncate transition"
|
||||
className="w-full text-sm px-2 py-1.5 rounded-md text-muted hover:text-fg truncate"
|
||||
>
|
||||
{ch.title}
|
||||
</button>
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Play, X } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { plexLang } from "../i18n";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
// Up-next / end-card (E6 S4). Shown at episode end when a next item exists and the pref is on, INSTEAD
|
||||
// of the old instant jump — so binge stays under the viewer's control (D6-A). Counts down ~10s then
|
||||
@@ -84,10 +86,7 @@ export function UpNextCard({
|
||||
<div className="truncate text-sm font-semibold leading-tight">{heading || "…"}</div>
|
||||
{sub && <div className="truncate text-xs text-white/60">{sub}</div>}
|
||||
<div className="mt-auto flex items-center gap-2 pt-2.5">
|
||||
<button
|
||||
onClick={onAdvance}
|
||||
className="flex items-center gap-1.5 rounded-full bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg hover:brightness-110"
|
||||
>
|
||||
<Button size="sm" onMedia onClick={onAdvance} className="text-xs font-semibold">
|
||||
<span className="relative grid h-5.5 w-5.5 flex-none place-items-center">
|
||||
<svg viewBox="0 0 24 24" className="absolute inset-0 -rotate-90">
|
||||
<circle
|
||||
@@ -114,22 +113,26 @@ export function UpNextCard({
|
||||
</span>
|
||||
{t("plex.player.upNext.playNow")}
|
||||
<span className="tabular-nums">{secs}</span>
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
onMedia
|
||||
onClick={onCancel}
|
||||
className="rounded-full border border-white/25 px-3 py-1.5 text-xs text-white/85 hover:bg-white/10"
|
||||
className="border border-white/25 text-xs"
|
||||
>
|
||||
{t("plex.player.upNext.cancel")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
onClick={onCancel}
|
||||
aria-label={t("plex.player.upNext.cancel")}
|
||||
className="absolute right-2 top-2 rounded p-0.5 text-white/50 hover:bg-white/10 hover:text-white"
|
||||
label={t("plex.player.upNext.cancel")}
|
||||
className="absolute right-1 top-1"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { navigateTo } from "../lib/nav";
|
||||
import { formatBytes } from "../lib/format";
|
||||
import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// Local-file ingest (E7 S5c · D5-A). A general upload — ANY file type, no format list: media
|
||||
// (video/audio) joins the library with the full function set, a generic file is download-only. The
|
||||
@@ -102,8 +102,8 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
<p className="text-sm text-muted mb-4">{t("downloads.upload.hint")}</p>
|
||||
|
||||
{/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog. */}
|
||||
<FocusSafeButton
|
||||
type="button"
|
||||
<Pressable
|
||||
align="center"
|
||||
onClick={pick}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -158,7 +158,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</FocusSafeButton>
|
||||
</Pressable>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
|
||||
@@ -109,11 +109,7 @@ function Actions({
|
||||
// overlay for the thumbnailed views, and inline beside the actions for the one-line row.
|
||||
const INFO_REVEAL =
|
||||
"reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 transition-opacity";
|
||||
const INFO_OVERLAY = clsx(
|
||||
INFO_REVEAL,
|
||||
"absolute bottom-1.5 left-1.5 z-10 rounded-md bg-black/70 p-1 text-white hover:bg-black/85",
|
||||
);
|
||||
const INFO_INLINE = clsx(INFO_REVEAL, "rounded-md p-1.5 text-muted hover:bg-surface hover:text-fg");
|
||||
const INFO_OVERLAY = clsx(INFO_REVEAL, "absolute bottom-1.5 left-1.5 z-10");
|
||||
|
||||
// Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click
|
||||
// keep the native "open youtube.com in a new tab" behavior via the underlying href.
|
||||
@@ -339,7 +335,7 @@ function Thumb({
|
||||
{/* Hover infobox trigger (E8·S3), docked bottom-left so it clears the centered play overlay, the
|
||||
corner badges (saved / stream, top corners) and the duration (bottom-right). Revealed with the
|
||||
rest of the hover chrome, dropped on touch. */}
|
||||
<VideoInfoCard video={video} className={INFO_OVERLAY} />
|
||||
<VideoInfoCard video={video} onMedia className={INFO_OVERLAY} />
|
||||
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
|
||||
{pct > 0 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
|
||||
@@ -559,7 +555,7 @@ function VideoCard({
|
||||
budget). ROW_COL.actions already counts its width. */}
|
||||
<div className="shrink-0 flex items-center gap-1">
|
||||
<div className="w-48">{actions}</div>
|
||||
<VideoInfoCard video={video} className={INFO_INLINE} />
|
||||
<VideoInfoCard video={video} className={INFO_REVEAL} />
|
||||
</div>
|
||||
{/* Everything else is shrink-0, so the title is the only thing that can give — which means
|
||||
once the fixed columns outgrow the row, it gives ALL of it and vanishes. Columns drop
|
||||
|
||||
@@ -9,6 +9,7 @@ import { linkify } from "../lib/linkify";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
// 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
|
||||
@@ -31,11 +32,14 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
// so the trigger deliberately lives OUTSIDE it.
|
||||
export default function VideoInfoCard({
|
||||
video,
|
||||
onMedia = false,
|
||||
className,
|
||||
}: {
|
||||
video: Video;
|
||||
/** Trigger-button classes (placement + hover-reveal + skin). The reveal/touch-suppression rules
|
||||
* live here so each host can match its surface (overlay vs inline). */
|
||||
/** The trigger sits on the thumbnail: the round, dark translucent IconButton (A2). */
|
||||
onMedia?: boolean;
|
||||
/** Trigger placement + hover-reveal. The reveal/touch-suppression rules live with the host, which
|
||||
* knows its surface (overlay vs inline); the skin is the IconButton's. */
|
||||
className?: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -90,8 +94,8 @@ export default function VideoInfoCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
variant={onMedia ? "media" : "ghost"}
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
// The overlay trigger sits inside the thumbnail's <a> (like the play buttons), so a click must
|
||||
@@ -100,12 +104,11 @@ export default function VideoInfoCard({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
aria-label={t("card.info")}
|
||||
title={t("card.info")}
|
||||
label={t("card.info")}
|
||||
className={className}
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
</button>
|
||||
<Info />
|
||||
</IconButton>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("card.info")}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Check, ChevronDown, type LucideIcon } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
export interface ViewOption<T extends string> {
|
||||
id: T;
|
||||
@@ -59,7 +60,7 @@ export default function ViewSwitcher<T extends string>({
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
<Pressable
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
// The aria-label carries the mode's name at every size — the visible text below is a
|
||||
@@ -75,7 +76,7 @@ export default function ViewSwitcher<T extends string>({
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 shrink-0 text-muted transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Pressable>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={label}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Chip } from "./ui/Chip";
|
||||
import { formatBytes } from "../lib/format";
|
||||
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
||||
import { LS, readJSON, writeJSON } from "../lib/storage";
|
||||
@@ -385,14 +386,15 @@ export default function WatchPage() {
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* An on/off toggle, so the shared pressed-pill paint (a solid fill when on) — not a
|
||||
Button, whose variants differ only in colour. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={repeat}
|
||||
<Chip
|
||||
size="md"
|
||||
pressed={repeat}
|
||||
icon={<Repeat className="size-3.5" aria-hidden />}
|
||||
onClick={toggleRepeat}
|
||||
className="pill inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium"
|
||||
className="font-medium"
|
||||
>
|
||||
<Repeat className="w-4 h-4" aria-hidden /> {T.repeat}
|
||||
</button>
|
||||
{T.repeat}
|
||||
</Chip>
|
||||
{meta.allow_download && (
|
||||
<a
|
||||
href={meta.file_url}
|
||||
|
||||
@@ -6,7 +6,8 @@ import { qk } from "../lib/queryKeys";
|
||||
import { api, type FeedFilters, type YearCount } from "../lib/api";
|
||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { filterChipClass } from "./ui/FilterChipGroup";
|
||||
import { Chip } from "./ui/Chip";
|
||||
import { Button } from "./ui/Button";
|
||||
import CountBadge from "./ui/CountBadge";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
|
||||
@@ -111,31 +112,23 @@ export default function YearFilter({
|
||||
{quick.map((y) => {
|
||||
const on = selected === y.year;
|
||||
return (
|
||||
<button
|
||||
key={y.year}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => pick(on ? undefined : y.year)}
|
||||
className={filterChipClass}
|
||||
>
|
||||
<Chip key={y.year} pressed={on} onClick={() => pick(on ? undefined : y.year)}>
|
||||
<span className="tabular-nums">{y.year}</span>
|
||||
{y.count > 0 && <CountBadge count={y.count} active={on} />}
|
||||
</button>
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
|
||||
{years.length > quick.length && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
<Chip
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={t("sidebar.year.moreLabel")}
|
||||
className={filterChipClass}
|
||||
>
|
||||
<span>{t("sidebar.year.more")}</span>
|
||||
<ChevronDown className="text-muted h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Chip>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
@@ -151,19 +144,15 @@ export default function YearFilter({
|
||||
["sortCount", true],
|
||||
] as const
|
||||
).map(([key, v]) => (
|
||||
<button
|
||||
<Button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={sortByCount === v}
|
||||
variant="outline"
|
||||
size="xs"
|
||||
pressed={sortByCount === v}
|
||||
onClick={() => setSortByCount(v)}
|
||||
className={`text-2xs rounded-md border px-2 py-0.5 transition ${
|
||||
sortByCount === v
|
||||
? "border-accent bg-accent/15 text-accent"
|
||||
: "border-border text-muted hover:border-accent/50 hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{t("sidebar.year." + key)}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ManagedChannel } from "../lib/api";
|
||||
import { formatNumber } from "../lib/format";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import { isFullySynced } from "./channelSync";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
// The Channels-manager cells that are components in their own right (they read i18n), split out of
|
||||
// channelColumns so that file holds only the column factory and its predicates (Fast Refresh).
|
||||
@@ -67,39 +67,35 @@ export function ActionsCell({
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<Tooltip hint={backfillHint}>
|
||||
<FocusSafeButton
|
||||
<IconButton
|
||||
onClick={!enabled ? undefined : isAdmin ? onReset : onDeep}
|
||||
disabled={!enabled}
|
||||
className={`shrink-0 transition ${
|
||||
requested
|
||||
? "text-accent"
|
||||
: enabled
|
||||
? "text-muted hover:text-fg"
|
||||
: "text-muted/40 cursor-default"
|
||||
}`}
|
||||
aria-label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
|
||||
variant={requested ? "ghost-accent" : "ghost"}
|
||||
label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
|
||||
noTitle
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
</FocusSafeButton>
|
||||
<History />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}>
|
||||
<button
|
||||
<IconButton
|
||||
onClick={onHide}
|
||||
className="text-muted hover:text-fg shrink-0"
|
||||
aria-label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}
|
||||
label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}
|
||||
noTitle
|
||||
>
|
||||
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
{c.hidden ? <EyeOff /> : <Eye />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{canWrite && (
|
||||
<Tooltip hint={t("channels.row.unsubscribeHint")}>
|
||||
<button
|
||||
<IconButton
|
||||
variant="danger"
|
||||
onClick={onUnsubscribe}
|
||||
className="text-muted hover:text-danger shrink-0"
|
||||
aria-label={t("channels.row.unsubscribeOnYoutube")}
|
||||
label={t("channels.row.unsubscribeOnYoutube")}
|
||||
noTitle
|
||||
>
|
||||
<UserMinus className="w-4 h-4" />
|
||||
</button>
|
||||
<UserMinus />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+5
-4
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// Turn a video description into clickable nodes:
|
||||
// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player
|
||||
@@ -78,7 +79,7 @@ export function renderDescription(
|
||||
if (yt) {
|
||||
const sameVideo = yt.id === opts.currentId;
|
||||
out.push(
|
||||
<button
|
||||
<Pressable
|
||||
key={key++}
|
||||
onClick={() =>
|
||||
sameVideo ? opts.onSeek(yt.start ?? 0) : opts.onLoadVideo(yt.id, yt.start)
|
||||
@@ -87,7 +88,7 @@ export function renderDescription(
|
||||
title={sameVideo ? t("player.jumpToTime") : t("player.playInApp")}
|
||||
>
|
||||
{href}
|
||||
</button>,
|
||||
</Pressable>,
|
||||
);
|
||||
} else {
|
||||
out.push(
|
||||
@@ -133,13 +134,13 @@ export function renderDescription(
|
||||
// alternative — the timestamp group — is the one that fired.
|
||||
const ts = m[3]!;
|
||||
out.push(
|
||||
<button
|
||||
<Pressable
|
||||
key={key++}
|
||||
onClick={() => opts.onSeek(tsToSeconds(ts))}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{ts}
|
||||
</button>,
|
||||
</Pressable>,
|
||||
);
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
@@ -11,6 +11,8 @@ import { inputCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
import { StatusPill } from "./downloadRow";
|
||||
import { jobTitle } from "./downloadTitle";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// 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).
|
||||
@@ -165,18 +167,19 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
|
||||
{filtered.map((u) => (
|
||||
<button
|
||||
<Pressable
|
||||
key={u.id}
|
||||
layer
|
||||
onClick={() => {
|
||||
onPick({ id: u.id, email: u.email });
|
||||
setQ("");
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-surface transition flex flex-col"
|
||||
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>}
|
||||
</button>
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -225,13 +228,12 @@ export function AdminSystem() {
|
||||
>
|
||||
<span className="flex-1 truncate">{u.email}</span>
|
||||
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
|
||||
<button
|
||||
<IconButton
|
||||
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
|
||||
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-fg"
|
||||
title={t("downloads.admin.editQuota")}
|
||||
label={t("downloads.admin.editQuota")}
|
||||
>
|
||||
<Settings2 className="w-4 h-4" />
|
||||
</button>
|
||||
<Settings2 />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
{s.per_user.length === 0 && (
|
||||
|
||||
@@ -95,12 +95,9 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addLink}
|
||||
className="mt-2 inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg transition"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> {t("downloads.edit.addLink")}
|
||||
</button>
|
||||
<Button variant="link-muted" icon={<Plus />} onClick={addLink} className="mt-2 text-sm">
|
||||
{t("downloads.edit.addLink")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
|
||||
<div className="flex justify-end">
|
||||
@@ -148,12 +145,14 @@ export function ErrorModal({
|
||||
<p className="text-sm text-muted">{t(failCopyKeys(job, "body"))}</p>
|
||||
{job.error && (
|
||||
<div>
|
||||
<button
|
||||
<Button
|
||||
variant="link-muted"
|
||||
aria-expanded={showRaw}
|
||||
onClick={() => setShowRaw((v) => !v)}
|
||||
className="text-xs text-muted hover:text-fg transition"
|
||||
className="text-xs"
|
||||
>
|
||||
{t("downloads.errors.fail.detailsToggle")}
|
||||
</button>
|
||||
</Button>
|
||||
{showRaw && (
|
||||
<pre className="mt-1.5 text-xs bg-surface rounded-lg p-2.5 overflow-x-auto whitespace-pre-wrap wrap-break-word max-h-40 text-muted">
|
||||
{job.error}
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { failCopyKeys } from "../lib/downloadFailure";
|
||||
import { jobTitle } from "./downloadTitle";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// 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,
|
||||
@@ -136,17 +138,14 @@ export function IconBtn({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
<IconButton
|
||||
variant={danger ? "danger" : "ghost"}
|
||||
onClick={onClick}
|
||||
data-nav-open={open ? "" : undefined}
|
||||
title={title}
|
||||
className={clsx(
|
||||
"p-1.5 rounded-md hover:bg-surface text-muted transition",
|
||||
danger ? "hover:text-danger" : "hover:text-fg",
|
||||
)}
|
||||
label={title}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -182,14 +181,9 @@ function SourceRef({ url }: { url: string }) {
|
||||
>
|
||||
{displayUrl(url)}
|
||||
</a>
|
||||
<button
|
||||
onClick={copy}
|
||||
aria-label={t("downloads.source.copy")}
|
||||
title={t("downloads.source.copy")}
|
||||
className="shrink-0 p-0.5 rounded-sm hover:bg-surface hover:text-fg transition"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<IconButton onClick={copy} label={t("downloads.source.copy")}>
|
||||
<Copy />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -267,14 +261,14 @@ export function DownloadRow({
|
||||
</span>
|
||||
) : null}
|
||||
{job.status === "error" ? (
|
||||
<button
|
||||
<Pressable
|
||||
onClick={() => onShowError?.(job)}
|
||||
className="text-danger hover:opacity-80 inline-flex items-center gap-1 truncate"
|
||||
className="text-danger inline-flex items-center gap-1 truncate"
|
||||
title={t("downloads.errors.fail.title")}
|
||||
>
|
||||
· {t(failCopyKeys(job, "short"))}
|
||||
<span className="text-muted underline">{t("downloads.errors.fail.detailsLink")}</span>
|
||||
</button>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</div>
|
||||
{job.source_url && <SourceRef url={job.source_url} />}
|
||||
@@ -306,12 +300,10 @@ export function DownloadRow({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Row action buttons: grown a step here (bigger tap target + icon) without touching the shared
|
||||
IconBtn — scoped so the same primitive stays smaller in the modals. `shrink-0` + no wrap keeps
|
||||
them on the title's line; the text column shrinks instead. */}
|
||||
<div className="flex items-start gap-1 shrink-0 [&_button]:p-2 [&_svg]:size-4.5">
|
||||
{children}
|
||||
</div>
|
||||
{/* Row action buttons: the md rung here (the row stands alone), the sm rung in the modals, one
|
||||
IconBtn for both. `shrink-0` + no wrap keeps them on the title's line; the text column
|
||||
shrinks instead. */}
|
||||
<div className="flex items-start gap-1 shrink-0 [&_button]:size-8">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
|
||||
import type { AutoMode, LoopMode } from "../lib/playerModes";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// Presentational controls for the YouTube modal player (PlayerModal), split out of the god component —
|
||||
// the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props.
|
||||
@@ -35,76 +35,69 @@ export function PlayerTransportControls({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const docked = variant === "docked";
|
||||
const navBtn = docked
|
||||
? "inline-flex items-center gap-1 text-white/80 enabled:not-aria-disabled:hover:text-white disabled:opacity-30 transition"
|
||||
: "inline-flex items-center gap-1.5 text-muted enabled:not-aria-disabled:hover:text-fg disabled:opacity-40 transition";
|
||||
// The docked bar sits on the video (always dark): the overlay variant; the modal's is a page surface.
|
||||
const chrome = docked ? "overlay" : "ghost";
|
||||
const navLabel = docked ? "hidden sm:inline" : "";
|
||||
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums";
|
||||
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
|
||||
const toggle = (on: boolean) =>
|
||||
docked
|
||||
? `inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
|
||||
on ? "bg-white/20 text-white" : "text-white/70 hover:bg-white/10 hover:text-white"
|
||||
}`
|
||||
: `inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
|
||||
on ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
|
||||
}`;
|
||||
const toggleLabel = docked ? "hidden md:inline" : "";
|
||||
return (
|
||||
<>
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant={chrome}
|
||||
size="sm"
|
||||
icon={<SkipBack />}
|
||||
onClick={onPrev}
|
||||
disabled={index === 0}
|
||||
label={t("player.previous")}
|
||||
title={`${t("player.previous")} · Shift+←`}
|
||||
className={navBtn}
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
<span className={navLabel}>{t("player.previous")}</span>
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
<span className={counter}>
|
||||
{index + 1} / {queueLength}
|
||||
</span>
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant={chrome}
|
||||
size="sm"
|
||||
onClick={onNext}
|
||||
disabled={index === queueLength - 1}
|
||||
label={t("player.next")}
|
||||
title={`${t("player.next")} · Shift+→`}
|
||||
className={navBtn}
|
||||
>
|
||||
<span className={navLabel}>{t("player.next")}</span>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</FocusSafeButton>
|
||||
<SkipForward />
|
||||
</Button>
|
||||
<span className={divider} />
|
||||
{/* Persistent playback settings — cycle on click, saved to your account. */}
|
||||
<button
|
||||
<Button
|
||||
variant={chrome}
|
||||
size="sm"
|
||||
pressed={autoMode !== "off"}
|
||||
icon={
|
||||
autoMode === "prev" ? <SkipBack /> : autoMode === "random" ? <Shuffle /> : <SkipForward />
|
||||
}
|
||||
onClick={onCycleAuto}
|
||||
label={`${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`}
|
||||
title={t("player.autoAdvance.hint")}
|
||||
className={toggle(autoMode !== "off")}
|
||||
>
|
||||
{autoMode === "prev" ? (
|
||||
<SkipBack className="h-3.5 w-3.5" />
|
||||
) : autoMode === "random" ? (
|
||||
<Shuffle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<SkipForward className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className={toggleLabel}>
|
||||
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant={chrome}
|
||||
size="sm"
|
||||
pressed={loopMode !== "off"}
|
||||
icon={loopMode === "one" ? <Repeat1 /> : <Repeat />}
|
||||
onClick={onCycleLoop}
|
||||
label={`${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`}
|
||||
title={t("player.loop.hint")}
|
||||
className={toggle(loopMode !== "off")}
|
||||
>
|
||||
{loopMode === "one" ? (
|
||||
<Repeat1 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Repeat className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className={toggleLabel}>
|
||||
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { TFunction } from "i18next";
|
||||
import type { Video } from "../lib/api";
|
||||
import { formatDuration } from "../lib/format";
|
||||
import type { Column } from "../lib/tableColumns";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`.
|
||||
//
|
||||
@@ -56,7 +58,7 @@ export function playlistColumns({
|
||||
cardPrimary: true,
|
||||
render: (v) => (
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<button
|
||||
<Pressable
|
||||
onClick={() => onPlay(v)}
|
||||
aria-label={`${t("card.play")} — ${v.title}`}
|
||||
className="relative w-15.5 h-8.75 rounded-sm overflow-hidden bg-surface shrink-0 group"
|
||||
@@ -67,15 +69,15 @@ export function playlistColumns({
|
||||
<span className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition">
|
||||
<Play className="w-4 h-4 text-white fill-current" />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onClick={() => onPlay(v)}
|
||||
// The row's "open" element: Enter on a keyboard-walked row (lib/listNav) plays it.
|
||||
data-nav-open=""
|
||||
className="min-w-0 flex-1 text-left"
|
||||
className="min-w-0 flex-1"
|
||||
>
|
||||
<span className="block text-[0.8125rem] text-fg truncate">{v.title}</span>
|
||||
</button>
|
||||
</Pressable>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -129,14 +131,14 @@ export function playlistColumns({
|
||||
width: "2.5rem",
|
||||
nowrap: true,
|
||||
render: (v) => (
|
||||
<button
|
||||
<IconButton
|
||||
variant="danger"
|
||||
onClick={() => onRemove(v)}
|
||||
label={t("playlists.removeItemOf", { title: v.title })}
|
||||
title={t("playlists.removeItem")}
|
||||
aria-label={t("playlists.removeItemOf", { title: v.title })}
|
||||
className="text-muted hover:text-fg p-1"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<X />
|
||||
</IconButton>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import PosterTile from "./PosterTile";
|
||||
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
|
||||
// collections have no movie runtime).
|
||||
@@ -109,13 +110,9 @@ function NewCollectionPanel({
|
||||
<div className="glass-card mb-6 rounded-2xl p-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold">{t("plex.collection.newCollection")}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label={t("common.close")}
|
||||
className="glass-hover grid h-8 w-8 place-items-center rounded-lg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<IconButton size="md" onClick={onClose} label={t("common.close")}>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
<input
|
||||
autoFocus
|
||||
@@ -146,14 +143,15 @@ function NewCollectionPanel({
|
||||
term={term}
|
||||
enabled={nameOk}
|
||||
renderAction={(c) => (
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
size="xs"
|
||||
icon={<Check />}
|
||||
onClick={() => create(c)}
|
||||
disabled={busy}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
||||
loading={busy}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t("plex.collection.createWith")}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -190,13 +188,9 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title:
|
||||
className="glass-card w-full max-w-sm rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
{isAdmin && !creating && (
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<Button variant="glass" size="sm" icon={<Plus />} onClick={() => setCreating(true)}>
|
||||
{t("plex.collection.newCollection")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{creating && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
// ±10s skip glyph: an outline circular arrow with a "10" in the centre — the YouTube/Plex-native
|
||||
// muscle-memory shape. Deliberately distinct from the episode-level SkipBack/SkipForward (double
|
||||
@@ -82,13 +82,14 @@ export function Slider({
|
||||
{suffix ?? ""}
|
||||
</span>
|
||||
{onReset && (
|
||||
<button
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
onClick={onReset}
|
||||
className="text-white/40 hover:text-white"
|
||||
aria-label={t("common.resetNamed", { name: label })}
|
||||
label={t("common.resetNamed", { name: label })}
|
||||
className="-my-1.5"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</button>
|
||||
<RotateCcw className="size-3.5!" />
|
||||
</IconButton>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -146,14 +147,17 @@ export function Ctrl({
|
||||
align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
|
||||
return (
|
||||
<div className="relative flex group/ctrl">
|
||||
<FocusSafeButton
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
size="player"
|
||||
onMedia
|
||||
noTitle
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
className="p-1 hover:text-accent disabled:opacity-30"
|
||||
label={label}
|
||||
>
|
||||
{children}
|
||||
</FocusSafeButton>
|
||||
</IconButton>
|
||||
<span
|
||||
className={`pointer-events-none absolute bottom-full ${pos} mb-2 whitespace-nowrap rounded-sm bg-black/90 px-2 py-1 text-2xs text-white opacity-0 transition group-hover/ctrl:opacity-100`}
|
||||
>
|
||||
|
||||
@@ -27,6 +27,8 @@ import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { Chip } from "./ui/Chip";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// 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,
|
||||
@@ -222,12 +224,9 @@ export function PlexPersonView({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onFilter({ actors: [d.name] })}
|
||||
className="rounded-full border border-border bg-card px-3 py-1 text-xs font-medium transition hover:border-accent hover:text-accent"
|
||||
>
|
||||
<Chip onClick={() => onFilter({ actors: [d.name] })} className="font-medium">
|
||||
{t("plex.person.filterBy", { name: d.name })}
|
||||
</button>
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
{d.biography && (
|
||||
@@ -428,7 +427,7 @@ export function PlexShowView({
|
||||
if (!badge) return null;
|
||||
const { score } = badge;
|
||||
return (
|
||||
<button
|
||||
<Pressable
|
||||
onClick={
|
||||
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
|
||||
}
|
||||
@@ -437,7 +436,7 @@ export function PlexShowView({
|
||||
{/* decorative rating gold — intentionally NOT a status token */}
|
||||
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
|
||||
{score.toFixed(1)}
|
||||
</button>
|
||||
</Pressable>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
@@ -645,12 +644,9 @@ export function PlexSeasonView({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-sm text-muted hover:text-accent transition text-left"
|
||||
>
|
||||
<Button variant="link-muted" onClick={onBack} className="text-sm">
|
||||
{d.show.title}
|
||||
</button>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold leading-tight">{se.title}</h1>
|
||||
<p className="text-sm text-muted mt-1">
|
||||
{t("plex.series.episodeCount", { count: se.episode_count })}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Fragment, useState, type ReactNode } from "react";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
||||
import { closeThenAct, useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { Pressable } from "./Pressable";
|
||||
|
||||
// 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
|
||||
@@ -209,19 +211,28 @@ export default function ActionMenu({
|
||||
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" });
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
className={
|
||||
className ??
|
||||
"glass-card glass-hover flex items-center justify-center rounded-xl p-2 text-sm transition"
|
||||
}
|
||||
>
|
||||
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
|
||||
</button>
|
||||
{/* A caller-shaped trigger (a labelled pill) is a surface; the default "…" is a glyph. */}
|
||||
{className ? (
|
||||
<Pressable
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
className={className}
|
||||
>
|
||||
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
|
||||
</Pressable>
|
||||
) : (
|
||||
<IconButton
|
||||
variant="glass"
|
||||
size="md"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
label={ariaLabel}
|
||||
>
|
||||
{trigger ?? <MoreHorizontal aria-hidden="true" />}
|
||||
</IconButton>
|
||||
)}
|
||||
<PopoverSurface popover={pop} ariaLabel={ariaLabel} className={MENU_SURFACE}>
|
||||
<MenuItemList items={items} onClose={() => setOpen(false)} />
|
||||
</PopoverSurface>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable } from "./Pressable";
|
||||
|
||||
// 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
|
||||
@@ -50,7 +51,8 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
|
||||
// commonly hide floating corner buttons via attribute selectors like [aria-label="Back to top"].
|
||||
// The accessible name comes from a visually-hidden child instead, which those selectors can't match.
|
||||
return createPortal(
|
||||
<button
|
||||
<Pressable
|
||||
align="center"
|
||||
onClick={toTop}
|
||||
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center w-16 h-16 rounded-full glass-card 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"
|
||||
@@ -58,7 +60,7 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
|
||||
>
|
||||
<ArrowUp className="w-7 h-7" aria-hidden="true" />
|
||||
<span className="sr-only">{t("common.backToTop")}</span>
|
||||
</button>,
|
||||
</Pressable>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,20 +2,21 @@ import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "./Button";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
type BannerTone = "accent" | "warning";
|
||||
|
||||
const TONES: Record<BannerTone, { bar: string; icon: string; action: string }> = {
|
||||
const TONES: Record<BannerTone, { bar: string; icon: string; action: "primary" | "warn" }> = {
|
||||
accent: {
|
||||
bar: "bg-accent/15 border-accent/30",
|
||||
icon: "text-accent",
|
||||
action: "btn-primary",
|
||||
action: "primary",
|
||||
},
|
||||
warning: {
|
||||
bar: "bg-warn/15 border-warn/30",
|
||||
icon: "text-warn",
|
||||
action: "btn-warn",
|
||||
action: "warn",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -54,12 +55,14 @@ export default function Banner({
|
||||
<Icon className={`w-4 h-4 shrink-0 ${c.icon}`} />
|
||||
<span className="min-w-0">{children}</span>
|
||||
{action && (
|
||||
<button
|
||||
<Button
|
||||
variant={c.action}
|
||||
size="sm"
|
||||
onClick={action.onClick}
|
||||
className={`ml-1 px-2.5 py-1 rounded-lg font-semibold transition ${c.action}`}
|
||||
className="ml-1 font-semibold"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<IconButton
|
||||
@@ -69,7 +72,7 @@ export default function Banner({
|
||||
onClick={onDismiss}
|
||||
className="ml-auto"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { FocusSafeButton } from "./FocusSafeButton";
|
||||
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
|
||||
@@ -43,6 +44,7 @@ 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
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import { useScrollEdges } from "../../lib/useScrollEdges";
|
||||
import { useListNav } from "../../lib/listNav";
|
||||
import { Button } from "./Button";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
// Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing
|
||||
// the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future
|
||||
@@ -95,10 +97,10 @@ export function Carousel({
|
||||
|
||||
// Mouse-only: hidden until the carousel is hovered or the arrow is keyboard-focused; `reveal-hover`
|
||||
// removes it on touch, where the strip scrolls by swipe — so an invisible edge button never eats a tap.
|
||||
// Round, dark translucent: they sit over the pictures (A2).
|
||||
const arrowBtn =
|
||||
"reveal-hover absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " +
|
||||
"bg-black/55 text-white backdrop-blur-xs border border-white/15 shadow-lg transition " +
|
||||
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75";
|
||||
"reveal-hover absolute top-1/2 -translate-y-1/2 z-10 backdrop-blur-xs border border-white/15 shadow-lg " +
|
||||
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100";
|
||||
|
||||
return (
|
||||
<div className={clsx("group/car relative", className)}>
|
||||
@@ -106,27 +108,25 @@ export function Carousel({
|
||||
<div className="mb-3 flex items-baseline gap-2">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
{onSeeAll && (
|
||||
<button
|
||||
onClick={onSeeAll}
|
||||
className="ml-auto inline-flex items-center gap-0.5 text-xs text-muted transition hover:text-accent"
|
||||
>
|
||||
<Button variant="link-muted" onClick={onSeeAll} className="ml-auto gap-0.5 text-xs">
|
||||
{seeAllLabel ?? t("common.seeAll")}
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{left && (
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
variant="media"
|
||||
size="md"
|
||||
onClick={() => page(-1)}
|
||||
aria-label={t("common.scrollLeft")}
|
||||
label={t("common.scrollLeft")}
|
||||
className={clsx(arrowBtn, "left-1")}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<ChevronLeft />
|
||||
</IconButton>
|
||||
)}
|
||||
{/* The px-1/pt-1 against -mx-1/-mt-1 keep the layout and give the keyboard ring room: a
|
||||
scroller clips its overflow, and the ring sits 4px outside the card it surrounds. */}
|
||||
@@ -140,14 +140,15 @@ export function Carousel({
|
||||
{children}
|
||||
</div>
|
||||
{right && (
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
variant="media"
|
||||
size="md"
|
||||
onClick={() => page(1)}
|
||||
aria-label={t("common.scrollRight")}
|
||||
label={t("common.scrollRight")}
|
||||
className={clsx(arrowBtn, "right-1")}
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
<ChevronRight />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E28 S3b: the chip family (docs/DESIGN.md "One primitive per button family"). One geometry for a
|
||||
// filter, an applied narrowing, a tag and a suggestion, where five files had five.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { Chip, DismissibleChip } from "./Chip";
|
||||
import { chipClass } from "./chipStyles";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("Chip", () => {
|
||||
it("is a pill on the 24px floor that never submits a form, and a toggle announces its state", () => {
|
||||
const onSubmit = vi.fn((e: { preventDefault: () => void }) => e.preventDefault());
|
||||
render(
|
||||
<form onSubmit={onSubmit}>
|
||||
<Chip pressed={false}>2024</Chip>
|
||||
</form>,
|
||||
);
|
||||
const chip = screen.getByRole("button", { name: "2024" });
|
||||
expect(chip.className).toContain("rounded-full");
|
||||
expect(chip.className).toContain("h-6");
|
||||
expect(chip.className).toContain("touch-target");
|
||||
expect(chip.getAttribute("aria-pressed")).toBe("false");
|
||||
fireEvent.click(chip);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a removing chip is named by the removal and shows the ×", () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<Chip variant="token" size="md" removeLabel="Remove Music" onClick={onClick}>
|
||||
Music
|
||||
</Chip>,
|
||||
);
|
||||
const chip = screen.getByRole("button", { name: "Remove Music" });
|
||||
expect(chip.getAttribute("title")).toBe("Remove Music");
|
||||
expect(chip.className).toContain("h-7");
|
||||
expect(chip.querySelector("svg")).not.toBeNull();
|
||||
fireEvent.click(chip);
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("the measuring mirror lays out exactly like the chip it measures", () => {
|
||||
render(
|
||||
<Chip variant="tag" size="xs">
|
||||
tag
|
||||
</Chip>,
|
||||
);
|
||||
const cls = screen.getByRole("button", { name: "tag" }).className;
|
||||
for (const c of chipClass("tag", "xs").split(" ")) expect(cls).toContain(c);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DismissibleChip", () => {
|
||||
it("is two sibling controls at most, never a button in a button", () => {
|
||||
const onAccept = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
const { container } = render(
|
||||
<DismissibleChip
|
||||
variant="suggest"
|
||||
onClick={onAccept}
|
||||
bodyLabel="Add the tag Music"
|
||||
onDismiss={onDismiss}
|
||||
dismissLabel="Not now"
|
||||
>
|
||||
Music
|
||||
</DismissibleChip>,
|
||||
);
|
||||
expect(container.querySelector("button button")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add the tag Music" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Not now" }));
|
||||
expect(onAccept).toHaveBeenCalledOnce();
|
||||
expect(onDismiss).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("with no body action the label is text, and only the × is a control", () => {
|
||||
render(
|
||||
<DismissibleChip onDismiss={() => {}} dismissLabel="Remove Year: 2024">
|
||||
Year: 2024
|
||||
</DismissibleChip>,
|
||||
);
|
||||
expect(screen.getAllByRole("button")).toHaveLength(1);
|
||||
expect(screen.getByText("Year: 2024").closest("button")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { forwardRef } from "react";
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import { X } from "lucide-react";
|
||||
import { FocusSafeButton } from "./FocusSafeButton";
|
||||
import { chipClass, type ChipSize, type ChipVariant } from "./chipStyles";
|
||||
|
||||
// The chip family (docs/DESIGN.md "One primitive per button family"): a small rounded-full token that
|
||||
// is a filter, an applied narrowing, a tag, or a suggestion. It was hand-painted in five files with
|
||||
// five geometries; this is the one. `FilterChipGroup` renders a single-select row of these.
|
||||
//
|
||||
// filter — a selectable option (a year, a tag filter, a content type). Toggles with `pressed`: the
|
||||
// `.pill` recipe in index.css paints the solid accent when on. Also the "+N" overflow and
|
||||
// "More…" triggers, which light up while their popover is open (aria-expanded).
|
||||
// token — an applied narrowing, tinted accent (the `.chip` recipe): it exists because it is on.
|
||||
// tag — a user's tag, the solid accent (`.chip-solid`). Dense: it sits in table rows.
|
||||
// suggest — dashed accent: a suggestion not yet taken.
|
||||
// ghost — dashed neutral: the "add a tag" trigger, quiet in every table row until reached for.
|
||||
//
|
||||
// Nothing goes under 24px (B1, WCAG 2.5.8) and a coarse pointer grows every chip to 44 (A3).
|
||||
|
||||
type ChipProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: ChipVariant;
|
||||
size?: ChipSize;
|
||||
/** A filter chip that toggles: aria-pressed, painted solid when on. */
|
||||
pressed?: boolean;
|
||||
/** Leading glyph (a lucide element). */
|
||||
icon?: ReactNode;
|
||||
/** The whole chip removes what it stands for: a trailing × and the removal as its name. */
|
||||
removeLabel?: string;
|
||||
};
|
||||
|
||||
/** A chip that is itself the control (a toggle, a removal, an accept, a "+N" trigger). */
|
||||
export const Chip = forwardRef<HTMLButtonElement, ChipProps>(function Chip(
|
||||
{
|
||||
variant = "filter",
|
||||
size = "sm",
|
||||
pressed,
|
||||
icon,
|
||||
removeLabel,
|
||||
type,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<FocusSafeButton
|
||||
ref={ref}
|
||||
type={type ?? "button"}
|
||||
aria-pressed={pressed}
|
||||
aria-label={removeLabel}
|
||||
title={removeLabel}
|
||||
className={clsx(chipClass(variant, size), removeLabel && "group pr-1.5", className)}
|
||||
{...rest}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
{removeLabel && (
|
||||
<X className="size-3 opacity-60 group-hover:opacity-100" aria-hidden="true" />
|
||||
)}
|
||||
</FocusSafeButton>
|
||||
);
|
||||
});
|
||||
|
||||
/** A chip whose label is not the control but which carries its own trailing × (a captured filter in
|
||||
* a saved view, a suggestion with a dismiss). Two sibling controls at most, never a button in a
|
||||
* button: `onClick` makes the body one (an accept), `onDismiss` the ×. */
|
||||
export function DismissibleChip({
|
||||
variant = "token",
|
||||
size = "sm",
|
||||
icon,
|
||||
children,
|
||||
onClick,
|
||||
bodyLabel,
|
||||
bodyTitle,
|
||||
onDismiss,
|
||||
dismissLabel,
|
||||
className,
|
||||
}: {
|
||||
variant?: Exclude<ChipVariant, "filter">;
|
||||
size?: ChipSize;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
bodyLabel?: string;
|
||||
bodyTitle?: string;
|
||||
onDismiss: () => void;
|
||||
dismissLabel: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const body = (
|
||||
<>
|
||||
{icon}
|
||||
<span className="truncate">{children}</span>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<span className={clsx(chipClass(variant, size), "pr-0.5 min-w-0", className)}>
|
||||
{onClick ? (
|
||||
<FocusSafeButton
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={bodyLabel}
|
||||
title={bodyTitle}
|
||||
className="inline-flex min-w-0 items-center gap-1 self-stretch [&>svg]:size-3 [&>svg]:shrink-0"
|
||||
>
|
||||
{body}
|
||||
</FocusSafeButton>
|
||||
) : (
|
||||
<span className="inline-flex min-w-0 items-center gap-1 [&>svg]:size-3">{body}</span>
|
||||
)}
|
||||
<FocusSafeButton
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label={dismissLabel}
|
||||
title={dismissLabel}
|
||||
className="-my-px grid size-6 shrink-0 place-items-center rounded-full opacity-60 transition hover:opacity-100 hover:bg-accent/20 touch-target"
|
||||
>
|
||||
<X className="size-3" aria-hidden="true" />
|
||||
</FocusSafeButton>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Search } from "lucide-react";
|
||||
import Modal from "./Modal";
|
||||
import { closeTopOverlayThenRun } from "../../lib/history";
|
||||
import { pageStep, pageTarget } from "../../lib/listPaging";
|
||||
import { Pressable } from "./Pressable";
|
||||
|
||||
// The app's jump-to bar: one text field over every place you can go and every app-wide thing you can
|
||||
// do, opened with Ctrl/Cmd+K. It is the "one-layer operation" half of E10 — reaching a channel three
|
||||
@@ -243,14 +244,13 @@ function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups:
|
||||
{g.label}
|
||||
</div>
|
||||
{g.items.map((item) => (
|
||||
<button
|
||||
<Pressable
|
||||
key={item.id}
|
||||
id={`${listId}-${item.id}`}
|
||||
ref={item.id === activeId ? activeRef : undefined}
|
||||
data-cmd-id={item.id}
|
||||
role="option"
|
||||
aria-selected={item.id === activeId}
|
||||
type="button"
|
||||
// The combobox input owns the tab stop; the arrows move a VIRTUAL selection over
|
||||
// these rows. Leaving them tabbable put DOM focus on a row while
|
||||
// aria-activedescendant named a different one — two competing "current" options —
|
||||
@@ -268,14 +268,14 @@ function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups:
|
||||
if (e.movementX === 0 && e.movementY === 0) return;
|
||||
setPicked(item.id);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm ${
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm ${
|
||||
item.id === activeId ? "bg-card text-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{item.icon && <span className="shrink-0">{item.icon}</span>}
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
{item.hint && <span className="shrink-0 text-xs text-muted">{item.hint}</span>}
|
||||
</button>
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// paint is CSS (`.pill[aria-pressed]`), so we assert the aria-pressed / onChange / testId contract.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||
import FilterChipGroup from "./FilterChipGroup";
|
||||
|
||||
const OPTS = [
|
||||
{ value: "all", label: "All", testId: "chip-all" },
|
||||
@@ -56,8 +56,12 @@ describe("FilterChipGroup", () => {
|
||||
expect(screen.getByRole("group", { name: "Sync state" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("exports the canonical chip class for multi-select siblings to reuse", () => {
|
||||
expect(filterChipClass).toContain("rounded-full");
|
||||
expect(filterChipClass).toContain("pill");
|
||||
it("renders its options as Chips: the pill, on the 24px rung", () => {
|
||||
render(<FilterChipGroup options={OPTS} value="all" onChange={vi.fn()} ariaLabel="Sync" />);
|
||||
const chip = screen.getByTestId("chip-all");
|
||||
expect(chip.className).toContain("rounded-full");
|
||||
expect(chip.className).toContain("pill");
|
||||
expect(chip.className).toContain("h-6");
|
||||
expect(chip.getAttribute("type")).toBe("button");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { type ReactNode } from "react";
|
||||
import CountBadge from "./CountBadge";
|
||||
|
||||
// Canonical filter-chip geometry — the rounded-full "Filters bar" look used app-wide for a selectable
|
||||
// filter chip. Exported so single-select groups (this component) AND standalone/multi-select filter
|
||||
// chips (e.g. the feed content-type toggles) render identically from ONE source of truth. The active
|
||||
// paint comes entirely from the `.pill[aria-pressed="true"]` recipe (index.css) — carry no
|
||||
// bg/text/border-colour utilities here or a utility class would beat the pressed fill.
|
||||
export const filterChipClass =
|
||||
"pill inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-xs active:translate-y-px";
|
||||
import { Chip } from "./Chip";
|
||||
|
||||
export interface FilterChipOption<T extends string> {
|
||||
value: T;
|
||||
@@ -19,7 +12,7 @@ export interface FilterChipOption<T extends string> {
|
||||
}
|
||||
|
||||
// A single-select row of filter chips (exactly one active), radio-like. For a multi-select chip row,
|
||||
// render plain buttons with `filterChipClass` instead — same look, different (independent) semantics.
|
||||
// render `Chip`s with `pressed` instead — same look, different (independent) semantics.
|
||||
export default function FilterChipGroup<T extends string>({
|
||||
options,
|
||||
value,
|
||||
@@ -39,18 +32,17 @@ export default function FilterChipGroup<T extends string>({
|
||||
{options.map((o) => {
|
||||
const on = value === o.value;
|
||||
return (
|
||||
<button
|
||||
<Chip
|
||||
key={o.value}
|
||||
data-testid={o.testId}
|
||||
onClick={() => onChange(o.value)}
|
||||
title={o.title}
|
||||
aria-pressed={on}
|
||||
className={filterChipClass}
|
||||
pressed={on}
|
||||
icon={o.icon}
|
||||
>
|
||||
{o.icon}
|
||||
<span>{o.label}</span>
|
||||
{o.count != null && o.count > 0 && <CountBadge count={o.count} active={on} />}
|
||||
</button>
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -11,12 +11,14 @@ import { FocusSafeButton } from "./FocusSafeButton";
|
||||
|
||||
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
|
||||
@@ -24,17 +26,20 @@ type IconButtonVariant =
|
||||
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)
|
||||
| "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).
|
||||
@@ -50,6 +55,7 @@ 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"> {
|
||||
@@ -94,7 +100,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
||||
aria-pressed={pressed}
|
||||
title={noTitle ? undefined : label}
|
||||
className={clsx(
|
||||
"inline-grid shrink-0 place-items-center transition touch-target disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
|
||||
"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],
|
||||
onMedia || variant === "media" ? "rounded-full" : "rounded-lg",
|
||||
pressed !== undefined && PRESSED,
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { hasPagerContent, pageCount } from "./pagerContent";
|
||||
import { FocusSafeButton } from "./FocusSafeButton";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import { Button } from "./Button";
|
||||
|
||||
// The Prev / page-input / Next + "N per page" cluster, lifted out of DataTable so a non-table view
|
||||
// (the channel manager's card layout) can paginate the same way. Controlled: the caller owns page +
|
||||
@@ -66,14 +66,15 @@ export default function Pager({
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
size="sm"
|
||||
icon={<ChevronLeft />}
|
||||
onClick={() => goToPage(Math.max(0, safePage - 1))}
|
||||
disabled={safePage === 0}
|
||||
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
{t("datatable.pager.prev")}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
<span className="text-muted flex items-center gap-1.5">
|
||||
{t("datatable.pager.pageLabel")}
|
||||
<input
|
||||
@@ -89,14 +90,15 @@ export default function Pager({
|
||||
/>
|
||||
{t("datatable.pager.ofTotal", { total: totalPages })}
|
||||
</span>
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
size="sm"
|
||||
onClick={() => goToPage(Math.min(totalPages - 1, safePage + 1))}
|
||||
disabled={safePage >= totalPages - 1}
|
||||
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
|
||||
>
|
||||
{t("datatable.pager.next")}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{total > 0 && (
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { type ReactNode, type CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, Eye, EyeOff, GripVertical, Pencil } from "lucide-react";
|
||||
import { ChevronDown, Eye, EyeOff, Pencil } from "lucide-react";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useSortableItem } from "./sortableA11y";
|
||||
import { DragHandle } from "./DragHandle";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { Pressable } from "./Pressable";
|
||||
|
||||
// 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
|
||||
@@ -46,7 +49,7 @@ export default function PanelGroup({
|
||||
const titleIsTrigger = !editing && !collapsible && !!onTitleClick;
|
||||
|
||||
const titleCls =
|
||||
"flex-1 min-w-0 flex items-center gap-1 text-left select-none text-sm uppercase tracking-wide";
|
||||
"flex-1 min-w-0 flex items-center gap-1 select-none text-sm uppercase tracking-wide";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -58,55 +61,53 @@ export default function PanelGroup({
|
||||
>
|
||||
<div className="flex items-center gap-1.5 px-3 py-2">
|
||||
{editing && (
|
||||
<button
|
||||
<DragHandle
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
title={t("sidebar.dragToReorder")}
|
||||
className="-ml-1 cursor-grab active:cursor-grabbing text-muted hover:text-fg touch-none"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</button>
|
||||
label={t("sidebar.dragToReorder")}
|
||||
className="-ml-1"
|
||||
/>
|
||||
)}
|
||||
{collapsible && !editing ? (
|
||||
<button
|
||||
<Pressable
|
||||
onClick={onToggleCollapse}
|
||||
aria-expanded={!collapsed}
|
||||
className={`${titleCls} text-muted hover:text-fg transition`}
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
</button>
|
||||
</Pressable>
|
||||
) : titleIsTrigger ? (
|
||||
<button
|
||||
<Pressable
|
||||
onClick={onTitleClick}
|
||||
title={titleActionLabel}
|
||||
className={`${titleCls} group text-muted hover:text-accent transition`}
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
<Pencil className="w-3 h-3 shrink-0 text-muted/60 group-hover:text-accent" />
|
||||
</button>
|
||||
</Pressable>
|
||||
) : (
|
||||
<span className={`${titleCls} text-muted`}>
|
||||
<span className="truncate">{title}</span>
|
||||
</span>
|
||||
)}
|
||||
{editing ? (
|
||||
<button
|
||||
<IconButton
|
||||
onClick={onToggleHidden}
|
||||
title={hidden ? t("sidebar.showWidget") : t("sidebar.hideWidget")}
|
||||
className="text-muted hover:text-fg"
|
||||
label={hidden ? t("sidebar.showWidget") : t("sidebar.hideWidget")}
|
||||
>
|
||||
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
{hidden ? <EyeOff /> : <Eye />}
|
||||
</IconButton>
|
||||
) : (
|
||||
collapsible && (
|
||||
<button
|
||||
<IconButton
|
||||
onClick={onToggleCollapse}
|
||||
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
|
||||
className="text-muted hover:text-fg"
|
||||
aria-expanded={!collapsed}
|
||||
label={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
|
||||
>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</IconButton>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@ domain-free.
|
||||
| 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`, `DragHandle` (one per button family, docs/DESIGN.md), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| 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), `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` |
|
||||
@@ -51,9 +51,9 @@ are not a target — see [`docs/DESIGN.md`](../../../../docs/DESIGN.md) § Devic
|
||||
change at 1024x768, 820x1180 and a desktop size. That contract lives in these components, not in the
|
||||
~160 call sites, because a rule repeated in 160 places is a rule that is already broken somewhere:
|
||||
|
||||
- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES` and `IconButton`'s sizes are the
|
||||
rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32). Pick a rung; do not hand-roll
|
||||
padding on a primitive.
|
||||
- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES`, `IconButton`'s and `Chip`'s sizes
|
||||
are the rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32, chips 24 / 28; the C5 media
|
||||
and player rungs). Pick a rung; do not hand-roll padding on a primitive.
|
||||
- **Type, padding and box geometry are rem-derived**, so the Settings text-size slider (`--font-scale`
|
||||
on the root font-size) scales boxes and padding with the label. A `text-[Npx]` font size is an
|
||||
eslint error for exactly this reason. The deliberate exceptions are the layout-engine measurements
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react";
|
||||
import { useScrollFade } from "../../lib/useScrollFade";
|
||||
import { useHoverFocusWithin } from "../../lib/useHoverFocusWithin";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { Pressable } from "./Pressable";
|
||||
|
||||
// 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
|
||||
@@ -72,7 +73,7 @@ export default function SidePanel({
|
||||
nowhere. It only goes transparent: the card's rounded corner cuts away its own pixels, and
|
||||
this square-cornered tab sits right under that arc, so it would otherwise bleed through.
|
||||
`opacity-0` (not `invisible`/`hidden`) keeps it hoverable and focusable. */}
|
||||
<button
|
||||
<Pressable
|
||||
{...peek.hoverHandlers}
|
||||
onClick={onToggleCollapse}
|
||||
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
|
||||
@@ -92,7 +93,7 @@ export default function SidePanel({
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
</Pressable>
|
||||
|
||||
{expanded && (
|
||||
<aside
|
||||
@@ -113,14 +114,14 @@ export default function SidePanel({
|
||||
</IconButton>
|
||||
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
|
||||
{onTitleClick ? (
|
||||
<button
|
||||
<Pressable
|
||||
onClick={onTitleClick}
|
||||
title={titleActionLabel}
|
||||
aria-label={titleActionLabel}
|
||||
className="flex-1 min-w-0 truncate text-left text-sm font-bold uppercase tracking-[0.12em] transition hover:text-accent"
|
||||
className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em] transition hover:text-accent"
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
</Pressable>
|
||||
) : (
|
||||
<span className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em]">
|
||||
{title}
|
||||
@@ -145,15 +146,13 @@ export default function SidePanel({
|
||||
</IconButton>
|
||||
)}
|
||||
{onToggleEditing && (
|
||||
<button
|
||||
<IconButton
|
||||
variant={editing ? "ghost-accent" : "ghost"}
|
||||
onClick={onToggleEditing}
|
||||
title={editing ? t("sidebar.done") : t("sidebar.customize")}
|
||||
className={`shrink-0 p-1.5 rounded-lg transition hover:bg-card ${
|
||||
editing ? "text-accent" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
label={editing ? t("sidebar.done") : t("sidebar.customize")}
|
||||
>
|
||||
{editing ? <Check className="w-4 h-4" /> : <Pencil className="w-4 h-4" />}
|
||||
</button>
|
||||
{editing ? <Check /> : <Pencil />}
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { createStore } from "../../lib/store";
|
||||
import { useToastDock } from "../../lib/toastDock";
|
||||
import { useOverlayOrder } from "./overlayOrder";
|
||||
import { LEVEL_STYLE } from "./toastLevels";
|
||||
import { Button } from "./Button";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
// 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
|
||||
@@ -170,24 +172,25 @@ export default function Toaster({
|
||||
)}
|
||||
</div>
|
||||
{toast.action && (
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
toast.action!.onClick();
|
||||
dismiss(toast.id);
|
||||
}}
|
||||
className="mt-1 text-accent text-sm font-semibold hover:underline"
|
||||
className="mt-1 text-sm"
|
||||
>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
<IconButton
|
||||
onClick={() => dismiss(toast.id)}
|
||||
className="shrink-0 text-muted hover:text-fg"
|
||||
title={t("notifications.dismiss")}
|
||||
label={t("notifications.dismiss")}
|
||||
className="-m-1.5"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<X />
|
||||
</IconButton>
|
||||
{toast.duration && (
|
||||
// Keyed by `ts`: a coalesced repeat re-arms the dismiss timer, so its bar restarts too.
|
||||
<CountdownBar key={toast.ts} since={toast.ts} duration={toast.duration} bar={bar} />
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Redo2, Undo2 } from "lucide-react";
|
||||
import { dispatch, useKeyScope } from "../../lib/keymap";
|
||||
import { FocusSafeButton } from "./FocusSafeButton";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
// Reusable undo/redo buttons with Ctrl/Cmd+Z (undo) and Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z
|
||||
// (redo) shortcuts. Pairs with useUndoable but takes plain props, so it works with any
|
||||
@@ -44,28 +44,14 @@ export default function UndoToolbar({
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [canUndo, canRedo, onUndo, onRedo]);
|
||||
|
||||
const btn =
|
||||
"p-1.5 rounded-lg border border-border text-muted enabled:not-aria-disabled:hover:text-fg enabled:not-aria-disabled:hover:border-accent disabled:opacity-30 transition";
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<FocusSafeButton
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
title={t("common.undo")}
|
||||
aria-label={t("common.undo")}
|
||||
className={btn}
|
||||
>
|
||||
<Undo2 className="w-4 h-4" />
|
||||
</FocusSafeButton>
|
||||
<FocusSafeButton
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
title={t("common.redo")}
|
||||
aria-label={t("common.redo")}
|
||||
className={btn}
|
||||
>
|
||||
<Redo2 className="w-4 h-4" />
|
||||
</FocusSafeButton>
|
||||
<IconButton variant="outline" onClick={onUndo} disabled={!canUndo} label={t("common.undo")}>
|
||||
<Undo2 />
|
||||
</IconButton>
|
||||
<IconButton variant="outline" onClick={onRedo} disabled={!canRedo} label={t("common.redo")}>
|
||||
<Redo2 />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
// The chip family's geometry and paint, apart from the component (`Chip.tsx`) so the hidden measuring
|
||||
// mirror of an overflowing chip lane can lay out exactly like the chips it measures.
|
||||
|
||||
export type ChipVariant = "filter" | "token" | "tag" | "suggest" | "ghost";
|
||||
export type ChipSize =
|
||||
| "md" // 28, text-sm — the applied-filter row above the feed
|
||||
| "sm" // 24, text-xs — a filter row
|
||||
| "xs"; // 24, text-2xs, tight — a tag in a table row
|
||||
|
||||
const ACT = "enabled:not-aria-disabled:";
|
||||
const PAINT: Record<ChipVariant, string> = {
|
||||
// `.pill` owns the resting, hover AND pressed colours; a colour utility here would beat the pressed
|
||||
// fill, so the open-popover state is the only one spelled out.
|
||||
filter: "pill border shadow-xs aria-expanded:border-accent aria-expanded:text-accent",
|
||||
token: `chip ${ACT}hover:border-accent ${ACT}hover:bg-accent/20 aria-expanded:border-accent`,
|
||||
// B3: a filled control's hover mixes its own text colour into the fill, never an opacity dim.
|
||||
tag: `chip-solid font-medium ${ACT}hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))]`,
|
||||
suggest: `border border-dashed border-accent/50 text-accent/90 ${ACT}hover:text-accent ${ACT}hover:border-accent`,
|
||||
ghost: `border border-dashed border-border text-muted ${ACT}hover:text-accent ${ACT}hover:border-accent`,
|
||||
};
|
||||
|
||||
const SIZES: Record<ChipSize, string> = {
|
||||
md: "h-7 px-2.5 text-sm gap-1",
|
||||
sm: "h-6 px-2.5 text-xs gap-1.5",
|
||||
xs: "h-6 px-1.5 text-2xs gap-1",
|
||||
};
|
||||
|
||||
const BASE =
|
||||
"inline-flex min-w-6 shrink-0 items-center justify-center rounded-full whitespace-nowrap transition touch-target disabled:opacity-50 aria-disabled:opacity-50 [&>svg]:shrink-0";
|
||||
|
||||
/** The chip's geometry + paint as a class string: for the hidden measuring mirror of an overflowing
|
||||
* chip lane, which must lay out exactly like the chips it measures but must not be buttons. */
|
||||
export function chipClass(variant: ChipVariant = "filter", size: ChipSize = "sm") {
|
||||
return clsx(BASE, PAINT[variant], SIZES[size]);
|
||||
}
|
||||
@@ -14,9 +14,6 @@ import { FocusSafeButton } from "./FocusSafeButton";
|
||||
export const inputCls =
|
||||
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40";
|
||||
|
||||
/** The standard text button style (used verbatim by ShareDialog + VideoEditor). */
|
||||
export const btnCls = "px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50";
|
||||
|
||||
/** Pill on/off switch — the bare control; compose it with your own label/row. Pass `label` for
|
||||
* the accessible name (the visible row text) since the control itself has no inner text. */
|
||||
export function Switch({
|
||||
|
||||
@@ -8,6 +8,9 @@ import { type SortState } from "../../lib/columnSort";
|
||||
import { isActive, type Column, type FilterMap } from "../../lib/tableColumns";
|
||||
import { alignClass } from "./tableAlign";
|
||||
import { useStickyTop } from "./stickyTop";
|
||||
import { Button } from "./Button";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { Pressable } from "./Pressable";
|
||||
|
||||
// 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
|
||||
@@ -75,17 +78,16 @@ function FilterFunnel<T>({
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
<IconButton
|
||||
variant={filterOn ? "ghost-accent" : "ghost"}
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={t("datatable.filter")}
|
||||
// Generous padding: the icon alone was a 14x14 hit target, the smallest in the app.
|
||||
className={`-m-1.5 p-1.5 transition ${
|
||||
filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
|
||||
}`}
|
||||
label={t("datatable.filter")}
|
||||
// The icon alone was a 14x14 hit target, the smallest in the app; the sm rung is 28.
|
||||
className="-my-1.5"
|
||||
>
|
||||
<ListFilter className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<ListFilter className="size-3.5!" />
|
||||
</IconButton>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={col.header}
|
||||
@@ -167,12 +169,13 @@ function FilterFunnel<T>({
|
||||
</FilterOptionList>
|
||||
)}
|
||||
{filterOn && (
|
||||
<button
|
||||
<Button
|
||||
variant="link-muted"
|
||||
onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")}
|
||||
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition"
|
||||
className="mt-1.5 w-full text-xs"
|
||||
>
|
||||
{t("datatable.clear")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
@@ -229,7 +232,7 @@ export function ColumnHead<T>({
|
||||
focusable no-op button, which put unlabeled dead stops in the tab order —
|
||||
two of them per playlist table, whose grip and actions headers are empty. */}
|
||||
{col.sortable ? (
|
||||
<button
|
||||
<Pressable
|
||||
onClick={() => onToggleSort(col.key)}
|
||||
className="inline-flex items-center gap-1 hover:text-fg cursor-pointer"
|
||||
>
|
||||
@@ -241,7 +244,7 @@ export function ColumnHead<T>({
|
||||
) : (
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
))}
|
||||
</button>
|
||||
</Pressable>
|
||||
) : (
|
||||
<span>{col.header}</span>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { SCHEMES, type Scheme } from "../lib/theme";
|
||||
import { BatchA } from "./batchA";
|
||||
import { BatchB } from "./batchB";
|
||||
import { BatchC } from "./batchC";
|
||||
import { Chip } from "../components/ui/Chip";
|
||||
|
||||
// The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits
|
||||
// OUTSIDE <App>, so it owns the theme attributes on <html> itself, through the same data-theme /
|
||||
@@ -23,12 +23,6 @@ function applyLook(l: Look) {
|
||||
el.style.setProperty("--font-scale", String(l.fontScale));
|
||||
}
|
||||
|
||||
const chip = (on: boolean) =>
|
||||
clsx(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition",
|
||||
on ? "border-accent bg-accent text-accent-fg" : "border-border text-muted hover:text-fg",
|
||||
);
|
||||
|
||||
export default function DesignLab() {
|
||||
const [look, setLook] = useState<Look>(START);
|
||||
useLayoutEffect(() => {
|
||||
@@ -47,36 +41,22 @@ export default function DesignLab() {
|
||||
<strong className="text-sm">Design lab</strong>
|
||||
<span className="flex gap-1">
|
||||
{(["dark", "light"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
className={chip(look.mode === m)}
|
||||
onClick={() => set({ mode: m })}
|
||||
>
|
||||
<Chip key={m} pressed={look.mode === m} onClick={() => set({ mode: m })}>
|
||||
{m}
|
||||
</button>
|
||||
</Chip>
|
||||
))}
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{SCHEMES.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={chip(look.scheme === s.id)}
|
||||
onClick={() => set({ scheme: s.id })}
|
||||
>
|
||||
<Chip key={s.id} pressed={look.scheme === s.id} onClick={() => set({ scheme: s.id })}>
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ background: s.swatch }} />
|
||||
{s.name}
|
||||
</button>
|
||||
</Chip>
|
||||
))}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={chip(look.backdrop)}
|
||||
onClick={() => set({ backdrop: !look.backdrop })}
|
||||
>
|
||||
<Chip pressed={look.backdrop} onClick={() => set({ backdrop: !look.backdrop })}>
|
||||
backdrop
|
||||
</button>
|
||||
</Chip>
|
||||
<label className="flex items-center gap-2 text-xs text-muted">
|
||||
text {look.fontScale.toFixed(2)}
|
||||
<input
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Btn } from "./batchA";
|
||||
import { hexContrast, hueGap } from "../lib/colorMath";
|
||||
import { Reference, Sample } from "./labKit";
|
||||
import { FADE_ABOVE_PANEL, FADE_BELOW, FADE_BELOW_PANEL, FILLED, H, LAYER } from "./labTokens";
|
||||
import { Chip } from "../components/ui/Chip";
|
||||
|
||||
// E28 S2 · Batch B — surfaces, chrome and colour, DECIDED (owner, 2026-09-24): every recommendation,
|
||||
// except B7, where the owner combined the soft tint + edge bar with optional labels. B1 (density) and
|
||||
@@ -124,8 +125,8 @@ function SchemeTile({ s, mode }: { s: SchemeDef; mode: Mode }) {
|
||||
{s.name} <span className="font-normal text-muted">· {mode}</span>
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="btn-primary rounded-lg px-2.5 py-1 text-xs font-medium">Save</span>
|
||||
<span className="btn-danger rounded-lg px-2.5 py-1 text-xs font-medium">Delete</span>
|
||||
<span className="state-filled rounded-lg px-2.5 py-1 text-xs font-medium">Save</span>
|
||||
<span className="state-danger rounded-lg px-2.5 py-1 text-xs font-medium">Delete</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 text-2xs">
|
||||
<span className="chip px-2 py-0.5">Filter</span>
|
||||
@@ -252,13 +253,9 @@ function WatchPage() {
|
||||
<div className="text-xs text-muted">Techno Tim</div>
|
||||
</div>
|
||||
<span className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("pill inline-flex items-center rounded-full border font-medium", H.md)}
|
||||
aria-pressed
|
||||
>
|
||||
<Repeat className="h-3.5 w-3.5" /> Repeat
|
||||
</button>
|
||||
<Chip size="md" pressed icon={<Repeat className="size-3.5" />} className="font-medium">
|
||||
Repeat
|
||||
</Chip>
|
||||
<Btn tone="glass" size="md" icon={<Download className={ico} />}>
|
||||
Download
|
||||
</Btn>
|
||||
|
||||
@@ -98,7 +98,6 @@
|
||||
"removeItem": "Remove",
|
||||
"removeItemOf": "Remove “{{title}}” from the playlist",
|
||||
"confirmRemove": "Remove “{{title}}” from this playlist?",
|
||||
"dragHandle": "Drag to reorder",
|
||||
"dragHandleOf": "Reorder “{{title}}”",
|
||||
"dragBlockedSort": "Reordering by hand is off while a column sort or grouping is applied.",
|
||||
"dragBlockedFilter": "Reordering by hand is off while a filter is applied.",
|
||||
|
||||
@@ -98,7 +98,6 @@
|
||||
"removeItem": "Eltávolítás",
|
||||
"removeItemOf": "„{{title}}” eltávolítása a listáról",
|
||||
"confirmRemove": "Eltávolítod a(z) „{{title}}” videót erről a listáról?",
|
||||
"dragHandle": "Húzd az átrendezéshez",
|
||||
"dragHandleOf": "„{{title}}” átrendezése",
|
||||
"dragBlockedSort": "A kézi átrendezés ki van kapcsolva, amíg oszloprendezés vagy csoportosítás van érvényben.",
|
||||
"dragBlockedFilter": "A kézi átrendezés ki van kapcsolva, amíg szűrő van érvényben.",
|
||||
|
||||
+10
-24
@@ -96,6 +96,16 @@
|
||||
background-color: color-mix(in srgb, var(--danger-fg) 24%, var(--danger));
|
||||
}
|
||||
}
|
||||
@utility state-warn {
|
||||
background-color: var(--warn);
|
||||
color: var(--warn-fg);
|
||||
&:hover:not(:disabled, [aria-disabled="true"]) {
|
||||
background-color: color-mix(in srgb, var(--warn-fg) 14%, var(--warn));
|
||||
}
|
||||
&:active:not(:disabled, [aria-disabled="true"]) {
|
||||
background-color: color-mix(in srgb, var(--warn-fg) 24%, var(--warn));
|
||||
}
|
||||
}
|
||||
/* A4: a control's ≥ 3:1 edge. */
|
||||
@utility edge-strong {
|
||||
border-color: var(--edge-strong);
|
||||
@@ -618,30 +628,6 @@ body {
|
||||
color: var(--accent-fg);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
/* .btn-{tone} — solid ACTION-button paint (the "primary/danger/warn action", distinct from the
|
||||
selection recipes above which are "selected among options"). Each sets its tone fill + contrasting
|
||||
fg; all share ONE standard hover-dim. Like .chip-solid this owns paint (+ that single hover
|
||||
behaviour), NOT geometry — a site keeps its own padding/radius/font/gap/disabled:opacity/shadow.
|
||||
Migrated sites MUST drop `bg-{tone} text-{tone}-fg hover:opacity-90` so these @layer-components
|
||||
rules aren't beaten by those utilities. No focus-visible — @layer base already rings every button. */
|
||||
.btn-primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: var(--danger);
|
||||
color: var(--danger-fg);
|
||||
}
|
||||
.btn-warn {
|
||||
background-color: var(--warn);
|
||||
color: var(--warn-fg);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled):not([aria-disabled="true"]),
|
||||
.btn-danger:hover:not(:disabled):not([aria-disabled="true"]),
|
||||
.btn-warn:hover:not(:disabled):not([aria-disabled="true"]) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ===== E10 S1c · the minimum-target half of the tablet comfort contract (docs/DESIGN.md) =====
|
||||
A 32px icon button is fine with a mouse (a precise pointer plus hover to confirm the aim) and
|
||||
not fine with a thumb. So the minimum grows ONLY where there is no precise pointer, which on
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { ESLint } from "eslint";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// `local/button-primitive` (eslint.config.js): one primitive per button family (docs/DESIGN.md, E28
|
||||
// S3b). 268 hand-rolled buttons were swept onto ui/Button, IconButton, Pressable, Chip and
|
||||
// DragHandle; this pins that the rule keeping a new one out actually fires, and where it stands down.
|
||||
const RULE = "local/button-primitive";
|
||||
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
|
||||
|
||||
async function hits(source: string, filePath = "src/components/__probe.tsx"): Promise<number> {
|
||||
const eslint = new ESLint({ cwd: FRONTEND });
|
||||
const [result] = await eslint.lintText(source, { filePath });
|
||||
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
|
||||
}
|
||||
|
||||
describe("a hand-rolled button", () => {
|
||||
it("is refused in a feature component, the lab and lib, raw or focus-safe", async () => {
|
||||
const raw = 'export const A = () => <button className="px-3">x</button>;\n';
|
||||
expect(await hits(raw)).toBe(1);
|
||||
expect(await hits(raw, "src/design-lab/__probe.tsx")).toBe(1);
|
||||
expect(await hits(raw, "src/lib/__probe.tsx")).toBe(1);
|
||||
// …and in a ui/ COMPOSITE: a pager or a side panel is built from the primitives too.
|
||||
expect(await hits(raw, "src/components/ui/Pager.tsx")).toBe(1);
|
||||
expect(
|
||||
await hits(
|
||||
'import { FocusSafeButton } from "./ui/FocusSafeButton";\nexport const B = () => <FocusSafeButton>x</FocusSafeButton>;\n',
|
||||
),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("is allowed only in a control primitive, and the primitives pass everywhere", async () => {
|
||||
for (const file of ["IconButton.tsx", "SegmentedToggle.tsx", "AnchoredPopover.tsx"])
|
||||
expect(
|
||||
await hits("export const C = () => <button>x</button>;\n", `src/components/ui/${file}`),
|
||||
).toBe(0);
|
||||
// Named like a primitive but outside ui/: still refused.
|
||||
expect(
|
||||
await hits("export const E = () => <button>x</button>;\n", "src/components/Button.tsx"),
|
||||
).toBe(1);
|
||||
expect(
|
||||
await hits(
|
||||
'import { Button } from "./ui/Button";\nexport const D = () => <Button>x</Button>;\n',
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user