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 |
|
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
||||||
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
||||||
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
| 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 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 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>` |
|
| 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,
|
- **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
|
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
|
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
|
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. A pressed
|
`Button` and `IconButton` use the `overlay` variant (white, a white layer), whatever the theme; the Plex
|
||||||
toggle is `pressed`: the solid accent on a `Button`, the accent glyph on an `IconButton`. A hand-rolled
|
control row is IconButton's `player` rung (36, a 20px glyph, round, 44 on touch). A pressed toggle is
|
||||||
`<button className="px-3 …">` is a violation. A text-less button must have an accessible name, and the
|
`pressed`: the solid accent on a `Button`, the accent glyph on an `IconButton`. A raw `<button>` outside
|
||||||
type enforces it. [WCAG 4.1.2]
|
`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,
|
- **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]
|
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`.
|
- 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).
|
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.
|
- A button that starts slow work shows `loading` (a spinner, `aria-busy`) and can't be pressed twice.
|
||||||
[Nielsen #1]
|
[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 | 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 | 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 | 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 |
|
| 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
|
// 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
|
// 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 —
|
// 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,
|
"sortable-a11y": sortableA11y,
|
||||||
"jsx-comment-text": jsxCommentText,
|
"jsx-comment-text": jsxCommentText,
|
||||||
"focus-safe-disabled": focusSafeDisabled,
|
"focus-safe-disabled": focusSafeDisabled,
|
||||||
|
"button-primitive": buttonPrimitive,
|
||||||
"literal-a11y-text": literalA11yText,
|
"literal-a11y-text": literalA11yText,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -211,6 +254,7 @@ export default tseslint.config(
|
|||||||
"react-hooks/refs": "off",
|
"react-hooks/refs": "off",
|
||||||
"local/jsx-comment-text": "error",
|
"local/jsx-comment-text": "error",
|
||||||
"local/focus-safe-disabled": "error",
|
"local/focus-safe-disabled": "error",
|
||||||
|
"local/button-primitive": "error",
|
||||||
"local/literal-a11y-text": "error",
|
"local/literal-a11y-text": "error",
|
||||||
"local/sortable-a11y": "error",
|
"local/sortable-a11y": "error",
|
||||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
@@ -372,6 +416,8 @@ export default tseslint.config(
|
|||||||
"no-restricted-globals": "off",
|
"no-restricted-globals": "off",
|
||||||
"no-restricted-properties": "off",
|
"no-restricted-properties": "off",
|
||||||
"local/literal-a11y-text": "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,
|
prettier,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { copyText } from "../lib/clipboard";
|
|||||||
import { formatDateTime } from "../lib/format";
|
import { formatDateTime } from "../lib/format";
|
||||||
import { invalidateUserAccess } from "../lib/adminUsers";
|
import { invalidateUserAccess } from "../lib/adminUsers";
|
||||||
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
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
|
// 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).
|
// one account lives (E12 · S5, design option C).
|
||||||
@@ -196,24 +196,28 @@ export default function AccountPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{a.can_activate && (
|
{a.can_activate && (
|
||||||
<FocusSafeButton
|
<Button
|
||||||
|
variant="glass"
|
||||||
|
fullWidth
|
||||||
|
icon={<Check />}
|
||||||
|
loading={activate.isPending}
|
||||||
onClick={() => activate.mutate()}
|
onClick={() => activate.mutate()}
|
||||||
disabled={activate.isPending}
|
className="border border-accent/40 text-accent"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<Check className="w-4 h-4 shrink-0" />
|
|
||||||
<span className="flex-1 text-left">{t("users.account.activate")}</span>
|
<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()}
|
onClick={() => resend.mutate()}
|
||||||
// The demo account's address is never confirmed, and the server refuses to mail it — as
|
// 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).
|
// 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}
|
disabled={user.is_demo || !a.email_configured || a.email_verified}
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<Send className="w-4 h-4 shrink-0" />
|
|
||||||
<span className="flex-1 text-left">{t("users.account.resend")}</span>
|
<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
|
{/* 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. */}
|
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 && (
|
{a.email_configured && a.email_verified && (
|
||||||
<span className="text-muted">{t("users.account.alreadyConfirmed")}</span>
|
<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()}
|
onClick={() => issue.mutate()}
|
||||||
disabled={issue.isPending || user.is_demo}
|
className="border border-accent/40 text-accent"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<KeyRound className="w-4 h-4 shrink-0" />
|
|
||||||
<span className="flex-1 text-left">
|
<span className="flex-1 text-left">
|
||||||
{link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")}
|
{link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")}
|
||||||
</span>
|
</span>
|
||||||
</FocusSafeButton>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{link && (
|
{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">
|
<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}
|
{link.url}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
|
variant="glass"
|
||||||
|
icon={copied ? <Check /> : <Copy />}
|
||||||
onClick={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")}
|
{copied ? t("users.account.copied") : t("users.account.copy")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xs text-muted leading-relaxed">{t("users.account.linkNote")}</p>
|
<p className="text-2xs text-muted leading-relaxed">{t("users.account.linkNote")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { decideChipLayout } from "../lib/chipOverflow";
|
|||||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
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
|
// 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
|
// 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.
|
// the "fit some + N more" treatment used on wider lanes.
|
||||||
const NARROW_PX = 420;
|
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 }) {
|
function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<button
|
<Chip
|
||||||
|
variant="token"
|
||||||
|
size="md"
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
data-filter-key={f.key}
|
data-filter-key={f.key}
|
||||||
onClick={f.remove}
|
onClick={f.remove}
|
||||||
title={t("feed.chips.remove", { name: f.label })}
|
removeLabel={t("feed.chips.remove", { name: f.label })}
|
||||||
aria-label={t("feed.chips.remove", { name: f.label })}
|
|
||||||
className={chipCls}
|
|
||||||
>
|
>
|
||||||
<span className="max-w-56 truncate">{f.label}</span>
|
<span className="max-w-56 truncate">{f.label}</span>
|
||||||
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
|
</Chip>
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,18 +68,16 @@ function OverflowPopover({
|
|||||||
const name = ariaLabel ?? label;
|
const name = ariaLabel ?? label;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<Chip
|
||||||
type="button"
|
variant="token"
|
||||||
|
size="md"
|
||||||
ref={pop.refs.setReference}
|
ref={pop.refs.setReference}
|
||||||
{...pop.getReferenceProps()}
|
{...pop.getReferenceProps()}
|
||||||
aria-label={name}
|
aria-label={name}
|
||||||
className={`chip inline-flex shrink-0 items-center gap-1 px-2.5 py-1 text-sm whitespace-nowrap transition ${
|
icon={icon ? <Filter className="size-3.5" aria-hidden="true" /> : undefined}
|
||||||
open ? "border-accent text-accent" : "hover:border-accent"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{icon && <Filter className="w-3.5 h-3.5 shrink-0" aria-hidden="true" />}
|
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</Chip>
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={pop}
|
popover={pop}
|
||||||
ariaLabel={name}
|
ariaLabel={name}
|
||||||
@@ -101,17 +98,17 @@ function OverflowPopover({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 border-t border-border px-1 pt-1.5">
|
<div className="mt-1 border-t border-border px-1 pt-1.5">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="link-muted"
|
||||||
data-testid="active-filter-clear-all"
|
data-testid="active-filter-clear-all"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onClearAll();
|
onClearAll();
|
||||||
setOpen(false);
|
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")}
|
{t("sidebar.clearAll")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
</>
|
</>
|
||||||
@@ -191,13 +188,13 @@ export default function ActiveFilterChips({
|
|||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
chipRefs.current[i] = el;
|
chipRefs.current[i] = el;
|
||||||
}}
|
}}
|
||||||
className={chipCls}
|
className={chipClass("token", "md") + " pr-1.5"}
|
||||||
>
|
>
|
||||||
<span className="max-w-56 truncate">{f.label}</span>
|
<span className="max-w-56 truncate">{f.label}</span>
|
||||||
<X className="w-3 h-3 shrink-0" />
|
<X className="size-3" />
|
||||||
</span>
|
</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}
|
+{n}
|
||||||
</span>
|
</span>
|
||||||
<span ref={clearRef} className="ml-0.5 text-sm underline underline-offset-2">
|
<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 && (
|
{layout.mode === "all" && n > 1 && (
|
||||||
<button
|
<Button
|
||||||
|
variant="link-muted"
|
||||||
data-testid="active-filter-clear-all"
|
data-testid="active-filter-clear-all"
|
||||||
onClick={onClearAll}
|
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")}
|
{t("sidebar.clearAll")}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
} from "@tanstack/react-query";
|
} 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 { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api";
|
||||||
import { copyText } from "../lib/clipboard";
|
import { copyText } from "../lib/clipboard";
|
||||||
@@ -33,6 +33,7 @@ import FilterChipGroup from "./ui/FilterChipGroup";
|
|||||||
import FilterSelect from "./ui/FilterSelect";
|
import FilterSelect from "./ui/FilterSelect";
|
||||||
import { Section } from "./ui/form";
|
import { Section } from "./ui/form";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
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
|
// 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
|
// 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;
|
onClear: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<span className="chip py-0.5 pl-2.5 pr-1">
|
<DismissibleChip onDismiss={onClear} dismissLabel={clearLabel} className="font-mono">
|
||||||
<span className="font-mono">{label}</span>
|
{label}
|
||||||
<button
|
</DismissibleChip>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,7 +441,7 @@ function RowDetail({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const pivotCls =
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<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">
|
<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>
|
<dt>{t("adminLogs.fields.request")}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="link"
|
||||||
className={pivotCls}
|
className={pivotCls}
|
||||||
title={t("adminLogs.showRequest")}
|
title={t("adminLogs.showRequest")}
|
||||||
onClick={() => onRequest(row.request_id!)}
|
onClick={() => onRequest(row.request_id!)}
|
||||||
>
|
>
|
||||||
{row.request_id}
|
{row.request_id}
|
||||||
</button>
|
</Button>
|
||||||
</dd>
|
</dd>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -480,14 +472,14 @@ function RowDetail({
|
|||||||
<>
|
<>
|
||||||
<dt>{t("adminLogs.fields.job")}</dt>
|
<dt>{t("adminLogs.fields.job")}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="link"
|
||||||
className={pivotCls}
|
className={pivotCls}
|
||||||
title={t("adminLogs.showJob")}
|
title={t("adminLogs.showJob")}
|
||||||
onClick={() => onJob(row.job_id!)}
|
onClick={() => onJob(row.job_id!)}
|
||||||
>
|
>
|
||||||
{row.job_id}
|
{row.job_id}
|
||||||
</button>
|
</Button>
|
||||||
</dd>
|
</dd>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import Pager from "./ui/Pager";
|
|||||||
import { PageToolbar } from "./ui/PageShell";
|
import { PageToolbar } from "./ui/PageShell";
|
||||||
import { useConfirm } from "./confirmContext";
|
import { useConfirm } from "./confirmContext";
|
||||||
import { useChannelLayout } from "./channelsContext";
|
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
|
// 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
|
// 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")
|
canWrite ? t("channels.discovery.subscribeHint") : t("channels.discovery.needWriteHint")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FocusSafeButton
|
<Button
|
||||||
// Busy blocks the click, not the focus (FocusSafeButton).
|
variant="glass"
|
||||||
|
size="xs"
|
||||||
|
icon={<UserPlus />}
|
||||||
onClick={() => onSubscribe(c)}
|
onClick={() => onSubscribe(c)}
|
||||||
disabled={!canWrite}
|
disabled={!canWrite}
|
||||||
busy={subscribe.isPending}
|
loading={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"
|
|
||||||
>
|
>
|
||||||
<UserPlus className="w-3.5 h-3.5" />
|
|
||||||
{t("channels.discovery.subscribe")}
|
{t("channels.discovery.subscribe")}
|
||||||
</FocusSafeButton>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ExternalLink } from "lucide-react";
|
|||||||
import Avatar from "./ui/Avatar";
|
import Avatar from "./ui/Avatar";
|
||||||
import Tooltip from "./ui/Tooltip";
|
import Tooltip from "./ui/Tooltip";
|
||||||
import { channelYouTubeUrl } from "../lib/format";
|
import { channelYouTubeUrl } from "../lib/format";
|
||||||
|
import { Pressable } from "./ui/Pressable";
|
||||||
|
|
||||||
// Avatar + channel name + "open on YouTube" link, shared by the Channel manager and the
|
// 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
|
// 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">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Avatar src={thumbnailUrl} fallback={title ?? ""} className="w-8 h-8 rounded-full shrink-0" />
|
<Avatar src={thumbnailUrl} fallback={title ?? ""} className="w-8 h-8 rounded-full shrink-0" />
|
||||||
{onView ? (
|
{onView ? (
|
||||||
<button
|
<Pressable
|
||||||
onClick={onView}
|
onClick={onView}
|
||||||
// The channel's "open" element: in a keyboard-walkable list (lib/listNav) Enter on the row or
|
// The channel's "open" element: in a keyboard-walkable list (lib/listNav) Enter on the row or
|
||||||
// card clicks this.
|
// 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 = the full name, so a truncated fluid-column name is still readable on hover.
|
||||||
title={name}
|
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}
|
{name}
|
||||||
</button>
|
</Pressable>
|
||||||
) : (
|
) : (
|
||||||
<span title={name} className="text-sm font-medium truncate min-w-0">
|
<span title={name} className="text-sm font-medium truncate min-w-0">
|
||||||
{name}
|
{name}
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import { ChannelTagsControl } from "./ChannelTags";
|
|||||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
|
||||||
|
|
||||||
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
|
// 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
|
// 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" }}
|
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<button
|
<Button
|
||||||
|
variant="media"
|
||||||
data-testid="channel-back"
|
data-testid="channel-back"
|
||||||
|
icon={<ArrowLeft />}
|
||||||
onClick={closeChannel}
|
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")}
|
{t("channel.back")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 sm:px-6">
|
<div className="px-4 sm:px-6">
|
||||||
@@ -489,39 +489,32 @@ export default function ChannelPage({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!me.is_demo && (
|
{!me.is_demo && (
|
||||||
<FocusSafeButton
|
<IconButton
|
||||||
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
|
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
|
||||||
// light up on hover, as if a click would do something.
|
// light up on hover, as if a click would do something.
|
||||||
|
size="md"
|
||||||
|
variant={ch?.blocked ? "danger-soft" : "outline-danger"}
|
||||||
onClick={() => block.mutate()}
|
onClick={() => block.mutate()}
|
||||||
busy={block.isPending}
|
loading={block.isPending}
|
||||||
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
label={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"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Ban className="w-4 h-4" />
|
<Ban />
|
||||||
</FocusSafeButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
{/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel
|
{/* 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
|
(mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos
|
||||||
from the feed. EyeOff = currently hidden (click to unhide). */}
|
from the feed. EyeOff = currently hidden (click to unhide). */}
|
||||||
{ch?.subscribed && !me.is_demo && (
|
{ch?.subscribed && !me.is_demo && (
|
||||||
<FocusSafeButton
|
<IconButton
|
||||||
|
size="md"
|
||||||
|
variant={ch.hidden ? "outline-accent" : "outline"}
|
||||||
onClick={() => toggleHidden.mutate()}
|
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")}
|
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" />}
|
{ch.hidden ? <EyeOff /> : <Eye />}
|
||||||
</FocusSafeButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
{!me.is_demo &&
|
{!me.is_demo &&
|
||||||
!ch?.blocked &&
|
!ch?.blocked &&
|
||||||
@@ -585,20 +578,15 @@ export default function ChannelPage({
|
|||||||
/>
|
/>
|
||||||
{!ch?.subscribed && nextToken && (
|
{!ch?.subscribed && nextToken && (
|
||||||
<div className="flex justify-center pb-6">
|
<div className="flex justify-center pb-6">
|
||||||
<button
|
<Button
|
||||||
// Busy blocks the click, not the focus (see Block above).
|
// Busy blocks the click, not the focus (see Block above).
|
||||||
onClick={() => !exploring && runExplore(nextToken)}
|
variant="outline"
|
||||||
aria-disabled={exploring || undefined}
|
icon={<RefreshCw />}
|
||||||
aria-busy={exploring || undefined}
|
loading={exploring}
|
||||||
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"
|
onClick={() => runExplore(nextToken)}
|
||||||
>
|
>
|
||||||
{exploring ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
{t("channel.loadMore")}
|
{t("channel.loadMore")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { type MyStatus } from "../lib/api";
|
|||||||
import { formatEta, formatNumber } from "../lib/format";
|
import { formatEta, formatNumber } from "../lib/format";
|
||||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
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
|
// 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
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="glass"
|
||||||
|
size="sm"
|
||||||
|
icon={<BarChart3 aria-hidden="true" />}
|
||||||
ref={pop.refs.setReference}
|
ref={pop.refs.setReference}
|
||||||
{...pop.getReferenceProps()}
|
{...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")}
|
{t("channels.stats.label")}
|
||||||
</button>
|
</Button>
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={pop}
|
popover={pop}
|
||||||
ariaLabel={t("channels.stats.label")}
|
ariaLabel={t("channels.stats.label")}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useRef, useState } from "react";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { type Tag } from "../lib/api";
|
import { type Tag } from "../lib/api";
|
||||||
import { PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
|
import { PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
|
||||||
@@ -8,6 +8,8 @@ import { useScrollFade } from "../lib/useScrollFade";
|
|||||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||||
import { IconButton } from "./ui/IconButton";
|
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
|
// 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:
|
// 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;
|
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) {
|
function TagChip({ tag, onClick, title }: TagChipProps) {
|
||||||
const cls =
|
const name = <span className="block max-w-32 truncate">{tag.name}</span>;
|
||||||
"chip-solid shrink-0 text-2xs font-medium px-1.5 py-0.5 rounded-full max-w-36 truncate";
|
|
||||||
return onClick ? (
|
return onClick ? (
|
||||||
<button
|
<Chip variant="tag" size="xs" onClick={onClick} title={title}>
|
||||||
type="button"
|
{name}
|
||||||
onClick={onClick}
|
</Chip>
|
||||||
title={title}
|
|
||||||
className={`${cls} hover:opacity-80 transition`}
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<span className={cls}>{tag.name}</span>
|
<span className={chipClass("tag", "xs")}>{name}</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,33 +60,22 @@ function SuggestedChip({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const pct = Math.round(suggestion.confidence * 100);
|
const pct = Math.round(suggestion.confidence * 100);
|
||||||
return (
|
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">
|
<DismissibleChip
|
||||||
<button
|
variant="suggest"
|
||||||
type="button"
|
size="xs"
|
||||||
onClick={onAccept}
|
className="max-w-36 font-medium"
|
||||||
title={t("channels.tags.suggestedHint", { pct })}
|
icon={<Sparkles />}
|
||||||
aria-label={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
|
onClick={onAccept}
|
||||||
className="inline-flex items-center gap-1 min-w-0 hover:text-accent transition"
|
bodyTitle={t("channels.tags.suggestedHint", { pct })}
|
||||||
>
|
bodyLabel={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
|
||||||
<Sparkles className="w-2.5 h-2.5 shrink-0" />
|
onDismiss={onDismiss}
|
||||||
<span className="truncate">{suggestion.tag.name}</span>
|
dismissLabel={t("channels.tags.dismissSuggestion")}
|
||||||
</button>
|
>
|
||||||
<button
|
{suggestion.tag.name}
|
||||||
type="button"
|
</DismissibleChip>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
/** 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
|
* 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
|
* 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={() => {}} />
|
<SuggestedChip suggestion={s} onAccept={() => {}} onDismiss={() => {}} />
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<span ref={pillRef} className={GHOST_PILL}>
|
<span ref={pillRef} className={chipClass("suggest", "xs")}>
|
||||||
+{suggestions.length}
|
+{suggestions.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,15 +159,15 @@ function SuggestedChipsLane({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{hidden > 0 && (
|
{hidden > 0 && (
|
||||||
<button
|
<Chip
|
||||||
|
variant="suggest"
|
||||||
|
size="xs"
|
||||||
ref={overflowPop.refs.setReference}
|
ref={overflowPop.refs.setReference}
|
||||||
type="button"
|
|
||||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||||
className={`${GHOST_PILL} hover:text-accent transition`}
|
|
||||||
{...overflowPop.getReferenceProps()}
|
{...overflowPop.getReferenceProps()}
|
||||||
>
|
>
|
||||||
+{hidden}
|
+{hidden}
|
||||||
</button>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -379,8 +365,8 @@ export function ChannelTagsControl({
|
|||||||
placement: "bottom",
|
placement: "bottom",
|
||||||
});
|
});
|
||||||
|
|
||||||
const pillClass =
|
// The "+N" is a filter-look chip (neutral): the tags themselves are the accent.
|
||||||
"shrink-0 text-3xs px-1.5 py-0.5 rounded-full bg-card text-muted border border-border";
|
const pillClass = chipClass("filter", "xs");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// When `bounded`, a FIXED width keeps the cell's footprint constant under `table-layout:auto`: a
|
// 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
|
{/* 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-
|
(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). */}
|
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}
|
ref={editPop.refs.setReference}
|
||||||
type="button"
|
|
||||||
title={t("channels.row.editTags")}
|
title={t("channels.row.editTags")}
|
||||||
aria-label={t("channels.row.editTags")}
|
aria-label={t("channels.row.editTags")}
|
||||||
className={
|
icon={<Plus className="size-3" />}
|
||||||
bounded
|
className={`font-medium ${bounded ? "w-6 justify-center px-0" : ""}`}
|
||||||
? "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"
|
|
||||||
}
|
|
||||||
{...editPop.getReferenceProps()}
|
{...editPop.getReferenceProps()}
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3 shrink-0" />
|
{!bounded && t("channels.tags.add")}
|
||||||
{!bounded && <span className="whitespace-nowrap">{t("channels.tags.add")}</span>}
|
</Chip>
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Untagged + has suggestions → the ghost-chip lane (its own overflow "+N"); otherwise the
|
{/* 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
|
attached-chip lane. The two are mutually exclusive: suggestions are computed for untagged
|
||||||
@@ -457,15 +440,14 @@ export function ChannelTagsControl({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{hidden > 0 && (
|
{hidden > 0 && (
|
||||||
<button
|
<Chip
|
||||||
|
size="xs"
|
||||||
ref={overflowPop.refs.setReference}
|
ref={overflowPop.refs.setReference}
|
||||||
type="button"
|
|
||||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||||
className={`${pillClass} hover:text-fg transition`}
|
|
||||||
{...overflowPop.getReferenceProps()}
|
{...overflowPop.getReferenceProps()}
|
||||||
>
|
>
|
||||||
+{hidden}
|
+{hidden}
|
||||||
</button>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -524,12 +524,13 @@ export default function Channels() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
<TagFilterChips chips={tagFilterChips} className="flex-1" />
|
<TagFilterChips chips={tagFilterChips} className="flex-1" />
|
||||||
{tagFilter.length > 0 && (
|
{tagFilter.length > 0 && (
|
||||||
<button
|
<Button
|
||||||
|
variant="link-muted"
|
||||||
onClick={() => setFilters({ ...filters, tags: [] })}
|
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")}
|
{t("datatable.clear")}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* Suggested-tags affordance (E8·S4): a sparkle count of the visible suggestions, with a
|
{/* 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
|
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 Avatar from "./ui/Avatar";
|
||||||
import ChatThread from "./ChatThread";
|
import ChatThread from "./ChatThread";
|
||||||
import Overlay from "./ui/Overlay";
|
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
|
// 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
|
// 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">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-card/50">
|
||||||
<button
|
<Pressable
|
||||||
onClick={() => toggleMinimize(chat.partnerId)}
|
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")}
|
title={chat.minimized ? t("messages.expand") : t("messages.minimize")}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -93,21 +95,16 @@ function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
|
|||||||
className="w-7 h-7 rounded-full shrink-0 text-xs"
|
className="w-7 h-7 rounded-full shrink-0 text-xs"
|
||||||
/>
|
/>
|
||||||
<span className="font-semibold text-sm truncate">{chat.name}</span>
|
<span className="font-semibold text-sm truncate">{chat.name}</span>
|
||||||
</button>
|
</Pressable>
|
||||||
<button
|
<IconButton
|
||||||
onClick={() => toggleMinimize(chat.partnerId)}
|
onClick={() => toggleMinimize(chat.partnerId)}
|
||||||
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
|
label={chat.minimized ? t("messages.expand") : t("messages.minimize")}
|
||||||
aria-label={chat.minimized ? t("messages.expand") : t("messages.minimize")}
|
|
||||||
>
|
>
|
||||||
{chat.minimized ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
{chat.minimized ? <ChevronUp /> : <ChevronDown />}
|
||||||
</button>
|
</IconButton>
|
||||||
<button
|
<IconButton onClick={() => closeChat(chat.partnerId)} label={t("messages.close")}>
|
||||||
onClick={() => closeChat(chat.partnerId)}
|
<X />
|
||||||
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
|
</IconButton>
|
||||||
aria-label={t("messages.close")}
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{!chat.minimized && (
|
{!chat.minimized && (
|
||||||
<div className="flex flex-col h-96">
|
<div className="flex flex-col h-96">
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import FilterSelect from "./ui/FilterSelect";
|
|||||||
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
|
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
|
||||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
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
|
// 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
|
// 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. */}
|
with no error anywhere — say so next to the fields that fix it. */}
|
||||||
{!me.email_configured && <InlineNotice>{t("config.emailOff")}</InlineNotice>}
|
{!me.email_configured && <InlineNotice>{t("config.emailOff")}</InlineNotice>}
|
||||||
<Tooltip hint={t("config.testEmailHint")}>
|
<Tooltip hint={t("config.testEmailHint")}>
|
||||||
<FocusSafeButton
|
<Button
|
||||||
|
variant="glass"
|
||||||
|
icon={<Send />}
|
||||||
|
loading={testEmail.isPending}
|
||||||
|
disabled={dirty}
|
||||||
onClick={() => testEmail.mutate()}
|
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")}
|
{t("config.testEmail")}
|
||||||
</FocusSafeButton>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -233,14 +234,15 @@ export default function ConfigPanel() {
|
|||||||
{activeGroup === "issues" && (
|
{activeGroup === "issues" && (
|
||||||
<div className="mt-3 pt-3 border-t border-border/60">
|
<div className="mt-3 pt-3 border-t border-border/60">
|
||||||
<Tooltip hint={t("config.issuesTestHint")}>
|
<Tooltip hint={t("config.issuesTestHint")}>
|
||||||
<FocusSafeButton
|
<Button
|
||||||
|
variant="glass"
|
||||||
|
icon={<Plug />}
|
||||||
|
loading={testIssues.isPending}
|
||||||
|
disabled={dirty}
|
||||||
onClick={() => testIssues.mutate()}
|
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")}
|
{t("config.issuesTest")}
|
||||||
</FocusSafeButton>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
|
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -249,14 +251,15 @@ export default function ConfigPanel() {
|
|||||||
{activeGroup === "plex" && (
|
{activeGroup === "plex" && (
|
||||||
<div className="mt-3 pt-3 border-t border-border/60">
|
<div className="mt-3 pt-3 border-t border-border/60">
|
||||||
<Tooltip hint={t("config.plexTestHint")}>
|
<Tooltip hint={t("config.plexTestHint")}>
|
||||||
<FocusSafeButton
|
<Button
|
||||||
|
variant="glass"
|
||||||
|
icon={<Plug />}
|
||||||
|
loading={testPlex.isPending}
|
||||||
|
disabled={dirty}
|
||||||
onClick={() => testPlex.mutate()}
|
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")}
|
{t("config.plexTest")}
|
||||||
</FocusSafeButton>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
|
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
|
||||||
{plexResult && (
|
{plexResult && (
|
||||||
@@ -404,15 +407,14 @@ function Field({
|
|||||||
)}
|
)}
|
||||||
{showReset && !secretDisabled && (
|
{showReset && !secretDisabled && (
|
||||||
<Tooltip hint={t("config.resetHint")}>
|
<Tooltip hint={t("config.resetHint")}>
|
||||||
<button
|
<Button
|
||||||
|
variant={item.secret && pendingSecretReset ? "link" : "link-muted"}
|
||||||
|
icon={<RotateCcw className="size-3.5!" />}
|
||||||
onClick={onResetField}
|
onClick={onResetField}
|
||||||
className={`inline-flex items-center gap-1 text-xs transition ${
|
className="text-xs gap-1"
|
||||||
item.secret && pendingSecretReset ? "text-accent" : "text-muted hover:text-fg"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
|
||||||
{t("config.reset")}
|
{t("config.reset")}
|
||||||
</button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
|||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||||
import { type FeedFilters } from "../lib/api";
|
import { type FeedFilters } from "../lib/api";
|
||||||
import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter";
|
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,
|
// 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
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<Pressable
|
||||||
type="button"
|
|
||||||
ref={pop.refs.setReference}
|
ref={pop.refs.setReference}
|
||||||
{...pop.getReferenceProps()}
|
{...pop.getReferenceProps()}
|
||||||
aria-label={`${t("sidebar.datePresetLabel")}: ${summary}`}
|
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" />
|
<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" />
|
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||||
</button>
|
</Pressable>
|
||||||
|
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={pop}
|
popover={pop}
|
||||||
@@ -179,13 +180,13 @@ export default function DateFilterSelect({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{(filters.dateFrom || filters.dateTo) && (
|
{(filters.dateFrom || filters.dateTo) && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="link-muted"
|
||||||
onClick={() => setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })}
|
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")}
|
{t("sidebar.clearDates")}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -175,12 +175,9 @@ export default function DownloadCenter() {
|
|||||||
<div className="px-4 pt-2 max-w-4xl w-full mx-auto">
|
<div className="px-4 pt-2 max-w-4xl w-full mx-auto">
|
||||||
<div className="flex items-center justify-between gap-3 mb-1">
|
<div className="flex items-center justify-between gap-3 mb-1">
|
||||||
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
|
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
|
||||||
<button
|
<Button variant="ghost" size="sm" icon={<Settings2 />} onClick={() => setManage(true)}>
|
||||||
onClick={() => setManage(true)}
|
{t("downloads.page.manage")}
|
||||||
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"
|
</Button>
|
||||||
>
|
|
||||||
<Settings2 className="w-4 h-4" /> {t("downloads.page.manage")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted mb-4">{t("downloads.page.subtitle")}</p>
|
<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))}
|
onChange={([v]) => v && setProfileId(Number(v))}
|
||||||
options={profiles.map((p) => ({ value: String(p.id), label: p.name }))}
|
options={profiles.map((p) => ({ value: String(p.id), label: p.name }))}
|
||||||
/>
|
/>
|
||||||
<button
|
<Button variant="link" onClick={() => setManage(true)} className="text-xs mt-1 mb-4">
|
||||||
type="button"
|
|
||||||
onClick={() => setManage(true)}
|
|
||||||
className="text-xs text-accent hover:underline mt-1 mb-4"
|
|
||||||
>
|
|
||||||
{t("downloads.dialog.manage")}
|
{t("downloads.dialog.manage")}
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<label className="block text-sm font-medium mb-1">{t("downloads.dialog.nameLabel")}</label>
|
<label className="block text-sm font-medium mb-1">{t("downloads.dialog.nameLabel")}</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import i18n from "../i18n";
|
|||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { errorAttrs, log } from "../lib/log";
|
import { errorAttrs, log } from "../lib/log";
|
||||||
import { reloadingForNewBuild } from "../lib/staleBuild";
|
import { reloadingForNewBuild } from "../lib/staleBuild";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -46,12 +47,9 @@ export default class ErrorBoundary extends Component<Props, State> {
|
|||||||
{i18n.t("errors.boundary.title")}
|
{i18n.t("errors.boundary.title")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm mb-3">{i18n.t("errors.boundary.subtitle")}</div>
|
<div className="text-sm mb-3">{i18n.t("errors.boundary.subtitle")}</div>
|
||||||
<button
|
<Button variant="link" onClick={() => location.reload()} className="text-sm">
|
||||||
onClick={() => location.reload()}
|
|
||||||
className="text-accent hover:underline text-sm font-semibold"
|
|
||||||
>
|
|
||||||
{i18n.t("errors.boundary.reload")}
|
{i18n.t("errors.boundary.reload")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type ReactNode } from "react";
|
|||||||
import ChannelLink from "./ChannelLink";
|
import ChannelLink from "./ChannelLink";
|
||||||
import NavGrid from "./ui/NavGrid";
|
import NavGrid from "./ui/NavGrid";
|
||||||
import { navItemAttrs } from "../lib/listNav";
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
|
||||||
// The presentation shared by the channel manager's "excluded channel" tabs — Hidden and Blocked.
|
// 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
|
// 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}
|
onView={onView ? () => onView(c) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button variant="outline" size="xs" icon={actionIcon} onClick={() => onAction(c.id)}>
|
||||||
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}
|
|
||||||
{actionLabel}
|
{actionLabel}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</NavGrid>
|
</NavGrid>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { PlexPosterCard } from "./plexCards";
|
|||||||
import { useNavigationActions } from "./navigationContext";
|
import { useNavigationActions } from "./navigationContext";
|
||||||
import { usePlexActions } from "./plexContext";
|
import { usePlexActions } from "./plexContext";
|
||||||
import { navItemAttrs } from "../lib/listNav";
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
|
import { Pressable } from "./ui/Pressable";
|
||||||
|
|
||||||
// The Home surface (E24) — "Mit nézzek most?".
|
// The Home surface (E24) — "Mit nézzek most?".
|
||||||
//
|
//
|
||||||
@@ -244,8 +245,7 @@ function ShelfTile({
|
|||||||
? t("home.shelf.unwatchedCount", { count: count ?? 0 })
|
? t("home.shelf.unwatchedCount", { count: count ?? 0 })
|
||||||
: t("home.shelf.itemCount", { count: count ?? 0 });
|
: t("home.shelf.itemCount", { count: count ?? 0 });
|
||||||
return (
|
return (
|
||||||
<button
|
<Pressable
|
||||||
type="button"
|
|
||||||
onClick={() => card.next && onPlay(card)}
|
onClick={() => card.next && onPlay(card)}
|
||||||
{...menuProps?.(card)}
|
{...menuProps?.(card)}
|
||||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
{...(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),
|
// Shift+F10, the only keyboard route to the menu. Inert to the click (the handler guards),
|
||||||
// alive to all three input paths.
|
// alive to all three input paths.
|
||||||
aria-disabled={!card.next}
|
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">
|
<span className="block aspect-video bg-card overflow-hidden">
|
||||||
{card.thumb ? (
|
{card.thumb ? (
|
||||||
@@ -268,7 +268,7 @@ function ShelfTile({
|
|||||||
<span className="block text-xs font-medium truncate">{card.title}</span>
|
<span className="block text-xs font-medium truncate">{card.title}</span>
|
||||||
<span className="block text-2xs text-muted">{countLabel}</span>
|
<span className="block text-2xs text-muted">{countLabel}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,8 +307,7 @@ function Hero({
|
|||||||
{...pinMenu.triggerProps(card)}
|
{...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]"
|
className="mb-6 rounded-xl border border-border bg-surface p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]"
|
||||||
>
|
>
|
||||||
<button
|
<Pressable
|
||||||
type="button"
|
|
||||||
onClick={() => onPlay(card)}
|
onClick={() => onPlay(card)}
|
||||||
className={`relative block w-full overflow-hidden rounded-lg border border-border bg-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"
|
card.aspect === "poster" ? "aspect-[2/3] max-w-[12rem]" : "aspect-video"
|
||||||
@@ -331,7 +330,7 @@ function Hero({
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</Pressable>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-col">
|
<div className="flex min-w-0 flex-col">
|
||||||
<p className="text-2xs font-semibold uppercase tracking-wider text-accent">{kicker}</p>
|
<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")}
|
{configured ? t("messages.unlock") : t("messages.setUp")}
|
||||||
</Button>
|
</Button>
|
||||||
{configured && (
|
{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")}
|
{t("messages.forgotPass")}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!configured && !compact && <p className="text-xs text-muted">{t("messages.setupWarn")}</p>}
|
{!configured && !compact && <p className="text-xs text-muted">{t("messages.setupWarn")}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Check, Globe } from "lucide-react";
|
|||||||
import { LANGUAGES, type LangCode } from "../i18n";
|
import { LANGUAGES, type LangCode } from "../i18n";
|
||||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||||
|
import { Pressable } from "./ui/Pressable";
|
||||||
|
|
||||||
// Compact language picker (globe + current code). Presentational: the parent decides what
|
// Compact language picker (globe + current code). Presentational: the parent decides what
|
||||||
// changing the language does (set it; persist server-side when signed in).
|
// changing the language does (set it; persist server-side when signed in).
|
||||||
@@ -42,15 +43,16 @@ export default function LanguageSwitcher({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<Pressable
|
||||||
ref={pop.refs.setReference}
|
ref={pop.refs.setReference}
|
||||||
{...pop.getReferenceProps()}
|
{...pop.getReferenceProps()}
|
||||||
title={current.label}
|
title={current.label}
|
||||||
aria-label={current.label}
|
aria-label={current.label}
|
||||||
|
layer
|
||||||
className={
|
className={
|
||||||
rail
|
rail
|
||||||
? "relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
? "relative p-2 rounded-lg text-muted hover:text-fg 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"
|
: "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"} />
|
<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>
|
<span className="text-xs font-semibold uppercase">{current.code}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Pressable>
|
||||||
|
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={pop}
|
popover={pop}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
|
|||||||
import type { Level } from "../lib/log";
|
import type { Level } from "../lib/log";
|
||||||
import { navItemAttrs } from "../lib/listNav";
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
import { LEVEL_TAG, logClock } from "./logLineParts";
|
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
|
// 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
|
// 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"
|
className="border-b border-border/50 last:border-b-0"
|
||||||
{...navItemAttrs(navIndex, `${event} ${msg}`)}
|
{...navItemAttrs(navIndex, `${event} ${msg}`)}
|
||||||
>
|
>
|
||||||
<button
|
<Pressable
|
||||||
type="button"
|
layer={!open}
|
||||||
data-nav-open
|
data-nav-open
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
title={title}
|
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" : ""
|
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
|
{/* 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. */}
|
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>
|
<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>}
|
{open && <div className="bg-accent/5 px-3 py-2 text-muted">{children}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { inputCls } from "./ui/form";
|
|||||||
import ChatThread from "./ChatThread";
|
import ChatThread from "./ChatThread";
|
||||||
import NavGrid from "./ui/NavGrid";
|
import NavGrid from "./ui/NavGrid";
|
||||||
import { navItemAttrs } from "../lib/listNav";
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
|
import { Pressable } from "./ui/Pressable";
|
||||||
|
|
||||||
type View =
|
type View =
|
||||||
| "list"
|
| "list"
|
||||||
@@ -158,10 +159,10 @@ function ConversationList({
|
|||||||
{...navItemAttrs(i, c.partner.name)}
|
{...navItemAttrs(i, c.partner.name)}
|
||||||
className="group flex items-center gap-1 rounded-xl hover:bg-card transition"
|
className="group flex items-center gap-1 rounded-xl hover:bg-card transition"
|
||||||
>
|
>
|
||||||
<button
|
<Pressable
|
||||||
data-nav-open=""
|
data-nav-open=""
|
||||||
onClick={() => onOpen(c)}
|
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
|
<Avatar
|
||||||
src={c.partner.avatar_url}
|
src={c.partner.avatar_url}
|
||||||
@@ -192,16 +193,16 @@ function ConversationList({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Pressable>
|
||||||
{!isSystem && ready && (
|
{!isSystem && ready && (
|
||||||
<button
|
<IconButton
|
||||||
|
size="md"
|
||||||
onClick={() => openChat(c.partner)}
|
onClick={() => openChat(c.partner)}
|
||||||
title={t("messages.popOut")}
|
label={t("messages.popOut")}
|
||||||
aria-label={t("messages.popOut")}
|
className="mr-1.5 reveal"
|
||||||
className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted reveal hover:text-accent hover:bg-bg transition"
|
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink />
|
||||||
</button>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -253,10 +254,11 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<button
|
<Pressable
|
||||||
key={u.id}
|
key={u.id}
|
||||||
|
layer
|
||||||
onClick={() => onPick(u)}
|
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
|
<Avatar
|
||||||
src={u.avatar_url}
|
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"
|
className="w-9 h-9 rounded-full shrink-0 text-sm"
|
||||||
/>
|
/>
|
||||||
<span className="truncate font-medium">{u.name}</span>
|
<span className="truncate font-medium">{u.name}</span>
|
||||||
</button>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -317,14 +319,13 @@ function PageThread({
|
|||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{!isSystem && ready && (
|
{!isSystem && ready && (
|
||||||
<button
|
<IconButton
|
||||||
|
size="md"
|
||||||
onClick={() => openChat({ id: partnerId, name, avatar_url: seedAvatar ?? null })}
|
onClick={() => openChat({ id: partnerId, name, avatar_url: seedAvatar ?? null })}
|
||||||
title={t("messages.popOut")}
|
label={t("messages.popOut")}
|
||||||
aria-label={t("messages.popOut")}
|
|
||||||
className="p-1.5 rounded-lg text-muted hover:text-accent hover:bg-card transition"
|
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink />
|
||||||
</button>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ChatThread meId={meId} partnerId={partnerId} isSystem={isSystem} />
|
<ChatThread meId={meId} partnerId={partnerId} isSystem={isSystem} />
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Toaster from "./ui/Toaster";
|
|||||||
import { useOverlayOrder } from "./ui/overlayOrder";
|
import { useOverlayOrder } from "./ui/overlayOrder";
|
||||||
import { useLayer } from "../lib/layerStack";
|
import { useLayer } from "../lib/layerStack";
|
||||||
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
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
|
// 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
|
// 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}
|
aria-labelledby={titleId}
|
||||||
className="glass relative w-[min(94vw,460px)] rounded-2xl p-7 text-center outline-none animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]"
|
className="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}
|
onClick={close}
|
||||||
className="absolute top-3 right-3 p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
|
className="absolute top-3 right-3"
|
||||||
aria-label={t("onboarding.close")}
|
label={t("onboarding.close")}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X />
|
||||||
</button>
|
</IconButton>
|
||||||
|
|
||||||
{step === "read" && (
|
{step === "read" && (
|
||||||
<>
|
<>
|
||||||
@@ -196,12 +198,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
|||||||
</p>
|
</p>
|
||||||
<ConsentHeadsUp />
|
<ConsentHeadsUp />
|
||||||
<div className="mt-5 flex flex-col gap-2">
|
<div className="mt-5 flex flex-col gap-2">
|
||||||
<button
|
<Button size="lg" icon={<Youtube />} onClick={() => beginGrant("read")}>
|
||||||
onClick={() => beginGrant("read")}
|
{t("onboarding.read.connect")}
|
||||||
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold btn-primary transition"
|
</Button>
|
||||||
>
|
|
||||||
<Youtube className="w-4 h-4" /> {t("onboarding.read.connect")}
|
|
||||||
</button>
|
|
||||||
<Button variant="ghost" size="md" onClick={close}>
|
<Button variant="ghost" size="md" onClick={close}>
|
||||||
{t("onboarding.read.skip")}
|
{t("onboarding.read.skip")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -254,21 +253,21 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{importSubs.isError && (
|
{importSubs.isError && (
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
icon={<RefreshCw />}
|
||||||
|
loading={importSubs.isPending}
|
||||||
onClick={() => importSubs.mutate()}
|
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")}
|
{t("onboarding.write.retryImport")}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<ConsentHeadsUp />
|
<ConsentHeadsUp />
|
||||||
<div className="mt-5 flex flex-col gap-2">
|
<div className="mt-5 flex flex-col gap-2">
|
||||||
<button
|
<Button size="lg" icon={<ShieldCheck />} onClick={() => beginGrant("write")}>
|
||||||
onClick={() => beginGrant("write")}
|
{t("onboarding.write.enableUnsubscribe")}
|
||||||
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold btn-primary transition"
|
</Button>
|
||||||
>
|
|
||||||
<ShieldCheck className="w-4 h-4" /> {t("onboarding.write.enableUnsubscribe")}
|
|
||||||
</button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="md"
|
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">
|
<p className="text-sm text-muted leading-relaxed mt-2 mb-5">
|
||||||
{t("onboarding.done.body")}
|
{t("onboarding.done.body")}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button
|
||||||
|
size="lg"
|
||||||
|
fullWidth
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
endOnboarding(false);
|
endOnboarding(false);
|
||||||
onClose();
|
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")}
|
{t("onboarding.done.done")}
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Command } from "lucide-react";
|
import { Command } from "lucide-react";
|
||||||
|
|
||||||
import { SHORTCUTS, formatChord, openPalette } from "../lib/keymap";
|
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
|
// 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
|
// `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 { t } = useTranslation();
|
||||||
const cap = useCap();
|
const cap = useCap();
|
||||||
return (
|
return (
|
||||||
<button
|
<Pressable
|
||||||
type="button"
|
|
||||||
onClick={openPalette}
|
onClick={openPalette}
|
||||||
aria-label={`${t("palette.open")} (${cap})`}
|
aria-label={`${t("palette.open")} (${cap})`}
|
||||||
title={`${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"
|
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}
|
{cap}
|
||||||
</button>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,8 +47,7 @@ export function PaletteLauncher() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const cap = useCap();
|
const cap = useCap();
|
||||||
return (
|
return (
|
||||||
<button
|
<Pressable
|
||||||
type="button"
|
|
||||||
onClick={openPalette}
|
onClick={openPalette}
|
||||||
title={t("palette.open")}
|
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"
|
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">
|
<span className="rounded-md border border-border px-1.5 py-0.5 font-mono text-3xs @max-[640px]/appmain:hidden">
|
||||||
{cap}
|
{cap}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 {
|
import {
|
||||||
closestCenter,
|
closestCenter,
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -23,6 +23,7 @@ import UndoToolbar from "./ui/UndoToolbar";
|
|||||||
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
|
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
|
||||||
import { DragHandle } from "./ui/DragHandle";
|
import { DragHandle } from "./ui/DragHandle";
|
||||||
import { Pressable } from "./ui/Pressable";
|
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
|
// 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
|
// 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">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{captured.map((f) => (
|
{captured.map((f) => (
|
||||||
<span
|
<DismissibleChip
|
||||||
key={f.key}
|
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}
|
{f.label}
|
||||||
<button
|
</DismissibleChip>
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import {
|
|||||||
formatViews,
|
formatViews,
|
||||||
relativeTime,
|
relativeTime,
|
||||||
} from "../lib/format";
|
} from "../lib/format";
|
||||||
import { renderDescription } from "../lib/descriptionLinks";
|
import { renderDescription } from "./descriptionLinks";
|
||||||
import { fullscreenElement } from "../lib/fullscreen";
|
import { fullscreenElement } from "../lib/fullscreen";
|
||||||
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
||||||
import { useBackToClose } from "../lib/history";
|
import { useBackToClose } from "../lib/history";
|
||||||
@@ -748,9 +748,9 @@ export default function PlayerModal({
|
|||||||
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
|
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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")}
|
{t("player.openOnYoutube")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Info } from "lucide-react";
|
|||||||
|
|
||||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
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
|
// 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).
|
// 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" />
|
<Info className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||||
<span>{t("playlists.limits.hint")}</span>
|
<span>{t("playlists.limits.hint")}</span>
|
||||||
<button
|
<Button variant="link" ref={pop.refs.setReference} {...pop.getReferenceProps()}>
|
||||||
type="button"
|
|
||||||
ref={pop.refs.setReference}
|
|
||||||
{...pop.getReferenceProps()}
|
|
||||||
className="text-accent hover:underline"
|
|
||||||
>
|
|
||||||
{t("playlists.limits.why")}
|
{t("playlists.limits.why")}
|
||||||
</button>
|
</Button>
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={pop}
|
popover={pop}
|
||||||
ariaLabel={t("playlists.limits.title")}
|
ariaLabel={t("playlists.limits.title")}
|
||||||
|
|||||||
@@ -27,11 +27,9 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
History,
|
History,
|
||||||
GripVertical,
|
|
||||||
ListPlus,
|
ListPlus,
|
||||||
Pencil,
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
RefreshCw,
|
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
@@ -68,8 +66,8 @@ import { ColumnHead } from "./ui/tableHeader";
|
|||||||
import { alignClass } from "./ui/tableAlign";
|
import { alignClass } from "./ui/tableAlign";
|
||||||
import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns";
|
import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns";
|
||||||
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
|
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
|
||||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
|
||||||
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
|
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
|
// 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.
|
// 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
|
// 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
|
// attributes/listeners. Disabled (not hidden) under a view, so the affordance stays
|
||||||
// put and can explain itself instead of silently doing nothing.
|
// put and can explain itself instead of silently doing nothing.
|
||||||
<button
|
<DragHandle
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...(dragDisabled ? {} : listeners)}
|
{...(dragDisabled ? {} : listeners)}
|
||||||
// `disabled` would drop it from the tab order AND suppress the tooltip, so the
|
unavailable={dragDisabled}
|
||||||
// reason reached nobody. aria-disabled keeps it focusable and the name carries the why.
|
label={dragDisabled ? dragHint : t("playlists.dragHandleOf", { title: video.title })}
|
||||||
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>
|
|
||||||
) : (
|
) : (
|
||||||
col.render(video)
|
col.render(video)
|
||||||
)}
|
)}
|
||||||
@@ -896,17 +885,15 @@ export default function Playlists() {
|
|||||||
<>
|
<>
|
||||||
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
||||||
{editable && (
|
{editable && (
|
||||||
<button
|
<IconButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRenameValue(detail.name);
|
setRenameValue(detail.name);
|
||||||
setRenaming(true);
|
setRenaming(true);
|
||||||
}}
|
}}
|
||||||
title={t("playlists.rename")}
|
label={t("playlists.rename")}
|
||||||
aria-label={t("playlists.rename")}
|
|
||||||
className="shrink-0 text-muted hover:text-fg"
|
|
||||||
>
|
>
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil />
|
||||||
</button>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
{detail.yt_playlist_id && (
|
{detail.yt_playlist_id && (
|
||||||
<a
|
<a
|
||||||
@@ -932,37 +919,25 @@ export default function Playlists() {
|
|||||||
{t("playlists.unsynced")}
|
{t("playlists.unsynced")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<FocusSafeButton
|
<Button
|
||||||
|
size="sm"
|
||||||
onClick={() => shown.length && openPlayer(shown[0]!.id)}
|
onClick={() => shown.length && openPlayer(shown[0]!.id)}
|
||||||
disabled={!shown.length}
|
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")}
|
{t("playlists.playAll")}
|
||||||
</FocusSafeButton>
|
</Button>
|
||||||
{editable && canWrite && (
|
{editable && canWrite && (
|
||||||
<FocusSafeButton
|
<IconButton
|
||||||
|
variant={detail.dirty ? "outline-accent" : "outline"}
|
||||||
onClick={pushToYoutube}
|
onClick={pushToYoutube}
|
||||||
disabled={pushing}
|
loading={pushing}
|
||||||
title={
|
label={
|
||||||
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
|
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 ? (
|
{linked ? <Youtube /> : <Upload />}
|
||||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
</IconButton>
|
||||||
) : linked ? (
|
|
||||||
<Youtube className="w-3.5 h-3.5" />
|
|
||||||
) : (
|
|
||||||
<Upload className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
</FocusSafeButton>
|
|
||||||
)}
|
)}
|
||||||
{linked && detail.dirty && (
|
{linked && detail.dirty && (
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -1009,12 +984,13 @@ export default function Playlists() {
|
|||||||
{t("playlists.groupByChannel")}
|
{t("playlists.groupByChannel")}
|
||||||
</label>
|
</label>
|
||||||
{viewActive && (
|
{viewActive && (
|
||||||
<button
|
<Button
|
||||||
|
variant="link-muted"
|
||||||
onClick={clearView}
|
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")}
|
{t("playlists.clearView")}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canSaveOrder && (
|
{canSaveOrder && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import { useMe } from "../lib/useMe";
|
|||||||
import { playlistName } from "../lib/playlistName";
|
import { playlistName } from "../lib/playlistName";
|
||||||
import { usePlaylistActions } from "./usePlaylistActions";
|
import { usePlaylistActions } from "./usePlaylistActions";
|
||||||
import PlaylistLimitsHint from "./PlaylistLimitsHint";
|
import PlaylistLimitsHint from "./PlaylistLimitsHint";
|
||||||
|
import { IconButton } from "./ui/IconButton";
|
||||||
|
import { Pressable } from "./ui/Pressable";
|
||||||
|
|
||||||
type PlSort = {
|
type PlSort = {
|
||||||
key: "custom" | "name" | "count" | "duration" | "recent";
|
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 })
|
next && setPlSort({ ...plSort, key: next.key as PlSort["key"], dir: next.dir })
|
||||||
}
|
}
|
||||||
trailing={
|
trailing={
|
||||||
<button
|
<IconButton
|
||||||
|
variant="outline"
|
||||||
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
|
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
|
||||||
title={t("playlists.dirtyFirst")}
|
label={t("playlists.dirtyFirst")}
|
||||||
aria-pressed={plSort.dirtyFirst}
|
pressed={plSort.dirtyFirst}
|
||||||
className="pill shrink-0 rounded-lg border p-1.5"
|
|
||||||
>
|
>
|
||||||
<Pin className="h-4 w-4" />
|
<Pin />
|
||||||
</button>
|
</IconButton>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -292,13 +294,12 @@ export default function PlaylistsRail({
|
|||||||
<>
|
<>
|
||||||
{playlists.length === 0 &&
|
{playlists.length === 0 &&
|
||||||
(listQuery.isError && !listQuery.data ? (
|
(listQuery.isError && !listQuery.data ? (
|
||||||
<button
|
<Pressable
|
||||||
type="button"
|
|
||||||
onClick={() => listQuery.refetch()}
|
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")}
|
{t("common.loadError")} — {t("common.retry")}
|
||||||
</button>
|
</Pressable>
|
||||||
) : listQuery.isLoading ? (
|
) : listQuery.isLoading ? (
|
||||||
<div className="text-xs text-muted px-1 py-1">{t("common.loading")}</div>
|
<div className="text-xs text-muted px-1 py-1">{t("common.loading")}</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -342,7 +343,7 @@ export default function PlaylistsRail({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<Pressable
|
||||||
key={pl.id}
|
key={pl.id}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
if (pl.id === selectedId) selectedRef.current = el;
|
if (pl.id === selectedId) selectedRef.current = el;
|
||||||
@@ -354,10 +355,9 @@ export default function PlaylistsRail({
|
|||||||
}}
|
}}
|
||||||
onClick={() => setSelectedId(pl.id)}
|
onClick={() => setSelectedId(pl.id)}
|
||||||
{...railMenu.triggerProps(pl)}
|
{...railMenu.triggerProps(pl)}
|
||||||
className={`${ROW_BASE} text-left ${
|
layer={pl.id !== selectedId}
|
||||||
pl.id === selectedId
|
className={`${ROW_BASE} ${
|
||||||
? "bg-accent/15 border-accent"
|
pl.id === selectedId ? "bg-accent/15 border-accent" : "border-transparent"
|
||||||
: "border-transparent hover:bg-card/60"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="w-8.5 h-8.5 rounded-md bg-card overflow-hidden shrink-0">
|
<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)}`}
|
` · ${formatRuntime(pl.total_duration_seconds)}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Pressable>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ import { useContextMenu } from "./ui/useContextMenu";
|
|||||||
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
|
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
|
||||||
import { dispatch, useKeyScope } from "../lib/keymap";
|
import { dispatch, useKeyScope } from "../lib/keymap";
|
||||||
import NavGrid from "./ui/NavGrid";
|
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.
|
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
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"
|
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. */}
|
{/* The photo + name open the actor's filmography page; the ✕ removes the filter. */}
|
||||||
<button
|
<Pressable
|
||||||
onClick={() => onOpenPerson(p.name)}
|
onClick={() => onOpenPerson(p.name)}
|
||||||
title={t("plex.info.viewPerson", { name: 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 ? (
|
{p.photo ? (
|
||||||
<img
|
<img
|
||||||
@@ -578,15 +581,14 @@ export default function PlexBrowse() {
|
|||||||
{t("plex.people.count", { count: p.count })}
|
{t("plex.people.count", { count: p.count })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Pressable>
|
||||||
<button
|
<IconButton
|
||||||
onClick={() => removeActor(p.name)}
|
onClick={() => removeActor(p.name)}
|
||||||
title={t("plex.people.remove")}
|
label={t("plex.people.remove")}
|
||||||
aria-label={t("plex.people.remove")}
|
className="ml-1"
|
||||||
className="ml-1 p-1 rounded-full text-muted hover:text-fg hover:bg-card transition"
|
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X />
|
||||||
</button>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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".
|
// 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">
|
<div className="flex flex-wrap items-center gap-3 text-sm text-muted">
|
||||||
<span>{t("plex.noMatchesFiltered", { count: plexFilterCount(filters) })}</span>
|
<span>{t("plex.noMatchesFiltered", { count: plexFilterCount(filters) })}</span>
|
||||||
<button
|
<Chip onClick={() => setFilters(EMPTY_PLEX_FILTERS)} className="font-medium">
|
||||||
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"
|
|
||||||
>
|
|
||||||
{t("plex.filter.clearAll")}
|
{t("plex.filter.clearAll")}
|
||||||
</button>
|
</Chip>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
|
<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 { Button } from "./ui/Button";
|
||||||
import Modal from "./ui/Modal";
|
import Modal from "./ui/Modal";
|
||||||
import { useConfirm } from "./confirmContext";
|
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
|
// 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
|
// 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}
|
key={c.id}
|
||||||
className="glass-card flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm"
|
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)}
|
onClick={() => toggle(c.id)}
|
||||||
disabled={!!busy}
|
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" : ""
|
inIt ? "text-accent" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -209,14 +211,15 @@ export default function PlexCollectionEditor({
|
|||||||
<span className="shrink-0 text-xs text-muted">
|
<span className="shrink-0 text-xs text-muted">
|
||||||
{t("plex.collEditor.count", { count: c.child_count ?? 0 })}
|
{t("plex.collEditor.count", { count: c.child_count ?? 0 })}
|
||||||
</span>
|
</span>
|
||||||
</FocusSafeButton>
|
</Pressable>
|
||||||
<FocusSafeButton
|
<Button
|
||||||
|
variant="outline-danger"
|
||||||
|
size="xs"
|
||||||
onClick={() => removeCollection(c.id, c.title)}
|
onClick={() => removeCollection(c.id, c.title)}
|
||||||
disabled={!!busy}
|
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")}
|
{t("plex.collEditor.delete")}
|
||||||
</FocusSafeButton>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -243,13 +246,14 @@ export default function PlexCollectionEditor({
|
|||||||
<span className="shrink-0 text-xs text-muted">
|
<span className="shrink-0 text-xs text-muted">
|
||||||
{t("plex.collEditor.count", { count: c.child_count ?? 0 })}
|
{t("plex.collEditor.count", { count: c.child_count ?? 0 })}
|
||||||
</span>
|
</span>
|
||||||
<FocusSafeButton
|
<Button
|
||||||
|
variant="glass"
|
||||||
|
size="xs"
|
||||||
onClick={() => markEditable(c.id)}
|
onClick={() => markEditable(c.id)}
|
||||||
disabled={!!busy}
|
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")}
|
{t("plex.collEditor.makeEditable")}
|
||||||
</FocusSafeButton>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import PlexLibraryPicker from "./PlexLibraryPicker";
|
|||||||
import { toggleWatchState } from "../lib/plexWatch";
|
import { toggleWatchState } from "../lib/plexWatch";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
import NavGrid from "./ui/NavGrid";
|
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
|
// 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,
|
// 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}
|
{d.summary}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button
|
||||||
|
variant="link"
|
||||||
|
aria-expanded={expanded}
|
||||||
onClick={() => setExpanded((v) => !v)}
|
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")}
|
{expanded ? t("plex.collection.less") : t("plex.collection.more")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -412,15 +415,15 @@ export default function PlexCollectionView({
|
|||||||
menuProps={pinMenu.triggerProps}
|
menuProps={pinMenu.triggerProps}
|
||||||
/>
|
/>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<FocusSafeButton
|
<IconButton
|
||||||
|
variant="media"
|
||||||
onClick={() => doRemove(c)}
|
onClick={() => doRemove(c)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
title={t("plex.collection.removeItem")}
|
label={t("plex.collection.removeItem")}
|
||||||
aria-label={t("plex.collection.removeItem")}
|
className="absolute right-1.5 top-1.5 z-base"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X />
|
||||||
</FocusSafeButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -475,13 +478,9 @@ function AddItemPanel({
|
|||||||
aria-label={t("plex.collection.addSearch")}
|
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"
|
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
|
<IconButton size="md" onClick={onClose} label={t("common.close")}>
|
||||||
onClick={onClose}
|
<X />
|
||||||
aria-label={t("common.close")}
|
</IconButton>
|
||||||
className="glass-hover grid h-8 w-8 place-items-center rounded-lg"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<PlexLibraryPicker
|
<PlexLibraryPicker
|
||||||
scope={scope}
|
scope={scope}
|
||||||
@@ -490,14 +489,15 @@ function AddItemPanel({
|
|||||||
renderAction={(c) => {
|
renderAction={(c) => {
|
||||||
const inColl = existing.has(c.id) || added.has(c.id);
|
const inColl = existing.has(c.id) || added.has(c.id);
|
||||||
return (
|
return (
|
||||||
<FocusSafeButton
|
<Button
|
||||||
|
variant="glass"
|
||||||
|
size="xs"
|
||||||
|
icon={inColl ? <Check /> : <Plus />}
|
||||||
onClick={() => add(c)}
|
onClick={() => add(c)}
|
||||||
disabled={inColl}
|
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")}
|
{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 { useContextMenu } from "./ui/useContextMenu";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
import NavGrid from "./ui/NavGrid";
|
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
|
// 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
|
// /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")}
|
ariaLabel={t("plex.discover.surprise")}
|
||||||
cards={surprise}
|
cards={surprise}
|
||||||
action={
|
action={
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
|
||||||
onClick={() => surpriseQ.refetch()}
|
onClick={() => surpriseQ.refetch()}
|
||||||
aria-label={t("plex.discover.reshuffle")}
|
loading={surpriseQ.isFetching}
|
||||||
title={t("plex.discover.reshuffle")}
|
label={t("plex.discover.reshuffle")}
|
||||||
className="rounded-full p-1 text-muted transition hover:text-accent"
|
|
||||||
>
|
>
|
||||||
<RotateCcw className={`h-3.5 w-3.5 ${surpriseQ.isFetching ? "animate-spin" : ""}`} />
|
<RotateCcw />
|
||||||
</button>
|
</IconButton>
|
||||||
}
|
}
|
||||||
{...railProps}
|
{...railProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ import {
|
|||||||
anyBlockingLayerAbove,
|
anyBlockingLayerAbove,
|
||||||
escapeHandled,
|
escapeHandled,
|
||||||
} from "../lib/layerStack";
|
} 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.
|
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
||||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
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">
|
<div className="flex items-center gap-3 text-white">
|
||||||
<button
|
<IconButton
|
||||||
|
variant="overlay"
|
||||||
|
size="player"
|
||||||
|
onMedia
|
||||||
onClick={handleBack}
|
onClick={handleBack}
|
||||||
className="p-1.5 rounded-lg hover:bg-white/15"
|
label={t("plex.player.back")}
|
||||||
title={t("plex.player.back")}
|
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-5 h-5" />
|
<ArrowLeft />
|
||||||
</button>
|
</IconButton>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-semibold truncate">
|
<div className="font-semibold truncate">
|
||||||
{detail?.kind === "episode" && detail.show_title ? detail.show_title : detail?.title}
|
{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) */}
|
{/* Skip intro / credits (with the auto-skip countdown fill, if armed) */}
|
||||||
{activeMarker && (
|
{activeMarker && (
|
||||||
<button
|
<Button
|
||||||
|
variant="media"
|
||||||
onClick={() => doSkip(activeMarker)}
|
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"
|
uiVisible ? "opacity-100" : "opacity-80"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -1295,7 +1301,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
? t("plex.player.skipIntro")
|
? t("plex.player.skipIntro")
|
||||||
: t("plex.player.skipCredits")}
|
: t("plex.player.skipCredits")}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bottom controls */}
|
{/* 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. */}
|
{/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. */}
|
||||||
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
|
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<IconButton
|
||||||
|
variant="overlay"
|
||||||
|
size="player"
|
||||||
|
onMedia
|
||||||
ref={tracksTriggerRef}
|
ref={tracksTriggerRef}
|
||||||
onClick={() => setTracksOpen((o) => !o)}
|
onClick={() => setTracksOpen((o) => !o)}
|
||||||
aria-label={t("plex.player.tracks")}
|
aria-expanded={tracksOpen}
|
||||||
className={`p-1 hover:text-accent ${tracksOpen ? "text-accent" : ""}`}
|
label={t("plex.player.tracks")}
|
||||||
>
|
>
|
||||||
<Languages className="w-5 h-5" />
|
<Languages />
|
||||||
</button>
|
</IconButton>
|
||||||
{tracksOpen && (
|
{tracksOpen && (
|
||||||
<div
|
<div
|
||||||
ref={setTracksNode}
|
ref={setTracksNode}
|
||||||
@@ -1496,42 +1505,42 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
<>
|
<>
|
||||||
<PanelHead>{t("plex.player.audio")}</PanelHead>
|
<PanelHead>{t("plex.player.audio")}</PanelHead>
|
||||||
{detail.audio_streams.map((a) => (
|
{detail.audio_streams.map((a) => (
|
||||||
<button
|
<Pressable
|
||||||
key={a.ord}
|
key={a.ord}
|
||||||
onClick={() => changeAudio(a.ord)}
|
onClick={() => changeAudio(a.ord)}
|
||||||
aria-pressed={(audioOrd ?? 0) === 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"
|
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{a.label}
|
{a.label}
|
||||||
</button>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{textSubs.length > 0 && (
|
{textSubs.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
|
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
|
||||||
<button
|
<Pressable
|
||||||
onClick={() => changeSubtitle(null)}
|
onClick={() => changeSubtitle(null)}
|
||||||
aria-pressed={subOrd == 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"
|
subOrd == null ? "text-accent" : "text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t("plex.player.subOff")}
|
{t("plex.player.subOff")}
|
||||||
</button>
|
</Pressable>
|
||||||
{textSubs.map((s) => (
|
{textSubs.map((s) => (
|
||||||
<button
|
<Pressable
|
||||||
key={s.ord}
|
key={s.ord}
|
||||||
onClick={() => changeSubtitle(s.ord)}
|
onClick={() => changeSubtitle(s.ord)}
|
||||||
aria-pressed={subOrd === 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"
|
subOrd === s.ord ? "text-accent" : "text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{s.label}
|
{s.label}
|
||||||
</button>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1543,14 +1552,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
{/* Gear = tuning, split into tabs (Sync / Playback / Subtitle). */}
|
{/* Gear = tuning, split into tabs (Sync / Playback / Subtitle). */}
|
||||||
{detail && (
|
{detail && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<IconButton
|
||||||
|
variant="overlay"
|
||||||
|
size="player"
|
||||||
|
onMedia
|
||||||
ref={menuTriggerRef}
|
ref={menuTriggerRef}
|
||||||
onClick={() => setMenuOpen((o) => !o)}
|
onClick={() => setMenuOpen((o) => !o)}
|
||||||
aria-label={t("plex.player.settings")}
|
aria-expanded={menuOpen}
|
||||||
className={`p-1 hover:text-accent ${menuOpen ? "text-accent" : ""}`}
|
label={t("plex.player.settings")}
|
||||||
>
|
>
|
||||||
<Settings className="w-5 h-5" />
|
<Settings />
|
||||||
</button>
|
</IconButton>
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<PlexPlayerSettings
|
<PlexPlayerSettings
|
||||||
menuRef={menuRef}
|
menuRef={menuRef}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { Slider, Toggle } from "./plexPlayerControls";
|
import { Slider, Toggle } from "./plexPlayerControls";
|
||||||
import { clockParts, type PlexPlayerPrefs } from "../lib/plexPlayerPrefs";
|
import { clockParts, type PlexPlayerPrefs } from "../lib/plexPlayerPrefs";
|
||||||
|
import { Pressable } from "./ui/Pressable";
|
||||||
|
|
||||||
type SettingsTab = "sync" | "playback" | "subtitle" | "clock";
|
type SettingsTab = "sync" | "playback" | "subtitle" | "clock";
|
||||||
|
|
||||||
@@ -44,15 +45,17 @@ export function PlexPlayerSettings(props: {
|
|||||||
>
|
>
|
||||||
<div className="mb-1.5 flex gap-1">
|
<div className="mb-1.5 flex gap-1">
|
||||||
{settingsTabs.map((tab) => (
|
{settingsTabs.map((tab) => (
|
||||||
<button
|
<Pressable
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
|
align="center"
|
||||||
|
aria-pressed={activeTab === tab.key}
|
||||||
onClick={() => setSettingsTab(tab.key)}
|
onClick={() => setSettingsTab(tab.key)}
|
||||||
className={`flex-1 whitespace-nowrap rounded px-2 py-1 text-2xs ${
|
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"
|
activeTab === tab.key ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -253,8 +256,10 @@ export function PlexPlayerSettings(props: {
|
|||||||
{prefs.clockDate && (
|
{prefs.clockDate && (
|
||||||
<div className="flex gap-1 px-1 py-1">
|
<div className="flex gap-1 px-1 py-1">
|
||||||
{(["hu", "en"] as const).map((st) => (
|
{(["hu", "en"] as const).map((st) => (
|
||||||
<button
|
<Pressable
|
||||||
key={st}
|
key={st}
|
||||||
|
align="center"
|
||||||
|
aria-pressed={prefs.clockDateStyle === st}
|
||||||
onClick={() => patchPrefs({ clockDateStyle: st })}
|
onClick={() => patchPrefs({ clockDateStyle: st })}
|
||||||
className={`flex-1 rounded px-2 py-1 text-2xs ${
|
className={`flex-1 rounded px-2 py-1 text-2xs ${
|
||||||
prefs.clockDateStyle === st
|
prefs.clockDateStyle === st
|
||||||
@@ -263,7 +268,7 @@ export function PlexPlayerSettings(props: {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date}
|
{clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date}
|
||||||
</button>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { notifyActionError } from "../lib/actionErrors";
|
|||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import Modal from "./ui/Modal";
|
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
|
// 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.
|
// 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 all = n >= size;
|
||||||
const partial = n > 0 && n < size;
|
const partial = n > 0 && n < size;
|
||||||
return (
|
return (
|
||||||
<FocusSafeButton
|
<Pressable
|
||||||
key={p.id}
|
key={p.id}
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={all ? true : partial ? "mixed" : false}
|
||||||
onClick={() => toggle(p)}
|
onClick={() => toggle(p)}
|
||||||
disabled={busy}
|
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" : ""
|
all ? "text-accent" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -175,7 +177,7 @@ export default function PlexPlaylistAdd({
|
|||||||
{t("plex.playlistAdd.count", { count: p.item_count })}
|
{t("plex.playlistAdd.count", { count: p.item_count })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</FocusSafeButton>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { api } from "../lib/api";
|
|||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import { availablePlexFields, healPlexField, plexFieldLabelKey } from "../lib/plexSearch";
|
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
|
// 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
|
// 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>
|
<div className="px-3 py-2 text-sm text-muted">{t("plex.loading")}</div>
|
||||||
)}
|
)}
|
||||||
{suggestions.map((c, i) => (
|
{suggestions.map((c, i) => (
|
||||||
<button
|
<Pressable
|
||||||
key={c.id}
|
key={c.id}
|
||||||
id={`${listId}-opt-${i}`}
|
id={`${listId}-opt-${i}`}
|
||||||
data-idx={i}
|
data-idx={i}
|
||||||
type="button"
|
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={hi === i}
|
aria-selected={hi === i}
|
||||||
onMouseDown={(e) => e.preventDefault()} // keep input focus through the click
|
onMouseDown={(e) => e.preventDefault()} // keep input focus through the click
|
||||||
onMouseEnter={() => setHi(i)}
|
onMouseEnter={() => setHi(i)}
|
||||||
onClick={() => pick(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"
|
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" />
|
<Film className="w-3.5 h-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { usePlexCollectionTitles } from "../lib/usePlexCollectionTitles";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { plexLang } from "../i18n";
|
import { plexLang } from "../i18n";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { api, plexFilterCount, type PlexFilters } from "../lib/api";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { type PanelLayout } from "../lib/panelLayout";
|
import { type PanelLayout } from "../lib/panelLayout";
|
||||||
@@ -20,6 +20,7 @@ import PersonalViewsModal from "./PersonalViewsModal";
|
|||||||
import { useSavedViews } from "./savedViewsContext";
|
import { useSavedViews } from "./savedViewsContext";
|
||||||
import { usePlex, usePlexActions } from "./plexContext";
|
import { usePlex, usePlexActions } from "./plexContext";
|
||||||
import { useMe } from "../lib/useMe";
|
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
|
// 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
|
// 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 }) {
|
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<span className="chip px-2 py-1 text-xs">
|
<DismissibleChip
|
||||||
<span className="truncate max-w-36">{label}</span>
|
onDismiss={onRemove}
|
||||||
<button
|
dismissLabel={t("common.removeNamed", { name: label })}
|
||||||
onClick={onRemove}
|
className="max-w-40"
|
||||||
className="hover:opacity-70"
|
>
|
||||||
aria-label={t("common.removeNamed", { name: label })}
|
{label}
|
||||||
>
|
</DismissibleChip>
|
||||||
<X className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import type Hls from "hls.js";
|
import type Hls from "hls.js";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { buildStatRows, type FragMeter, type HlsLevelInfo, type StatRow } from "../lib/plexStats";
|
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
|
// "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
|
// 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">
|
<span className="text-xs font-semibold uppercase tracking-wide text-white/70">
|
||||||
{t("plex.player.stats.title")}
|
{t("plex.player.stats.title")}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<IconButton variant="overlay" onClick={onClose} label={t("plex.player.close")}>
|
||||||
onClick={onClose}
|
<X />
|
||||||
aria-label={t("plex.player.close")}
|
</IconButton>
|
||||||
className="rounded p-0.5 text-white/60 hover:bg-white/10 hover:text-white"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/* No aria-live here: the rows re-sample every second, so a live region would make a screen
|
{/* 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.
|
reader announce the changing values continuously and drown out real player announcements.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { hueOf, hueTileGradient } from "../lib/hueTile";
|
import { hueOf, hueTileGradient } from "../lib/hueTile";
|
||||||
import { navItemAttrs } from "../lib/listNav";
|
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}),
|
// 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
|
// 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 posters = (art ?? []).slice(0, 2);
|
||||||
const band = hueTileGradient(hueOf(title));
|
const band = hueTileGradient(hueOf(title));
|
||||||
return (
|
return (
|
||||||
<button
|
<Pressable
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
title={title}
|
title={title}
|
||||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
{...(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. */}
|
{/* Top half: 2 montage posters, or the colour hash when a group has no art. */}
|
||||||
<div
|
<div
|
||||||
@@ -60,6 +60,6 @@ export default function PosterTile({
|
|||||||
<div className="truncate text-sm font-semibold text-white drop-shadow">{title}</div>
|
<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>}
|
{subtitle != null && <div className="truncate text-2xs text-white/80">{subtitle}</div>}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { inputCls } from "./ui/form";
|
|||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||||
import FilterSelect from "./ui/FilterSelect";
|
import FilterSelect from "./ui/FilterSelect";
|
||||||
|
import { IconButton } from "./ui/IconButton";
|
||||||
|
|
||||||
const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
|
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"
|
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>
|
<span className="flex-1 truncate">{p.name}</span>
|
||||||
<button
|
<IconButton onClick={() => startEdit(p)} label={t("downloads.actions.rename")}>
|
||||||
onClick={() => startEdit(p)}
|
<Pencil />
|
||||||
title={t("downloads.actions.rename")}
|
</IconButton>
|
||||||
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-fg"
|
<IconButton
|
||||||
>
|
variant="danger"
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onDelete(p)}
|
onClick={() => onDelete(p)}
|
||||||
title={t("downloads.profiles.delete")}
|
label={t("downloads.profiles.delete")}
|
||||||
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-danger"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 />
|
||||||
</button>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { Bookmark, Check, Settings2 } from "lucide-react";
|
|||||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||||
import { useSavedViews } from "./savedViewsContext";
|
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
|
// 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
|
// 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");
|
const label = ariaLabel ?? t("views.saved");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
{/* A caller-shaped trigger (the toolbar's view chip) is a surface; the default one a glyph. */}
|
||||||
type="button"
|
{className ? (
|
||||||
ref={pop.refs.setReference}
|
<Pressable
|
||||||
{...pop.getReferenceProps()}
|
ref={pop.refs.setReference}
|
||||||
aria-label={label}
|
{...pop.getReferenceProps()}
|
||||||
className={className ?? "shrink-0 flex text-accent transition hover:text-fg"}
|
aria-label={label}
|
||||||
>
|
className={className}
|
||||||
{trigger}
|
>
|
||||||
</button>
|
{trigger}
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
variant="ghost-accent"
|
||||||
|
ref={pop.refs.setReference}
|
||||||
|
{...pop.getReferenceProps()}
|
||||||
|
label={label}
|
||||||
|
>
|
||||||
|
{trigger}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={pop}
|
popover={pop}
|
||||||
ariaLabel={label}
|
ariaLabel={label}
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import { useSearchFocusKey } from "../lib/keymap";
|
import { useSearchFocusKey } from "../lib/keymap";
|
||||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
|
||||||
import { PaletteKeycap } from "./PaletteLauncher";
|
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
|
// 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
|
// (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"
|
className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
|
||||||
/>
|
/>
|
||||||
{value && (
|
{value && (
|
||||||
<button
|
<IconButton onClick={() => onChange("")} label={t("header.clearSearch")}>
|
||||||
onClick={() => onChange("")}
|
<X />
|
||||||
title={t("header.clearSearch")}
|
</IconButton>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
<PaletteKeycap />
|
<PaletteKeycap />
|
||||||
{onGo && (
|
{onGo && (
|
||||||
<FocusSafeButton
|
<Button
|
||||||
|
size="sm"
|
||||||
onClick={fire}
|
onClick={fire}
|
||||||
disabled={goDisabled}
|
disabled={goDisabled}
|
||||||
title={goTitle ?? goLabel ?? t("header.goTip")}
|
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}
|
{goIcon}
|
||||||
<span className={goIcon ? "hidden md:inline" : undefined}>
|
<span className={goIcon ? "hidden md:inline" : undefined}>
|
||||||
{goLabel ?? t("header.go")}
|
{goLabel ?? t("header.go")}
|
||||||
</span>
|
</span>
|
||||||
</FocusSafeButton>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{below}
|
{below}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { setLanguage, type LangCode } from "../i18n";
|
|||||||
import LanguageSwitcher from "./LanguageSwitcher";
|
import LanguageSwitcher from "./LanguageSwitcher";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { inputCls } from "./ui/form";
|
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
|
// 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
|
// 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">
|
<div className="flex items-center justify-between mt-6">
|
||||||
<FocusSafeButton
|
<Button
|
||||||
|
variant="glass"
|
||||||
|
icon={<ChevronLeft />}
|
||||||
onClick={() => go(-1)}
|
onClick={() => go(-1)}
|
||||||
disabled={stepIdx === 0 || busy}
|
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")}
|
{t("setup.nav.back")}
|
||||||
</FocusSafeButton>
|
</Button>
|
||||||
<FocusSafeButton
|
<Button
|
||||||
onClick={next}
|
onClick={next}
|
||||||
disabled={busy}
|
loading={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"
|
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" ? t("setup.nav.finish") : t("setup.nav.next")}
|
||||||
{step !== "finish" && !busy && <ChevronRight className="w-4 h-4" />}
|
{step !== "finish" && !busy && <ChevronRight className="w-4 h-4" />}
|
||||||
</FocusSafeButton>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { qk } from "../lib/queryKeys";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
|
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
|
||||||
import clsx from "clsx";
|
|
||||||
import Modal from "./ui/Modal";
|
import Modal from "./ui/Modal";
|
||||||
import { useConfirm } from "./confirmContext";
|
import { useConfirm } from "./confirmContext";
|
||||||
import { notify } from "../lib/notifications";
|
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 { inputCls } from "./ui/form";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
import { Pressable } from "./ui/Pressable";
|
||||||
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
|
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
|
||||||
|
|
||||||
// --- share with a registered user (ACL) -----------------------------------------------------
|
// --- share with a registered user (ACL) -----------------------------------------------------
|
||||||
@@ -72,15 +71,16 @@ function UserShare({ job }: { job: DownloadJob }) {
|
|||||||
{open && filtered.length > 0 && (
|
{open && filtered.length > 0 && (
|
||||||
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
|
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
|
||||||
{filtered.map((u) => (
|
{filtered.map((u) => (
|
||||||
<FocusSafeButton
|
<Pressable
|
||||||
key={u.id}
|
key={u.id}
|
||||||
|
layer
|
||||||
onClick={() => share.mutate(u.email)}
|
onClick={() => share.mutate(u.email)}
|
||||||
disabled={share.isPending}
|
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>
|
<span className="truncate">{u.display_name || u.email}</span>
|
||||||
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
|
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
|
||||||
</FocusSafeButton>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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"
|
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())}
|
onClick={() => savePass.mutate(newPass.trim())}
|
||||||
disabled={savePass.isPending || !newPass.trim()}
|
loading={savePass.isPending}
|
||||||
className="px-2 py-1 rounded-md text-xs font-semibold btn-primary disabled:opacity-40 transition"
|
disabled={!newPass.trim()}
|
||||||
>
|
>
|
||||||
{t("downloads.share.savePassword")}
|
{t("downloads.share.savePassword")}
|
||||||
</FocusSafeButton>
|
</Button>
|
||||||
{link.has_password && (
|
{link.has_password && (
|
||||||
<FocusSafeButton
|
<Button
|
||||||
|
variant="outline-danger"
|
||||||
|
size="xs"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (
|
if (
|
||||||
await confirm({
|
await confirm({
|
||||||
@@ -248,10 +251,9 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
|||||||
savePass.mutate("");
|
savePass.mutate("");
|
||||||
}}
|
}}
|
||||||
disabled={savePass.isPending}
|
disabled={savePass.isPending}
|
||||||
className="px-2 py-1 rounded-md text-xs text-muted hover:text-danger transition"
|
|
||||||
>
|
>
|
||||||
{t("downloads.share.removePassword")}
|
{t("downloads.share.removePassword")}
|
||||||
</FocusSafeButton>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -336,20 +338,17 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
|||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<span className="text-muted">{t("downloads.share.expiry")}:</span>
|
<span className="text-muted">{t("downloads.share.expiry")}:</span>
|
||||||
{EXPIRY_DAYS.map((d) => (
|
{EXPIRY_DAYS.map((d) => (
|
||||||
<button
|
<Button
|
||||||
key={String(d)}
|
key={String(d)}
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
pressed={expiryDays === d}
|
||||||
onClick={() => setExpiryDays(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
|
{d === null
|
||||||
? t("downloads.share.expiryNever")
|
? t("downloads.share.expiryNever")
|
||||||
: t("downloads.share.days", { n: d })}
|
: t("downloads.share.days", { n: d })}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import Tabs from "./ui/Tabs";
|
|||||||
import { Section, SettingRow } from "./ui/form";
|
import { Section, SettingRow } from "./ui/form";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
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;
|
const RANGES = [7, 30, 90] as const;
|
||||||
type StatsTab = "overview" | "system";
|
type StatsTab = "overview" | "system";
|
||||||
@@ -175,26 +176,28 @@ function Overview({ me }: { me: Me }) {
|
|||||||
|
|
||||||
{!me.is_demo && (
|
{!me.is_demo && (
|
||||||
<Section title={t("stats.sync.actions")}>
|
<Section title={t("stats.sync.actions")}>
|
||||||
<Tooltip hint={t("stats.sync.syncSubscriptionsHint")}>
|
<div className="flex flex-wrap gap-2">
|
||||||
<FocusSafeButton
|
<Tooltip hint={t("stats.sync.syncSubscriptionsHint")}>
|
||||||
onClick={() => syncSubs.mutate()}
|
<Button
|
||||||
disabled={syncSubs.isPending}
|
variant="glass"
|
||||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
icon={<RefreshCw />}
|
||||||
>
|
loading={syncSubs.isPending}
|
||||||
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
onClick={() => syncSubs.mutate()}
|
||||||
{t("stats.sync.syncSubscriptions")}
|
>
|
||||||
</FocusSafeButton>
|
{t("stats.sync.syncSubscriptions")}
|
||||||
</Tooltip>
|
</Button>
|
||||||
<Tooltip hint={t("stats.sync.backfillEverythingHint")}>
|
</Tooltip>
|
||||||
<FocusSafeButton
|
<Tooltip hint={t("stats.sync.backfillEverythingHint")}>
|
||||||
onClick={() => deepAll.mutate()}
|
<Button
|
||||||
disabled={deepAll.isPending}
|
variant="glass"
|
||||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition mt-2"
|
icon={<History />}
|
||||||
>
|
loading={deepAll.isPending}
|
||||||
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
|
onClick={() => deepAll.mutate()}
|
||||||
{t("stats.sync.backfillEverything")}
|
>
|
||||||
</FocusSafeButton>
|
{t("stats.sync.backfillEverything")}
|
||||||
</Tooltip>
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -227,15 +230,16 @@ function SystemStats() {
|
|||||||
{s && (
|
{s && (
|
||||||
<Section title={t("stats.sync.admin")}>
|
<Section title={t("stats.sync.admin")}>
|
||||||
<Tooltip hint={t("stats.sync.adminPauseHint")}>
|
<Tooltip hint={t("stats.sync.adminPauseHint")}>
|
||||||
<button
|
<Button
|
||||||
|
variant="glass"
|
||||||
|
icon={s.paused ? <Play /> : <Pause />}
|
||||||
|
loading={pauseResume.isPending}
|
||||||
onClick={() => pauseResume.mutate(s.paused)}
|
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
|
{s.paused
|
||||||
? t("stats.sync.resumeBackgroundSync")
|
? t("stats.sync.resumeBackgroundSync")
|
||||||
: t("stats.sync.pauseBackgroundSync")}
|
: t("stats.sync.pauseBackgroundSync")}
|
||||||
</button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
@@ -321,9 +325,10 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
|
|||||||
const isSystem = row.user_id === null;
|
const isSystem = row.user_id === null;
|
||||||
return (
|
return (
|
||||||
<div className="glass-card rounded-xl p-3">
|
<div className="glass-card rounded-xl p-3">
|
||||||
<button
|
<Pressable
|
||||||
onClick={() => setOpen((o) => !o)}
|
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" : ""}`}>
|
<span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
|
||||||
{isSystem ? t("stats.system") : row.email}
|
{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">
|
<span className="text-sm font-semibold tabular-nums shrink-0">
|
||||||
{formatNumber(row.total)}
|
{formatNumber(row.total)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</Pressable>
|
||||||
{/* Proportion bar */}
|
{/* Proportion bar */}
|
||||||
<div className="mt-2 h-1.5 bg-border/40 rounded-full overflow-hidden">
|
<div className="mt-2 h-1.5 bg-border/40 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import { notifyActionError } from "../lib/actionErrors";
|
|||||||
import { formatViews } from "../lib/format";
|
import { formatViews } from "../lib/format";
|
||||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
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*
|
// 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
|
// 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) && (
|
const pauseBtn = isAdmin && (data.paused || syncing > 0 || notFull > 0) && (
|
||||||
<FocusSafeButton
|
<IconButton
|
||||||
onClick={() => toggle.mutate()}
|
onClick={() => toggle.mutate()}
|
||||||
disabled={toggle.isPending}
|
loading={toggle.isPending}
|
||||||
title={data.paused ? t("header.sync.resume") : t("header.sync.pause")}
|
label={data.paused ? t("header.sync.resume") : t("header.sync.pause")}
|
||||||
className="p-1 rounded-md hover:bg-card hover:text-fg transition"
|
|
||||||
>
|
>
|
||||||
{data.paused ? <Play className="w-3.5 h-3.5" /> : <Pause className="w-3.5 h-3.5" />}
|
{data.paused ? <Play /> : <Pause />}
|
||||||
</FocusSafeButton>
|
</IconButton>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Chip: the headline count rides in the header; the state, the history link and pause live in a
|
// 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")}`;
|
)} ${t("header.sync.total")}`;
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-auto shrink-0">
|
<div className="pointer-events-auto shrink-0">
|
||||||
<button
|
<Pressable
|
||||||
ref={pop.refs.setReference}
|
ref={pop.refs.setReference}
|
||||||
{...pop.getReferenceProps()}
|
{...pop.getReferenceProps()}
|
||||||
// Short label only: the popover already explains the numbers, and the long one overflowed
|
// Short label only: the popover already explains the numbers, and the long one overflowed
|
||||||
@@ -134,7 +135,7 @@ export default function SyncStatus({
|
|||||||
{needsAttention && (
|
{needsAttention && (
|
||||||
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-accent ring-2 ring-bg" />
|
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-accent ring-2 ring-bg" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</Pressable>
|
||||||
|
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={pop}
|
popover={pop}
|
||||||
@@ -161,17 +162,18 @@ export default function SyncStatus({
|
|||||||
)}
|
)}
|
||||||
{notFull > 0 && (
|
{notFull > 0 && (
|
||||||
<div className="flex items-center justify-between gap-1.5 pt-2">
|
<div className="flex items-center justify-between gap-1.5 pt-2">
|
||||||
<button
|
<Button
|
||||||
|
variant="link-muted"
|
||||||
|
icon={<History className="size-3.5!" />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
onGoToFullHistory();
|
onGoToFullHistory();
|
||||||
}}
|
}}
|
||||||
title={t("header.sync.fullHistoryTooltip")}
|
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 })}
|
{t("header.sync.withoutFullHistory", { count: notFull })}
|
||||||
</button>
|
</Button>
|
||||||
{!showMain && pauseBtn}
|
{!showMain && pauseBtn}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
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 { PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||||
|
|
||||||
@@ -20,16 +21,11 @@ export interface FilterChip {
|
|||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Chip({ chip, extra }: { chip: FilterChip; extra?: string }) {
|
function TagChip({ chip }: { chip: FilterChip }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<Chip onClick={chip.onToggle} pressed={chip.active}>
|
||||||
type="button"
|
|
||||||
onClick={chip.onToggle}
|
|
||||||
aria-pressed={chip.active}
|
|
||||||
className={`${filterChipClass}${extra ? ` ${extra}` : ""}`}
|
|
||||||
>
|
|
||||||
{chip.label}
|
{chip.label}
|
||||||
</button>
|
</Chip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,28 +99,22 @@ export default function TagFilterChips({
|
|||||||
}}
|
}}
|
||||||
className="inline-flex"
|
className="inline-flex"
|
||||||
>
|
>
|
||||||
<span className={filterChipClass}>{c.label}</span>
|
<span className={chipClass()}>{c.label}</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<span ref={pillRef} className={filterChipClass}>
|
<span ref={pillRef} className={chipClass()}>
|
||||||
+{chips.length}
|
+{chips.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
|
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
|
||||||
{chips.slice(0, visible).map((c) => (
|
{chips.slice(0, visible).map((c) => (
|
||||||
<Chip key={c.key} chip={c} extra="shrink-0" />
|
<TagChip key={c.key} chip={c} />
|
||||||
))}
|
))}
|
||||||
{hidden > 0 && (
|
{hidden > 0 && (
|
||||||
<button
|
<Chip ref={pop.refs.setReference} aria-label={moreLabel} {...pop.getReferenceProps()}>
|
||||||
ref={pop.refs.setReference}
|
|
||||||
type="button"
|
|
||||||
aria-label={moreLabel}
|
|
||||||
className={`${filterChipClass} shrink-0 ${open ? "border-accent text-accent" : ""}`}
|
|
||||||
{...pop.getReferenceProps()}
|
|
||||||
>
|
|
||||||
+{hidden}
|
+{hidden}
|
||||||
</button>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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]"
|
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) => (
|
{chips.slice(visible).map((c) => (
|
||||||
<Chip key={c.key} chip={c} />
|
<TagChip key={c.key} chip={c} />
|
||||||
))}
|
))}
|
||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import Modal from "./ui/Modal";
|
|||||||
import { useConfirm } from "./confirmContext";
|
import { useConfirm } from "./confirmContext";
|
||||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
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
|
// 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
|
// 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 })}
|
{t("tagManager.channels", { count: tag.channel_count })}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<IconButton variant="danger" onClick={onDelete} label={t("tagManager.delete")}>
|
||||||
onClick={onDelete}
|
<Trash2 />
|
||||||
title={t("tagManager.delete")}
|
</IconButton>
|
||||||
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>
|
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={pop}
|
popover={pop}
|
||||||
className="glass-menu w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
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"
|
className="max-h-72 overflow-y-auto no-scrollbar"
|
||||||
>
|
>
|
||||||
{channels.map((ch) => (
|
{channels.map((ch) => (
|
||||||
<button
|
<Pressable
|
||||||
key={ch.id}
|
key={ch.id}
|
||||||
|
layer
|
||||||
onClick={() => onPickChannel(ch.title)}
|
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}
|
{ch.title}
|
||||||
</button>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { Play, X } from "lucide-react";
|
|||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { plexLang } from "../i18n";
|
import { plexLang } from "../i18n";
|
||||||
import { qk } from "../lib/queryKeys";
|
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
|
// 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
|
// 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>
|
<div className="truncate text-sm font-semibold leading-tight">{heading || "…"}</div>
|
||||||
{sub && <div className="truncate text-xs text-white/60">{sub}</div>}
|
{sub && <div className="truncate text-xs text-white/60">{sub}</div>}
|
||||||
<div className="mt-auto flex items-center gap-2 pt-2.5">
|
<div className="mt-auto flex items-center gap-2 pt-2.5">
|
||||||
<button
|
<Button size="sm" onMedia onClick={onAdvance} className="text-xs font-semibold">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<span className="relative grid h-5.5 w-5.5 flex-none place-items-center">
|
<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">
|
<svg viewBox="0 0 24 24" className="absolute inset-0 -rotate-90">
|
||||||
<circle
|
<circle
|
||||||
@@ -114,22 +113,26 @@ export function UpNextCard({
|
|||||||
</span>
|
</span>
|
||||||
{t("plex.player.upNext.playNow")}
|
{t("plex.player.upNext.playNow")}
|
||||||
<span className="tabular-nums">{secs}</span>
|
<span className="tabular-nums">{secs}</span>
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="overlay"
|
||||||
|
size="sm"
|
||||||
|
onMedia
|
||||||
onClick={onCancel}
|
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")}
|
{t("plex.player.upNext.cancel")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<IconButton
|
||||||
|
variant="overlay"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
aria-label={t("plex.player.upNext.cancel")}
|
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"
|
className="absolute right-1 top-1"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X />
|
||||||
</button>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { navigateTo } from "../lib/nav";
|
|||||||
import { formatBytes } from "../lib/format";
|
import { formatBytes } from "../lib/format";
|
||||||
import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
|
import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
|
||||||
import { inputCls } from "./ui/form";
|
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
|
// 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
|
// (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>
|
<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. */}
|
{/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog. */}
|
||||||
<FocusSafeButton
|
<Pressable
|
||||||
type="button"
|
align="center"
|
||||||
onClick={pick}
|
onClick={pick}
|
||||||
onDragEnter={(e) => {
|
onDragEnter={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -158,7 +158,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</FocusSafeButton>
|
</Pressable>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
@@ -109,11 +109,7 @@ function Actions({
|
|||||||
// overlay for the thumbnailed views, and inline beside the actions for the one-line row.
|
// overlay for the thumbnailed views, and inline beside the actions for the one-line row.
|
||||||
const INFO_REVEAL =
|
const INFO_REVEAL =
|
||||||
"reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 transition-opacity";
|
"reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 transition-opacity";
|
||||||
const INFO_OVERLAY = clsx(
|
const INFO_OVERLAY = clsx(INFO_REVEAL, "absolute bottom-1.5 left-1.5 z-10");
|
||||||
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");
|
|
||||||
|
|
||||||
// Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click
|
// 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.
|
// 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
|
{/* 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
|
corner badges (saved / stream, top corners) and the duration (bottom-right). Revealed with the
|
||||||
rest of the hover chrome, dropped on touch. */}
|
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). */}
|
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
|
||||||
{pct > 0 && (
|
{pct > 0 && (
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
|
<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. */}
|
budget). ROW_COL.actions already counts its width. */}
|
||||||
<div className="shrink-0 flex items-center gap-1">
|
<div className="shrink-0 flex items-center gap-1">
|
||||||
<div className="w-48">{actions}</div>
|
<div className="w-48">{actions}</div>
|
||||||
<VideoInfoCard video={video} className={INFO_INLINE} />
|
<VideoInfoCard video={video} className={INFO_REVEAL} />
|
||||||
</div>
|
</div>
|
||||||
{/* Everything else is shrink-0, so the title is the only thing that can give — which means
|
{/* 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
|
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 Avatar from "./ui/Avatar";
|
||||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
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)
|
// 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
|
// 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.
|
// so the trigger deliberately lives OUTSIDE it.
|
||||||
export default function VideoInfoCard({
|
export default function VideoInfoCard({
|
||||||
video,
|
video,
|
||||||
|
onMedia = false,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
/** Trigger-button classes (placement + hover-reveal + skin). The reveal/touch-suppression rules
|
/** The trigger sits on the thumbnail: the round, dark translucent IconButton (A2). */
|
||||||
* live here so each host can match its surface (overlay vs inline). */
|
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;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
@@ -90,8 +94,8 @@ export default function VideoInfoCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
variant={onMedia ? "media" : "ghost"}
|
||||||
ref={pop.refs.setReference}
|
ref={pop.refs.setReference}
|
||||||
{...pop.getReferenceProps()}
|
{...pop.getReferenceProps()}
|
||||||
// The overlay trigger sits inside the thumbnail's <a> (like the play buttons), so a click must
|
// 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.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
aria-label={t("card.info")}
|
label={t("card.info")}
|
||||||
title={t("card.info")}
|
|
||||||
className={className}
|
className={className}
|
||||||
>
|
>
|
||||||
<Info className="w-4 h-4" />
|
<Info />
|
||||||
</button>
|
</IconButton>
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={pop}
|
popover={pop}
|
||||||
ariaLabel={t("card.info")}
|
ariaLabel={t("card.info")}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Check, ChevronDown, type LucideIcon } from "lucide-react";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||||
|
import { Pressable } from "./ui/Pressable";
|
||||||
|
|
||||||
export interface ViewOption<T extends string> {
|
export interface ViewOption<T extends string> {
|
||||||
id: T;
|
id: T;
|
||||||
@@ -59,7 +60,7 @@ export default function ViewSwitcher<T extends string>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<Pressable
|
||||||
ref={pop.refs.setReference}
|
ref={pop.refs.setReference}
|
||||||
{...pop.getReferenceProps()}
|
{...pop.getReferenceProps()}
|
||||||
// The aria-label carries the mode's name at every size — the visible text below is a
|
// 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
|
<ChevronDown
|
||||||
className={`h-3.5 w-3.5 shrink-0 text-muted transition-transform ${open ? "rotate-180" : ""}`}
|
className={`h-3.5 w-3.5 shrink-0 text-muted transition-transform ${open ? "rotate-180" : ""}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</Pressable>
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={pop}
|
popover={pop}
|
||||||
ariaLabel={label}
|
ariaLabel={label}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useId, useRef, useState } from "react";
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
|
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
|
import { Chip } from "./ui/Chip";
|
||||||
import { formatBytes } from "../lib/format";
|
import { formatBytes } from "../lib/format";
|
||||||
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
||||||
import { LS, readJSON, writeJSON } from "../lib/storage";
|
import { LS, readJSON, writeJSON } from "../lib/storage";
|
||||||
@@ -385,14 +386,15 @@ export default function WatchPage() {
|
|||||||
<div className="flex shrink-0 items-center gap-2">
|
<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
|
{/* 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, whose variants differ only in colour. */}
|
||||||
<button
|
<Chip
|
||||||
type="button"
|
size="md"
|
||||||
aria-pressed={repeat}
|
pressed={repeat}
|
||||||
|
icon={<Repeat className="size-3.5" aria-hidden />}
|
||||||
onClick={toggleRepeat}
|
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}
|
{T.repeat}
|
||||||
</button>
|
</Chip>
|
||||||
{meta.allow_download && (
|
{meta.allow_download && (
|
||||||
<a
|
<a
|
||||||
href={meta.file_url}
|
href={meta.file_url}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { qk } from "../lib/queryKeys";
|
|||||||
import { api, type FeedFilters, type YearCount } from "../lib/api";
|
import { api, type FeedFilters, type YearCount } from "../lib/api";
|
||||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
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 CountBadge from "./ui/CountBadge";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
|
|
||||||
@@ -111,31 +112,23 @@ export default function YearFilter({
|
|||||||
{quick.map((y) => {
|
{quick.map((y) => {
|
||||||
const on = selected === y.year;
|
const on = selected === y.year;
|
||||||
return (
|
return (
|
||||||
<button
|
<Chip key={y.year} pressed={on} onClick={() => pick(on ? undefined : y.year)}>
|
||||||
key={y.year}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={on}
|
|
||||||
onClick={() => pick(on ? undefined : y.year)}
|
|
||||||
className={filterChipClass}
|
|
||||||
>
|
|
||||||
<span className="tabular-nums">{y.year}</span>
|
<span className="tabular-nums">{y.year}</span>
|
||||||
{y.count > 0 && <CountBadge count={y.count} active={on} />}
|
{y.count > 0 && <CountBadge count={y.count} active={on} />}
|
||||||
</button>
|
</Chip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{years.length > quick.length && (
|
{years.length > quick.length && (
|
||||||
<>
|
<>
|
||||||
<button
|
<Chip
|
||||||
type="button"
|
|
||||||
ref={pop.refs.setReference}
|
ref={pop.refs.setReference}
|
||||||
{...pop.getReferenceProps()}
|
{...pop.getReferenceProps()}
|
||||||
aria-label={t("sidebar.year.moreLabel")}
|
aria-label={t("sidebar.year.moreLabel")}
|
||||||
className={filterChipClass}
|
|
||||||
>
|
>
|
||||||
<span>{t("sidebar.year.more")}</span>
|
<span>{t("sidebar.year.more")}</span>
|
||||||
<ChevronDown className="text-muted h-3.5 w-3.5" aria-hidden="true" />
|
<ChevronDown className="text-muted h-3.5 w-3.5" aria-hidden="true" />
|
||||||
</button>
|
</Chip>
|
||||||
|
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={pop}
|
popover={pop}
|
||||||
@@ -151,19 +144,15 @@ export default function YearFilter({
|
|||||||
["sortCount", true],
|
["sortCount", true],
|
||||||
] as const
|
] as const
|
||||||
).map(([key, v]) => (
|
).map(([key, v]) => (
|
||||||
<button
|
<Button
|
||||||
key={key}
|
key={key}
|
||||||
type="button"
|
variant="outline"
|
||||||
aria-pressed={sortByCount === v}
|
size="xs"
|
||||||
|
pressed={sortByCount === v}
|
||||||
onClick={() => setSortByCount(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)}
|
{t("sidebar.year." + key)}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { ManagedChannel } from "../lib/api";
|
|||||||
import { formatNumber } from "../lib/format";
|
import { formatNumber } from "../lib/format";
|
||||||
import Tooltip from "./ui/Tooltip";
|
import Tooltip from "./ui/Tooltip";
|
||||||
import { isFullySynced } from "./channelSync";
|
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
|
// 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).
|
// channelColumns so that file holds only the column factory and its predicates (Fast Refresh).
|
||||||
@@ -67,39 +67,35 @@ export function ActionsCell({
|
|||||||
return (
|
return (
|
||||||
<div className="inline-flex items-center gap-2">
|
<div className="inline-flex items-center gap-2">
|
||||||
<Tooltip hint={backfillHint}>
|
<Tooltip hint={backfillHint}>
|
||||||
<FocusSafeButton
|
<IconButton
|
||||||
onClick={!enabled ? undefined : isAdmin ? onReset : onDeep}
|
onClick={!enabled ? undefined : isAdmin ? onReset : onDeep}
|
||||||
disabled={!enabled}
|
disabled={!enabled}
|
||||||
className={`shrink-0 transition ${
|
variant={requested ? "ghost-accent" : "ghost"}
|
||||||
requested
|
label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
|
||||||
? "text-accent"
|
noTitle
|
||||||
: enabled
|
|
||||||
? "text-muted hover:text-fg"
|
|
||||||
: "text-muted/40 cursor-default"
|
|
||||||
}`}
|
|
||||||
aria-label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
|
|
||||||
>
|
>
|
||||||
<History className="w-4 h-4" />
|
<History />
|
||||||
</FocusSafeButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}>
|
<Tooltip hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}>
|
||||||
<button
|
<IconButton
|
||||||
onClick={onHide}
|
onClick={onHide}
|
||||||
className="text-muted hover:text-fg shrink-0"
|
label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}
|
||||||
aria-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" />}
|
{c.hidden ? <EyeOff /> : <Eye />}
|
||||||
</button>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{canWrite && (
|
{canWrite && (
|
||||||
<Tooltip hint={t("channels.row.unsubscribeHint")}>
|
<Tooltip hint={t("channels.row.unsubscribeHint")}>
|
||||||
<button
|
<IconButton
|
||||||
|
variant="danger"
|
||||||
onClick={onUnsubscribe}
|
onClick={onUnsubscribe}
|
||||||
className="text-muted hover:text-danger shrink-0"
|
label={t("channels.row.unsubscribeOnYoutube")}
|
||||||
aria-label={t("channels.row.unsubscribeOnYoutube")}
|
noTitle
|
||||||
>
|
>
|
||||||
<UserMinus className="w-4 h-4" />
|
<UserMinus />
|
||||||
</button>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+5
-4
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
|
import { Pressable } from "./ui/Pressable";
|
||||||
|
|
||||||
// Turn a video description into clickable nodes:
|
// Turn a video description into clickable nodes:
|
||||||
// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player
|
// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player
|
||||||
@@ -78,7 +79,7 @@ export function renderDescription(
|
|||||||
if (yt) {
|
if (yt) {
|
||||||
const sameVideo = yt.id === opts.currentId;
|
const sameVideo = yt.id === opts.currentId;
|
||||||
out.push(
|
out.push(
|
||||||
<button
|
<Pressable
|
||||||
key={key++}
|
key={key++}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
sameVideo ? opts.onSeek(yt.start ?? 0) : opts.onLoadVideo(yt.id, yt.start)
|
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")}
|
title={sameVideo ? t("player.jumpToTime") : t("player.playInApp")}
|
||||||
>
|
>
|
||||||
{href}
|
{href}
|
||||||
</button>,
|
</Pressable>,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
out.push(
|
out.push(
|
||||||
@@ -133,13 +134,13 @@ export function renderDescription(
|
|||||||
// alternative — the timestamp group — is the one that fired.
|
// alternative — the timestamp group — is the one that fired.
|
||||||
const ts = m[3]!;
|
const ts = m[3]!;
|
||||||
out.push(
|
out.push(
|
||||||
<button
|
<Pressable
|
||||||
key={key++}
|
key={key++}
|
||||||
onClick={() => opts.onSeek(tsToSeconds(ts))}
|
onClick={() => opts.onSeek(tsToSeconds(ts))}
|
||||||
className="text-accent hover:underline"
|
className="text-accent hover:underline"
|
||||||
>
|
>
|
||||||
{ts}
|
{ts}
|
||||||
</button>,
|
</Pressable>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
last = m.index + m[0].length;
|
last = m.index + m[0].length;
|
||||||
@@ -11,6 +11,8 @@ import { inputCls } from "./ui/form";
|
|||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { StatusPill } from "./downloadRow";
|
import { StatusPill } from "./downloadRow";
|
||||||
import { jobTitle } from "./downloadTitle";
|
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
|
// 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).
|
// 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 && (
|
{open && filtered.length > 0 && (
|
||||||
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
|
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
|
||||||
{filtered.map((u) => (
|
{filtered.map((u) => (
|
||||||
<button
|
<Pressable
|
||||||
key={u.id}
|
key={u.id}
|
||||||
|
layer
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onPick({ id: u.id, email: u.email });
|
onPick({ id: u.id, email: u.email });
|
||||||
setQ("");
|
setQ("");
|
||||||
setOpen(false);
|
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>
|
<span className="truncate">{u.display_name || u.email}</span>
|
||||||
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
|
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
|
||||||
</button>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -225,13 +228,12 @@ export function AdminSystem() {
|
|||||||
>
|
>
|
||||||
<span className="flex-1 truncate">{u.email}</span>
|
<span className="flex-1 truncate">{u.email}</span>
|
||||||
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
|
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
|
||||||
<button
|
<IconButton
|
||||||
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
|
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
|
||||||
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-fg"
|
label={t("downloads.admin.editQuota")}
|
||||||
title={t("downloads.admin.editQuota")}
|
|
||||||
>
|
>
|
||||||
<Settings2 className="w-4 h-4" />
|
<Settings2 />
|
||||||
</button>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{s.per_user.length === 0 && (
|
{s.per_user.length === 0 && (
|
||||||
|
|||||||
@@ -95,12 +95,9 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button variant="link-muted" icon={<Plus />} onClick={addLink} className="mt-2 text-sm">
|
||||||
onClick={addLink}
|
{t("downloads.edit.addLink")}
|
||||||
className="mt-2 inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg transition"
|
</Button>
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" /> {t("downloads.edit.addLink")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
|
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
@@ -148,12 +145,14 @@ export function ErrorModal({
|
|||||||
<p className="text-sm text-muted">{t(failCopyKeys(job, "body"))}</p>
|
<p className="text-sm text-muted">{t(failCopyKeys(job, "body"))}</p>
|
||||||
{job.error && (
|
{job.error && (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<Button
|
||||||
|
variant="link-muted"
|
||||||
|
aria-expanded={showRaw}
|
||||||
onClick={() => setShowRaw((v) => !v)}
|
onClick={() => setShowRaw((v) => !v)}
|
||||||
className="text-xs text-muted hover:text-fg transition"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
{t("downloads.errors.fail.detailsToggle")}
|
{t("downloads.errors.fail.detailsToggle")}
|
||||||
</button>
|
</Button>
|
||||||
{showRaw && (
|
{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">
|
<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}
|
{job.error}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
import { navItemAttrs } from "../lib/listNav";
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
import { failCopyKeys } from "../lib/downloadFailure";
|
import { failCopyKeys } from "../lib/downloadFailure";
|
||||||
import { jobTitle } from "./downloadTitle";
|
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),
|
// 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,
|
// split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue,
|
||||||
@@ -136,17 +138,14 @@ export function IconBtn({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<IconButton
|
||||||
|
variant={danger ? "danger" : "ghost"}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
data-nav-open={open ? "" : undefined}
|
data-nav-open={open ? "" : undefined}
|
||||||
title={title}
|
label={title}
|
||||||
className={clsx(
|
|
||||||
"p-1.5 rounded-md hover:bg-surface text-muted transition",
|
|
||||||
danger ? "hover:text-danger" : "hover:text-fg",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</IconButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,14 +181,9 @@ function SourceRef({ url }: { url: string }) {
|
|||||||
>
|
>
|
||||||
{displayUrl(url)}
|
{displayUrl(url)}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<IconButton onClick={copy} label={t("downloads.source.copy")}>
|
||||||
onClick={copy}
|
<Copy />
|
||||||
aria-label={t("downloads.source.copy")}
|
</IconButton>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -267,14 +261,14 @@ export function DownloadRow({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{job.status === "error" ? (
|
{job.status === "error" ? (
|
||||||
<button
|
<Pressable
|
||||||
onClick={() => onShowError?.(job)}
|
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")}
|
title={t("downloads.errors.fail.title")}
|
||||||
>
|
>
|
||||||
· {t(failCopyKeys(job, "short"))}
|
· {t(failCopyKeys(job, "short"))}
|
||||||
<span className="text-muted underline">{t("downloads.errors.fail.detailsLink")}</span>
|
<span className="text-muted underline">{t("downloads.errors.fail.detailsLink")}</span>
|
||||||
</button>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{job.source_url && <SourceRef url={job.source_url} />}
|
{job.source_url && <SourceRef url={job.source_url} />}
|
||||||
@@ -306,12 +300,10 @@ export function DownloadRow({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Row action buttons: grown a step here (bigger tap target + icon) without touching the shared
|
{/* Row action buttons: the md rung here (the row stands alone), the sm rung in the modals, one
|
||||||
IconBtn — scoped so the same primitive stays smaller in the modals. `shrink-0` + no wrap keeps
|
IconBtn for both. `shrink-0` + no wrap keeps them on the title's line; the text column
|
||||||
them on the title's line; the text column shrinks instead. */}
|
shrinks instead. */}
|
||||||
<div className="flex items-start gap-1 shrink-0 [&_button]:p-2 [&_svg]:size-4.5">
|
<div className="flex items-start gap-1 shrink-0 [&_button]:size-8">{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
|
import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
|
||||||
import type { AutoMode, LoopMode } from "../lib/playerModes";
|
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 —
|
// 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.
|
// 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 { t } = useTranslation();
|
||||||
const docked = variant === "docked";
|
const docked = variant === "docked";
|
||||||
const navBtn = docked
|
// The docked bar sits on the video (always dark): the overlay variant; the modal's is a page surface.
|
||||||
? "inline-flex items-center gap-1 text-white/80 enabled:not-aria-disabled:hover:text-white disabled:opacity-30 transition"
|
const chrome = docked ? "overlay" : "ghost";
|
||||||
: "inline-flex items-center gap-1.5 text-muted enabled:not-aria-disabled:hover:text-fg disabled:opacity-40 transition";
|
|
||||||
const navLabel = docked ? "hidden sm:inline" : "";
|
const navLabel = docked ? "hidden sm:inline" : "";
|
||||||
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums";
|
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 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" : "";
|
const toggleLabel = docked ? "hidden md:inline" : "";
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FocusSafeButton
|
<Button
|
||||||
|
variant={chrome}
|
||||||
|
size="sm"
|
||||||
|
icon={<SkipBack />}
|
||||||
onClick={onPrev}
|
onClick={onPrev}
|
||||||
disabled={index === 0}
|
disabled={index === 0}
|
||||||
|
label={t("player.previous")}
|
||||||
title={`${t("player.previous")} · Shift+←`}
|
title={`${t("player.previous")} · Shift+←`}
|
||||||
className={navBtn}
|
|
||||||
>
|
>
|
||||||
<SkipBack className="h-4 w-4" />
|
|
||||||
<span className={navLabel}>{t("player.previous")}</span>
|
<span className={navLabel}>{t("player.previous")}</span>
|
||||||
</FocusSafeButton>
|
</Button>
|
||||||
<span className={counter}>
|
<span className={counter}>
|
||||||
{index + 1} / {queueLength}
|
{index + 1} / {queueLength}
|
||||||
</span>
|
</span>
|
||||||
<FocusSafeButton
|
<Button
|
||||||
|
variant={chrome}
|
||||||
|
size="sm"
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
disabled={index === queueLength - 1}
|
disabled={index === queueLength - 1}
|
||||||
|
label={t("player.next")}
|
||||||
title={`${t("player.next")} · Shift+→`}
|
title={`${t("player.next")} · Shift+→`}
|
||||||
className={navBtn}
|
|
||||||
>
|
>
|
||||||
<span className={navLabel}>{t("player.next")}</span>
|
<span className={navLabel}>{t("player.next")}</span>
|
||||||
<SkipForward className="h-4 w-4" />
|
<SkipForward />
|
||||||
</FocusSafeButton>
|
</Button>
|
||||||
<span className={divider} />
|
<span className={divider} />
|
||||||
{/* Persistent playback settings — cycle on click, saved to your account. */}
|
{/* 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}
|
onClick={onCycleAuto}
|
||||||
|
label={`${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`}
|
||||||
title={t("player.autoAdvance.hint")}
|
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}>
|
<span className={toggleLabel}>
|
||||||
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
|
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant={chrome}
|
||||||
|
size="sm"
|
||||||
|
pressed={loopMode !== "off"}
|
||||||
|
icon={loopMode === "one" ? <Repeat1 /> : <Repeat />}
|
||||||
onClick={onCycleLoop}
|
onClick={onCycleLoop}
|
||||||
|
label={`${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`}
|
||||||
title={t("player.loop.hint")}
|
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}>
|
<span className={toggleLabel}>
|
||||||
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
|
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { TFunction } from "i18next";
|
|||||||
import type { Video } from "../lib/api";
|
import type { Video } from "../lib/api";
|
||||||
import { formatDuration } from "../lib/format";
|
import { formatDuration } from "../lib/format";
|
||||||
import type { Column } from "../lib/tableColumns";
|
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`.
|
// Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`.
|
||||||
//
|
//
|
||||||
@@ -56,7 +58,7 @@ export function playlistColumns({
|
|||||||
cardPrimary: true,
|
cardPrimary: true,
|
||||||
render: (v) => (
|
render: (v) => (
|
||||||
<div className="flex items-center gap-2.5 min-w-0">
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
<button
|
<Pressable
|
||||||
onClick={() => onPlay(v)}
|
onClick={() => onPlay(v)}
|
||||||
aria-label={`${t("card.play")} — ${v.title}`}
|
aria-label={`${t("card.play")} — ${v.title}`}
|
||||||
className="relative w-15.5 h-8.75 rounded-sm overflow-hidden bg-surface shrink-0 group"
|
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">
|
<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" />
|
<Play className="w-4 h-4 text-white fill-current" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</Pressable>
|
||||||
<button
|
<Pressable
|
||||||
onClick={() => onPlay(v)}
|
onClick={() => onPlay(v)}
|
||||||
// The row's "open" element: Enter on a keyboard-walked row (lib/listNav) plays it.
|
// The row's "open" element: Enter on a keyboard-walked row (lib/listNav) plays it.
|
||||||
data-nav-open=""
|
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>
|
<span className="block text-[0.8125rem] text-fg truncate">{v.title}</span>
|
||||||
</button>
|
</Pressable>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -129,14 +131,14 @@ export function playlistColumns({
|
|||||||
width: "2.5rem",
|
width: "2.5rem",
|
||||||
nowrap: true,
|
nowrap: true,
|
||||||
render: (v) => (
|
render: (v) => (
|
||||||
<button
|
<IconButton
|
||||||
|
variant="danger"
|
||||||
onClick={() => onRemove(v)}
|
onClick={() => onRemove(v)}
|
||||||
|
label={t("playlists.removeItemOf", { title: v.title })}
|
||||||
title={t("playlists.removeItem")}
|
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" />
|
<X />
|
||||||
</button>
|
</IconButton>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
|
|||||||
import PosterTile from "./PosterTile";
|
import PosterTile from "./PosterTile";
|
||||||
import PlexLibraryPicker from "./PlexLibraryPicker";
|
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||||
import NavGrid from "./ui/NavGrid";
|
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
|
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
|
||||||
// collections have no movie runtime).
|
// collections have no movie runtime).
|
||||||
@@ -109,13 +110,9 @@ function NewCollectionPanel({
|
|||||||
<div className="glass-card mb-6 rounded-2xl p-4">
|
<div className="glass-card mb-6 rounded-2xl p-4">
|
||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
<h3 className="text-sm font-semibold">{t("plex.collection.newCollection")}</h3>
|
<h3 className="text-sm font-semibold">{t("plex.collection.newCollection")}</h3>
|
||||||
<button
|
<IconButton size="md" onClick={onClose} label={t("common.close")}>
|
||||||
onClick={onClose}
|
<X />
|
||||||
aria-label={t("common.close")}
|
</IconButton>
|
||||||
className="glass-hover grid h-8 w-8 place-items-center rounded-lg"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -146,14 +143,15 @@ function NewCollectionPanel({
|
|||||||
term={term}
|
term={term}
|
||||||
enabled={nameOk}
|
enabled={nameOk}
|
||||||
renderAction={(c) => (
|
renderAction={(c) => (
|
||||||
<FocusSafeButton
|
<Button
|
||||||
|
variant="glass"
|
||||||
|
size="xs"
|
||||||
|
icon={<Check />}
|
||||||
onClick={() => create(c)}
|
onClick={() => create(c)}
|
||||||
disabled={busy}
|
loading={busy}
|
||||||
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
<Check className="h-3.5 w-3.5" />
|
|
||||||
{t("plex.collection.createWith")}
|
{t("plex.collection.createWith")}
|
||||||
</FocusSafeButton>
|
</Button>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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"
|
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 && (
|
{isAdmin && !creating && (
|
||||||
<button
|
<Button variant="glass" size="sm" icon={<Plus />} onClick={() => setCreating(true)}>
|
||||||
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" />
|
|
||||||
{t("plex.collection.newCollection")}
|
{t("plex.collection.newCollection")}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{creating && (
|
{creating && (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { RotateCcw } from "lucide-react";
|
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
|
// ±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
|
// muscle-memory shape. Deliberately distinct from the episode-level SkipBack/SkipForward (double
|
||||||
@@ -82,13 +82,14 @@ export function Slider({
|
|||||||
{suffix ?? ""}
|
{suffix ?? ""}
|
||||||
</span>
|
</span>
|
||||||
{onReset && (
|
{onReset && (
|
||||||
<button
|
<IconButton
|
||||||
|
variant="overlay"
|
||||||
onClick={onReset}
|
onClick={onReset}
|
||||||
className="text-white/40 hover:text-white"
|
label={t("common.resetNamed", { name: label })}
|
||||||
aria-label={t("common.resetNamed", { name: label })}
|
className="-my-1.5"
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-3 w-3" />
|
<RotateCcw className="size-3.5!" />
|
||||||
</button>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,14 +147,17 @@ export function Ctrl({
|
|||||||
align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
|
align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
|
||||||
return (
|
return (
|
||||||
<div className="relative flex group/ctrl">
|
<div className="relative flex group/ctrl">
|
||||||
<FocusSafeButton
|
<IconButton
|
||||||
|
variant="overlay"
|
||||||
|
size="player"
|
||||||
|
onMedia
|
||||||
|
noTitle
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={label}
|
label={label}
|
||||||
className="p-1 hover:text-accent disabled:opacity-30"
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</FocusSafeButton>
|
</IconButton>
|
||||||
<span
|
<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`}
|
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 PlexCollectionEditor from "./PlexCollectionEditor";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
import NavGrid from "./ui/NavGrid";
|
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
|
// 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,
|
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
|
||||||
@@ -222,12 +224,9 @@ export function PlexPersonView({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Chip onClick={() => onFilter({ actors: [d.name] })} className="font-medium">
|
||||||
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"
|
|
||||||
>
|
|
||||||
{t("plex.person.filterBy", { name: d.name })}
|
{t("plex.person.filterBy", { name: d.name })}
|
||||||
</button>
|
</Chip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{d.biography && (
|
{d.biography && (
|
||||||
@@ -428,7 +427,7 @@ export function PlexShowView({
|
|||||||
if (!badge) return null;
|
if (!badge) return null;
|
||||||
const { score } = badge;
|
const { score } = badge;
|
||||||
return (
|
return (
|
||||||
<button
|
<Pressable
|
||||||
onClick={
|
onClick={
|
||||||
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
|
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
|
||||||
}
|
}
|
||||||
@@ -437,7 +436,7 @@ export function PlexShowView({
|
|||||||
{/* decorative rating gold — intentionally NOT a status token */}
|
{/* decorative rating gold — intentionally NOT a status token */}
|
||||||
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
|
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
|
||||||
{score.toFixed(1)}
|
{score.toFixed(1)}
|
||||||
</button>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
@@ -645,12 +644,9 @@ export function PlexSeasonView({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<button
|
<Button variant="link-muted" onClick={onBack} className="text-sm">
|
||||||
onClick={onBack}
|
|
||||||
className="text-sm text-muted hover:text-accent transition text-left"
|
|
||||||
>
|
|
||||||
{d.show.title}
|
{d.show.title}
|
||||||
</button>
|
</Button>
|
||||||
<h1 className="text-2xl font-semibold leading-tight">{se.title}</h1>
|
<h1 className="text-2xl font-semibold leading-tight">{se.title}</h1>
|
||||||
<p className="text-sm text-muted mt-1">
|
<p className="text-sm text-muted mt-1">
|
||||||
{t("plex.series.episodeCount", { count: se.episode_count })}
|
{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 { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||||
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
||||||
import { closeThenAct, useAnchoredPopover } from "./anchoredPopoverHooks";
|
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.
|
// 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
|
// 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" });
|
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" });
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
{/* A caller-shaped trigger (a labelled pill) is a surface; the default "…" is a glyph. */}
|
||||||
type="button"
|
{className ? (
|
||||||
ref={pop.refs.setReference}
|
<Pressable
|
||||||
{...pop.getReferenceProps()}
|
ref={pop.refs.setReference}
|
||||||
aria-label={ariaLabel}
|
{...pop.getReferenceProps()}
|
||||||
title={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
className={
|
title={ariaLabel}
|
||||||
className ??
|
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" />}
|
||||||
>
|
</Pressable>
|
||||||
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
|
) : (
|
||||||
</button>
|
<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}>
|
<PopoverSurface popover={pop} ariaLabel={ariaLabel} className={MENU_SURFACE}>
|
||||||
<MenuItemList items={items} onClose={() => setOpen(false)} />
|
<MenuItemList items={items} onClose={() => setOpen(false)} />
|
||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { ArrowUp } from "lucide-react";
|
import { ArrowUp } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
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
|
// 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
|
// 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"].
|
// 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.
|
// The accessible name comes from a visually-hidden child instead, which those selectors can't match.
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<button
|
<Pressable
|
||||||
|
align="center"
|
||||||
onClick={toTop}
|
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 ${
|
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"
|
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" />
|
<ArrowUp className="w-7 h-7" aria-hidden="true" />
|
||||||
<span className="sr-only">{t("common.backToTop")}</span>
|
<span className="sr-only">{t("common.backToTop")}</span>
|
||||||
</button>,
|
</Pressable>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,21 @@ import type { ReactNode } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
|
import { Button } from "./Button";
|
||||||
import { IconButton } from "./IconButton";
|
import { IconButton } from "./IconButton";
|
||||||
|
|
||||||
type BannerTone = "accent" | "warning";
|
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: {
|
accent: {
|
||||||
bar: "bg-accent/15 border-accent/30",
|
bar: "bg-accent/15 border-accent/30",
|
||||||
icon: "text-accent",
|
icon: "text-accent",
|
||||||
action: "btn-primary",
|
action: "primary",
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
bar: "bg-warn/15 border-warn/30",
|
bar: "bg-warn/15 border-warn/30",
|
||||||
icon: "text-warn",
|
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}`} />
|
<Icon className={`w-4 h-4 shrink-0 ${c.icon}`} />
|
||||||
<span className="min-w-0">{children}</span>
|
<span className="min-w-0">{children}</span>
|
||||||
{action && (
|
{action && (
|
||||||
<button
|
<Button
|
||||||
|
variant={c.action}
|
||||||
|
size="sm"
|
||||||
onClick={action.onClick}
|
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}
|
{action.label}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onDismiss && (
|
{onDismiss && (
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -69,7 +72,7 @@ export default function Banner({
|
|||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
className="ml-auto"
|
className="ml-auto"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { FocusSafeButton } from "./FocusSafeButton";
|
|||||||
type ButtonVariant =
|
type ButtonVariant =
|
||||||
| "primary" // solid accent — the main affirmative action
|
| "primary" // solid accent — the main affirmative action
|
||||||
| "danger" // solid danger — destructive confirm
|
| "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" // bordered, quiet — secondary action (Full history, Subscribe, Cancel-with-border)
|
||||||
| "outline-accent" // bordered + accent-tinted — an affirmative secondary (Activate, Approve)
|
| "outline-accent" // bordered + accent-tinted — an affirmative secondary (Activate, Approve)
|
||||||
| "outline-danger" // neutral at rest, danger on hover — a row-level Delete
|
| "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> = {
|
const VARIANTS: Record<ButtonVariant, string> = {
|
||||||
primary: "font-medium state-filled",
|
primary: "font-medium state-filled",
|
||||||
danger: "font-medium state-danger",
|
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: `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",
|
"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
|
// 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 clsx from "clsx";
|
||||||
import { useScrollEdges } from "../../lib/useScrollEdges";
|
import { useScrollEdges } from "../../lib/useScrollEdges";
|
||||||
import { useListNav } from "../../lib/listNav";
|
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
|
// 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
|
// 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`
|
// 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.
|
// 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 =
|
const arrowBtn =
|
||||||
"reveal-hover absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " +
|
"reveal-hover absolute top-1/2 -translate-y-1/2 z-10 backdrop-blur-xs border border-white/15 shadow-lg " +
|
||||||
"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";
|
||||||
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("group/car relative", className)}>
|
<div className={clsx("group/car relative", className)}>
|
||||||
@@ -106,27 +108,25 @@ export function Carousel({
|
|||||||
<div className="mb-3 flex items-baseline gap-2">
|
<div className="mb-3 flex items-baseline gap-2">
|
||||||
<h2 className="text-sm font-semibold">{title}</h2>
|
<h2 className="text-sm font-semibold">{title}</h2>
|
||||||
{onSeeAll && (
|
{onSeeAll && (
|
||||||
<button
|
<Button variant="link-muted" onClick={onSeeAll} className="ml-auto gap-0.5 text-xs">
|
||||||
onClick={onSeeAll}
|
|
||||||
className="ml-auto inline-flex items-center gap-0.5 text-xs text-muted transition hover:text-accent"
|
|
||||||
>
|
|
||||||
{seeAllLabel ?? t("common.seeAll")}
|
{seeAllLabel ?? t("common.seeAll")}
|
||||||
<ChevronRight className="h-3.5 w-3.5" />
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{left && (
|
{left && (
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
variant="media"
|
||||||
|
size="md"
|
||||||
onClick={() => page(-1)}
|
onClick={() => page(-1)}
|
||||||
aria-label={t("common.scrollLeft")}
|
label={t("common.scrollLeft")}
|
||||||
className={clsx(arrowBtn, "left-1")}
|
className={clsx(arrowBtn, "left-1")}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-5 w-5" />
|
<ChevronLeft />
|
||||||
</button>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
{/* The px-1/pt-1 against -mx-1/-mt-1 keep the layout and give the keyboard ring room: a
|
{/* 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. */}
|
scroller clips its overflow, and the ring sits 4px outside the card it surrounds. */}
|
||||||
@@ -140,14 +140,15 @@ export function Carousel({
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
{right && (
|
{right && (
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
variant="media"
|
||||||
|
size="md"
|
||||||
onClick={() => page(1)}
|
onClick={() => page(1)}
|
||||||
aria-label={t("common.scrollRight")}
|
label={t("common.scrollRight")}
|
||||||
className={clsx(arrowBtn, "right-1")}
|
className={clsx(arrowBtn, "right-1")}
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-5 w-5" />
|
<ChevronRight />
|
||||||
</button>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 Modal from "./Modal";
|
||||||
import { closeTopOverlayThenRun } from "../../lib/history";
|
import { closeTopOverlayThenRun } from "../../lib/history";
|
||||||
import { pageStep, pageTarget } from "../../lib/listPaging";
|
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
|
// 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
|
// 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}
|
{g.label}
|
||||||
</div>
|
</div>
|
||||||
{g.items.map((item) => (
|
{g.items.map((item) => (
|
||||||
<button
|
<Pressable
|
||||||
key={item.id}
|
key={item.id}
|
||||||
id={`${listId}-${item.id}`}
|
id={`${listId}-${item.id}`}
|
||||||
ref={item.id === activeId ? activeRef : undefined}
|
ref={item.id === activeId ? activeRef : undefined}
|
||||||
data-cmd-id={item.id}
|
data-cmd-id={item.id}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={item.id === activeId}
|
aria-selected={item.id === activeId}
|
||||||
type="button"
|
|
||||||
// The combobox input owns the tab stop; the arrows move a VIRTUAL selection over
|
// 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
|
// these rows. Leaving them tabbable put DOM focus on a row while
|
||||||
// aria-activedescendant named a different one — two competing "current" options —
|
// 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;
|
if (e.movementX === 0 && e.movementY === 0) return;
|
||||||
setPicked(item.id);
|
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.id === activeId ? "bg-card text-fg" : "text-muted hover:text-fg"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.icon && <span className="shrink-0">{item.icon}</span>}
|
{item.icon && <span className="shrink-0">{item.icon}</span>}
|
||||||
<span className="min-w-0 flex-1 truncate">{item.label}</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>}
|
{item.hint && <span className="shrink-0 text-xs text-muted">{item.hint}</span>}
|
||||||
</button>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// paint is CSS (`.pill[aria-pressed]`), so we assert the aria-pressed / onChange / testId contract.
|
// paint is CSS (`.pill[aria-pressed]`), so we assert the aria-pressed / onChange / testId contract.
|
||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
import FilterChipGroup from "./FilterChipGroup";
|
||||||
|
|
||||||
const OPTS = [
|
const OPTS = [
|
||||||
{ value: "all", label: "All", testId: "chip-all" },
|
{ value: "all", label: "All", testId: "chip-all" },
|
||||||
@@ -56,8 +56,12 @@ describe("FilterChipGroup", () => {
|
|||||||
expect(screen.getByRole("group", { name: "Sync state" })).toBeTruthy();
|
expect(screen.getByRole("group", { name: "Sync state" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exports the canonical chip class for multi-select siblings to reuse", () => {
|
it("renders its options as Chips: the pill, on the 24px rung", () => {
|
||||||
expect(filterChipClass).toContain("rounded-full");
|
render(<FilterChipGroup options={OPTS} value="all" onChange={vi.fn()} ariaLabel="Sync" />);
|
||||||
expect(filterChipClass).toContain("pill");
|
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 { type ReactNode } from "react";
|
||||||
import CountBadge from "./CountBadge";
|
import CountBadge from "./CountBadge";
|
||||||
|
import { Chip } from "./Chip";
|
||||||
// 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";
|
|
||||||
|
|
||||||
export interface FilterChipOption<T extends string> {
|
export interface FilterChipOption<T extends string> {
|
||||||
value: T;
|
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,
|
// 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>({
|
export default function FilterChipGroup<T extends string>({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
@@ -39,18 +32,17 @@ export default function FilterChipGroup<T extends string>({
|
|||||||
{options.map((o) => {
|
{options.map((o) => {
|
||||||
const on = value === o.value;
|
const on = value === o.value;
|
||||||
return (
|
return (
|
||||||
<button
|
<Chip
|
||||||
key={o.value}
|
key={o.value}
|
||||||
data-testid={o.testId}
|
data-testid={o.testId}
|
||||||
onClick={() => onChange(o.value)}
|
onClick={() => onChange(o.value)}
|
||||||
title={o.title}
|
title={o.title}
|
||||||
aria-pressed={on}
|
pressed={on}
|
||||||
className={filterChipClass}
|
icon={o.icon}
|
||||||
>
|
>
|
||||||
{o.icon}
|
|
||||||
<span>{o.label}</span>
|
<span>{o.label}</span>
|
||||||
{o.count != null && o.count > 0 && <CountBadge count={o.count} active={on} />}
|
{o.count != null && o.count > 0 && <CountBadge count={o.count} active={on} />}
|
||||||
</button>
|
</Chip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ import { FocusSafeButton } from "./FocusSafeButton";
|
|||||||
|
|
||||||
type IconButtonVariant =
|
type IconButtonVariant =
|
||||||
| "ghost" // the default: quiet glyph, a state layer on hover
|
| "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)
|
| "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" // 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-accent" // bordered + accent — an affirmative row action (Approve)
|
||||||
| "outline-danger" // bordered, danger on hover — a row's destructive action beside bordered siblings
|
| "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)
|
| "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" // 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
|
| "glass" // glass card — beside the glass Buttons on a Plex page
|
||||||
| "media" // round, dark translucent — on a thumbnail or a poster
|
| "media" // round, dark translucent — on a thumbnail or a poster
|
||||||
| "overlay"; // white glyph on the always-dark player chrome, whatever the theme
|
| "overlay"; // white glyph on the always-dark player chrome, whatever the theme
|
||||||
@@ -24,17 +26,20 @@ type IconButtonVariant =
|
|||||||
type IconButtonSize =
|
type IconButtonSize =
|
||||||
| "sm" // 28 — rows and dense toolbars
|
| "sm" // 28 — rows and dense toolbars
|
||||||
| "md" // 32 — on its own
|
| "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 ACT = "enabled:not-aria-disabled:";
|
||||||
const VARIANTS: Record<IconButtonVariant, string> = {
|
const VARIANTS: Record<IconButtonVariant, string> = {
|
||||||
ghost: `text-muted state-layer ${ACT}hover:text-fg`,
|
ghost: `text-muted state-layer ${ACT}hover:text-fg`,
|
||||||
|
"ghost-accent": "text-accent state-layer",
|
||||||
primary: "state-filled",
|
primary: "state-filled",
|
||||||
outline: `border border-border text-muted state-layer ${ACT}hover:text-fg`,
|
outline: `border border-border text-muted state-layer ${ACT}hover:text-fg`,
|
||||||
"outline-accent": "border border-accent/40 text-accent state-layer",
|
"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-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",
|
"outline-warn": "border border-warn/40 text-warn state-layer",
|
||||||
danger: `text-muted state-layer ${ACT}hover:text-danger`,
|
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`,
|
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
|
// 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).
|
// 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",
|
sm: "size-7 [&>svg]:size-4",
|
||||||
md: "size-8 [&>svg]:size-4",
|
md: "size-8 [&>svg]:size-4",
|
||||||
lg: "size-12 [&>svg]:size-6",
|
lg: "size-12 [&>svg]:size-6",
|
||||||
|
player: "size-9 [&>svg]:size-5",
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
||||||
@@ -94,7 +100,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|||||||
aria-pressed={pressed}
|
aria-pressed={pressed}
|
||||||
title={noTitle ? undefined : label}
|
title={noTitle ? undefined : label}
|
||||||
className={clsx(
|
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],
|
VARIANTS[variant],
|
||||||
onMedia || variant === "media" ? "rounded-full" : "rounded-lg",
|
onMedia || variant === "media" ? "rounded-full" : "rounded-lg",
|
||||||
pressed !== undefined && PRESSED,
|
pressed !== undefined && PRESSED,
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { hasPagerContent, pageCount } from "./pagerContent";
|
import { hasPagerContent, pageCount } from "./pagerContent";
|
||||||
import { FocusSafeButton } from "./FocusSafeButton";
|
|
||||||
import SegmentedToggle from "./SegmentedToggle";
|
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 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 +
|
// (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">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FocusSafeButton
|
<Button
|
||||||
|
variant="glass"
|
||||||
|
size="sm"
|
||||||
|
icon={<ChevronLeft />}
|
||||||
onClick={() => goToPage(Math.max(0, safePage - 1))}
|
onClick={() => goToPage(Math.max(0, safePage - 1))}
|
||||||
disabled={safePage === 0}
|
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")}
|
{t("datatable.pager.prev")}
|
||||||
</FocusSafeButton>
|
</Button>
|
||||||
<span className="text-muted flex items-center gap-1.5">
|
<span className="text-muted flex items-center gap-1.5">
|
||||||
{t("datatable.pager.pageLabel")}
|
{t("datatable.pager.pageLabel")}
|
||||||
<input
|
<input
|
||||||
@@ -89,14 +90,15 @@ export default function Pager({
|
|||||||
/>
|
/>
|
||||||
{t("datatable.pager.ofTotal", { total: totalPages })}
|
{t("datatable.pager.ofTotal", { total: totalPages })}
|
||||||
</span>
|
</span>
|
||||||
<FocusSafeButton
|
<Button
|
||||||
|
variant="glass"
|
||||||
|
size="sm"
|
||||||
onClick={() => goToPage(Math.min(totalPages - 1, safePage + 1))}
|
onClick={() => goToPage(Math.min(totalPages - 1, safePage + 1))}
|
||||||
disabled={safePage >= totalPages - 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")}
|
{t("datatable.pager.next")}
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
</FocusSafeButton>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{total > 0 && (
|
{total > 0 && (
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { type ReactNode, type CSSProperties } from "react";
|
import { type ReactNode, type CSSProperties } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { CSS } from "@dnd-kit/utilities";
|
||||||
import { useSortableItem } from "./sortableA11y";
|
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
|
// 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
|
// 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 titleIsTrigger = !editing && !collapsible && !!onTitleClick;
|
||||||
|
|
||||||
const titleCls =
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -58,55 +61,53 @@ export default function PanelGroup({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5 px-3 py-2">
|
<div className="flex items-center gap-1.5 px-3 py-2">
|
||||||
{editing && (
|
{editing && (
|
||||||
<button
|
<DragHandle
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
title={t("sidebar.dragToReorder")}
|
label={t("sidebar.dragToReorder")}
|
||||||
className="-ml-1 cursor-grab active:cursor-grabbing text-muted hover:text-fg touch-none"
|
className="-ml-1"
|
||||||
>
|
/>
|
||||||
<GripVertical className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
{collapsible && !editing ? (
|
{collapsible && !editing ? (
|
||||||
<button
|
<Pressable
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
className={`${titleCls} text-muted hover:text-fg transition`}
|
className={`${titleCls} text-muted hover:text-fg transition`}
|
||||||
>
|
>
|
||||||
<span className="truncate">{title}</span>
|
<span className="truncate">{title}</span>
|
||||||
</button>
|
</Pressable>
|
||||||
) : titleIsTrigger ? (
|
) : titleIsTrigger ? (
|
||||||
<button
|
<Pressable
|
||||||
onClick={onTitleClick}
|
onClick={onTitleClick}
|
||||||
title={titleActionLabel}
|
title={titleActionLabel}
|
||||||
className={`${titleCls} group text-muted hover:text-accent transition`}
|
className={`${titleCls} group text-muted hover:text-accent transition`}
|
||||||
>
|
>
|
||||||
<span className="truncate">{title}</span>
|
<span className="truncate">{title}</span>
|
||||||
<Pencil className="w-3 h-3 shrink-0 text-muted/60 group-hover:text-accent" />
|
<Pencil className="w-3 h-3 shrink-0 text-muted/60 group-hover:text-accent" />
|
||||||
</button>
|
</Pressable>
|
||||||
) : (
|
) : (
|
||||||
<span className={`${titleCls} text-muted`}>
|
<span className={`${titleCls} text-muted`}>
|
||||||
<span className="truncate">{title}</span>
|
<span className="truncate">{title}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<button
|
<IconButton
|
||||||
onClick={onToggleHidden}
|
onClick={onToggleHidden}
|
||||||
title={hidden ? t("sidebar.showWidget") : t("sidebar.hideWidget")}
|
label={hidden ? t("sidebar.showWidget") : t("sidebar.hideWidget")}
|
||||||
className="text-muted hover:text-fg"
|
|
||||||
>
|
>
|
||||||
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
{hidden ? <EyeOff /> : <Eye />}
|
||||||
</button>
|
</IconButton>
|
||||||
) : (
|
) : (
|
||||||
collapsible && (
|
collapsible && (
|
||||||
<button
|
<IconButton
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
|
aria-expanded={!collapsed}
|
||||||
className="text-muted hover:text-fg"
|
label={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
|
||||||
>
|
>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`}
|
className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</IconButton>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ domain-free.
|
|||||||
| area | components |
|
| 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` |
|
| 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` |
|
| 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` |
|
| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
| 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
|
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:
|
~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
|
- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES`, `IconButton`'s and `Chip`'s sizes
|
||||||
rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32). Pick a rung; do not hand-roll
|
are the rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32, chips 24 / 28; the C5 media
|
||||||
padding on a primitive.
|
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`
|
- **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
|
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
|
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 { useScrollFade } from "../../lib/useScrollFade";
|
||||||
import { useHoverFocusWithin } from "../../lib/useHoverFocusWithin";
|
import { useHoverFocusWithin } from "../../lib/useHoverFocusWithin";
|
||||||
import { IconButton } from "./IconButton";
|
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
|
// 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
|
// 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
|
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.
|
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. */}
|
`opacity-0` (not `invisible`/`hidden`) keeps it hoverable and focusable. */}
|
||||||
<button
|
<Pressable
|
||||||
{...peek.hoverHandlers}
|
{...peek.hoverHandlers}
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
|
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
|
||||||
@@ -92,7 +93,7 @@ export default function SidePanel({
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</Pressable>
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<aside
|
<aside
|
||||||
@@ -113,14 +114,14 @@ export default function SidePanel({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
|
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
|
||||||
{onTitleClick ? (
|
{onTitleClick ? (
|
||||||
<button
|
<Pressable
|
||||||
onClick={onTitleClick}
|
onClick={onTitleClick}
|
||||||
title={titleActionLabel}
|
title={titleActionLabel}
|
||||||
aria-label={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}
|
{title}
|
||||||
</button>
|
</Pressable>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em]">
|
<span className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em]">
|
||||||
{title}
|
{title}
|
||||||
@@ -145,15 +146,13 @@ export default function SidePanel({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
{onToggleEditing && (
|
{onToggleEditing && (
|
||||||
<button
|
<IconButton
|
||||||
|
variant={editing ? "ghost-accent" : "ghost"}
|
||||||
onClick={onToggleEditing}
|
onClick={onToggleEditing}
|
||||||
title={editing ? t("sidebar.done") : t("sidebar.customize")}
|
label={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"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{editing ? <Check className="w-4 h-4" /> : <Pencil className="w-4 h-4" />}
|
{editing ? <Check /> : <Pencil />}
|
||||||
</button>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { createStore } from "../../lib/store";
|
|||||||
import { useToastDock } from "../../lib/toastDock";
|
import { useToastDock } from "../../lib/toastDock";
|
||||||
import { useOverlayOrder } from "./overlayOrder";
|
import { useOverlayOrder } from "./overlayOrder";
|
||||||
import { LEVEL_STYLE } from "./toastLevels";
|
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
|
// 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
|
// context (its `@container` implies layout containment) — so anything portalled to <body> at the
|
||||||
@@ -170,24 +172,25 @@ export default function Toaster({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{toast.action && (
|
{toast.action && (
|
||||||
<button
|
<Button
|
||||||
|
variant="link"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
toast.action!.onClick();
|
toast.action!.onClick();
|
||||||
dismiss(toast.id);
|
dismiss(toast.id);
|
||||||
}}
|
}}
|
||||||
className="mt-1 text-accent text-sm font-semibold hover:underline"
|
className="mt-1 text-sm"
|
||||||
>
|
>
|
||||||
{toast.action.label}
|
{toast.action.label}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<IconButton
|
||||||
onClick={() => dismiss(toast.id)}
|
onClick={() => dismiss(toast.id)}
|
||||||
className="shrink-0 text-muted hover:text-fg"
|
label={t("notifications.dismiss")}
|
||||||
title={t("notifications.dismiss")}
|
className="-m-1.5"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X />
|
||||||
</button>
|
</IconButton>
|
||||||
{toast.duration && (
|
{toast.duration && (
|
||||||
// Keyed by `ts`: a coalesced repeat re-arms the dismiss timer, so its bar restarts too.
|
// 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} />
|
<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 { useTranslation } from "react-i18next";
|
||||||
import { Redo2, Undo2 } from "lucide-react";
|
import { Redo2, Undo2 } from "lucide-react";
|
||||||
import { dispatch, useKeyScope } from "../../lib/keymap";
|
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
|
// 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
|
// (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);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [canUndo, canRedo, onUndo, onRedo]);
|
}, [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 (
|
return (
|
||||||
<div className="inline-flex items-center gap-1">
|
<div className="inline-flex items-center gap-1">
|
||||||
<FocusSafeButton
|
<IconButton variant="outline" onClick={onUndo} disabled={!canUndo} label={t("common.undo")}>
|
||||||
onClick={onUndo}
|
<Undo2 />
|
||||||
disabled={!canUndo}
|
</IconButton>
|
||||||
title={t("common.undo")}
|
<IconButton variant="outline" onClick={onRedo} disabled={!canRedo} label={t("common.redo")}>
|
||||||
aria-label={t("common.undo")}
|
<Redo2 />
|
||||||
className={btn}
|
</IconButton>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</div>
|
</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 =
|
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";
|
"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
|
/** 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. */
|
* the accessible name (the visible row text) since the control itself has no inner text. */
|
||||||
export function Switch({
|
export function Switch({
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { type SortState } from "../../lib/columnSort";
|
|||||||
import { isActive, type Column, type FilterMap } from "../../lib/tableColumns";
|
import { isActive, type Column, type FilterMap } from "../../lib/tableColumns";
|
||||||
import { alignClass } from "./tableAlign";
|
import { alignClass } from "./tableAlign";
|
||||||
import { useStickyTop } from "./stickyTop";
|
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
|
// 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
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<IconButton
|
||||||
|
variant={filterOn ? "ghost-accent" : "ghost"}
|
||||||
ref={pop.refs.setReference}
|
ref={pop.refs.setReference}
|
||||||
{...pop.getReferenceProps()}
|
{...pop.getReferenceProps()}
|
||||||
aria-label={t("datatable.filter")}
|
label={t("datatable.filter")}
|
||||||
// Generous padding: the icon alone was a 14x14 hit target, the smallest in the app.
|
// The icon alone was a 14x14 hit target, the smallest in the app; the sm rung is 28.
|
||||||
className={`-m-1.5 p-1.5 transition ${
|
className="-my-1.5"
|
||||||
filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<ListFilter className="w-3.5 h-3.5" />
|
<ListFilter className="size-3.5!" />
|
||||||
</button>
|
</IconButton>
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={pop}
|
popover={pop}
|
||||||
ariaLabel={col.header}
|
ariaLabel={col.header}
|
||||||
@@ -167,12 +169,13 @@ function FilterFunnel<T>({
|
|||||||
</FilterOptionList>
|
</FilterOptionList>
|
||||||
)}
|
)}
|
||||||
{filterOn && (
|
{filterOn && (
|
||||||
<button
|
<Button
|
||||||
|
variant="link-muted"
|
||||||
onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")}
|
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")}
|
{t("datatable.clear")}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
</>
|
</>
|
||||||
@@ -229,7 +232,7 @@ export function ColumnHead<T>({
|
|||||||
focusable no-op button, which put unlabeled dead stops in the tab order —
|
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. */}
|
two of them per playlist table, whose grip and actions headers are empty. */}
|
||||||
{col.sortable ? (
|
{col.sortable ? (
|
||||||
<button
|
<Pressable
|
||||||
onClick={() => onToggleSort(col.key)}
|
onClick={() => onToggleSort(col.key)}
|
||||||
className="inline-flex items-center gap-1 hover:text-fg cursor-pointer"
|
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" />
|
<ArrowDown className="w-3 h-3" />
|
||||||
))}
|
))}
|
||||||
</button>
|
</Pressable>
|
||||||
) : (
|
) : (
|
||||||
<span>{col.header}</span>
|
<span>{col.header}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useLayoutEffect, useState } from "react";
|
import { useLayoutEffect, useState } from "react";
|
||||||
import clsx from "clsx";
|
|
||||||
import { SCHEMES, type Scheme } from "../lib/theme";
|
import { SCHEMES, type Scheme } from "../lib/theme";
|
||||||
import { BatchA } from "./batchA";
|
import { BatchA } from "./batchA";
|
||||||
import { BatchB } from "./batchB";
|
import { BatchB } from "./batchB";
|
||||||
import { BatchC } from "./batchC";
|
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
|
// 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 /
|
// 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));
|
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() {
|
export default function DesignLab() {
|
||||||
const [look, setLook] = useState<Look>(START);
|
const [look, setLook] = useState<Look>(START);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -47,36 +41,22 @@ export default function DesignLab() {
|
|||||||
<strong className="text-sm">Design lab</strong>
|
<strong className="text-sm">Design lab</strong>
|
||||||
<span className="flex gap-1">
|
<span className="flex gap-1">
|
||||||
{(["dark", "light"] as const).map((m) => (
|
{(["dark", "light"] as const).map((m) => (
|
||||||
<button
|
<Chip key={m} pressed={look.mode === m} onClick={() => set({ mode: m })}>
|
||||||
key={m}
|
|
||||||
type="button"
|
|
||||||
className={chip(look.mode === m)}
|
|
||||||
onClick={() => set({ mode: m })}
|
|
||||||
>
|
|
||||||
{m}
|
{m}
|
||||||
</button>
|
</Chip>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex flex-wrap gap-1">
|
<span className="flex flex-wrap gap-1">
|
||||||
{SCHEMES.map((s) => (
|
{SCHEMES.map((s) => (
|
||||||
<button
|
<Chip key={s.id} pressed={look.scheme === s.id} onClick={() => set({ scheme: s.id })}>
|
||||||
key={s.id}
|
|
||||||
type="button"
|
|
||||||
className={chip(look.scheme === s.id)}
|
|
||||||
onClick={() => set({ scheme: s.id })}
|
|
||||||
>
|
|
||||||
<span className="h-2.5 w-2.5 rounded-full" style={{ background: s.swatch }} />
|
<span className="h-2.5 w-2.5 rounded-full" style={{ background: s.swatch }} />
|
||||||
{s.name}
|
{s.name}
|
||||||
</button>
|
</Chip>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<Chip pressed={look.backdrop} onClick={() => set({ backdrop: !look.backdrop })}>
|
||||||
type="button"
|
|
||||||
className={chip(look.backdrop)}
|
|
||||||
onClick={() => set({ backdrop: !look.backdrop })}
|
|
||||||
>
|
|
||||||
backdrop
|
backdrop
|
||||||
</button>
|
</Chip>
|
||||||
<label className="flex items-center gap-2 text-xs text-muted">
|
<label className="flex items-center gap-2 text-xs text-muted">
|
||||||
text {look.fontScale.toFixed(2)}
|
text {look.fontScale.toFixed(2)}
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { Btn } from "./batchA";
|
|||||||
import { hexContrast, hueGap } from "../lib/colorMath";
|
import { hexContrast, hueGap } from "../lib/colorMath";
|
||||||
import { Reference, Sample } from "./labKit";
|
import { Reference, Sample } from "./labKit";
|
||||||
import { FADE_ABOVE_PANEL, FADE_BELOW, FADE_BELOW_PANEL, FILLED, H, LAYER } from "./labTokens";
|
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,
|
// 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
|
// 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>
|
{s.name} <span className="font-normal text-muted">· {mode}</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<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="state-filled 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-danger rounded-lg px-2.5 py-1 text-xs font-medium">Delete</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5 text-2xs">
|
<div className="flex flex-wrap gap-1.5 text-2xs">
|
||||||
<span className="chip px-2 py-0.5">Filter</span>
|
<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 className="text-xs text-muted">Techno Tim</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="flex gap-1.5">
|
<span className="flex gap-1.5">
|
||||||
<button
|
<Chip size="md" pressed icon={<Repeat className="size-3.5" />} className="font-medium">
|
||||||
type="button"
|
Repeat
|
||||||
className={clsx("pill inline-flex items-center rounded-full border font-medium", H.md)}
|
</Chip>
|
||||||
aria-pressed
|
|
||||||
>
|
|
||||||
<Repeat className="h-3.5 w-3.5" /> Repeat
|
|
||||||
</button>
|
|
||||||
<Btn tone="glass" size="md" icon={<Download className={ico} />}>
|
<Btn tone="glass" size="md" icon={<Download className={ico} />}>
|
||||||
Download
|
Download
|
||||||
</Btn>
|
</Btn>
|
||||||
|
|||||||
@@ -98,7 +98,6 @@
|
|||||||
"removeItem": "Remove",
|
"removeItem": "Remove",
|
||||||
"removeItemOf": "Remove “{{title}}” from the playlist",
|
"removeItemOf": "Remove “{{title}}” from the playlist",
|
||||||
"confirmRemove": "Remove “{{title}}” from this playlist?",
|
"confirmRemove": "Remove “{{title}}” from this playlist?",
|
||||||
"dragHandle": "Drag to reorder",
|
|
||||||
"dragHandleOf": "Reorder “{{title}}”",
|
"dragHandleOf": "Reorder “{{title}}”",
|
||||||
"dragBlockedSort": "Reordering by hand is off while a column sort or grouping is applied.",
|
"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.",
|
"dragBlockedFilter": "Reordering by hand is off while a filter is applied.",
|
||||||
|
|||||||
@@ -98,7 +98,6 @@
|
|||||||
"removeItem": "Eltávolítás",
|
"removeItem": "Eltávolítás",
|
||||||
"removeItemOf": "„{{title}}” eltávolítása a listáról",
|
"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?",
|
"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",
|
"dragHandleOf": "„{{title}}” átrendezése",
|
||||||
"dragBlockedSort": "A kézi átrendezés ki van kapcsolva, amíg oszloprendezés vagy csoportosítás van érvényben.",
|
"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.",
|
"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));
|
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. */
|
/* A4: a control's ≥ 3:1 edge. */
|
||||||
@utility edge-strong {
|
@utility edge-strong {
|
||||||
border-color: var(--edge-strong);
|
border-color: var(--edge-strong);
|
||||||
@@ -618,30 +628,6 @@ body {
|
|||||||
color: var(--accent-fg);
|
color: var(--accent-fg);
|
||||||
border-color: var(--accent);
|
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) =====
|
/* ===== 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
|
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
|
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