release: 0.82.0 (feature/e10-platform-ergonomics)
gate / frontend (push) Failing after 2m7s
gate / backend (push) Successful in 3m36s
gate / frontend (push) Failing after 2m7s
gate / backend (push) Successful in 3m36s
This commit is contained in:
@@ -7,6 +7,35 @@ edit the release notes and re-run the generator.
|
||||
|
||||
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||
|
||||
## 0.82.0 — 2026-09-06
|
||||
|
||||
Siftlode gets a keyboard. There is one place that lists every shortcut, a jump-to box that takes you anywhere by name, and a right-click menu on playlist rows — plus the whole app now holds together at a tablet's width and stops thinning itself out on a very wide screen.
|
||||
|
||||
### Features
|
||||
|
||||
- Press h anywhere and Siftlode tells you what you can press. One sheet for the whole app, and it only lists keys that actually do something where you are standing — the player's keys inside the player, undo where there is something to undo. It is h rather than the usual ?, because on a Hungarian keyboard ? is a two-key chord and h is already the key you use in the Plex player.
|
||||
- Ctrl+K (⌘K on a Mac) opens a jump-to box: start typing and it finds your modules, channels, playlists and saved views by name, plus a few actions like switching theme. It ignores accents and word order, so "penzugy" finds "Pénzügy" and "pod thevr" finds "TheVR Podcast".
|
||||
- Playlist rows answer a right-click. The same menu the ⋯ button opens — right-click anywhere on the row, long-press on a tablet, or Shift+F10 from the keyboard. Rail rows can be renamed in place and deleted from it, and a video can be moved straight to another list through a submenu.
|
||||
- Moving a video between lists is now one request instead of two. It used to add it there and then remove it here, which left the video in both lists whenever the second step failed; and if the move cannot be done, it now says so instead of the row quietly sliding back.
|
||||
- The app is built for 1024×768 as its floor — a tablet in landscape — and every page is measured against it rather than eyeballed. That caught a bug no screenshot could show: a hidden element used to measure filter chips was dragging the whole page 249px sideways on the channel manager, pulling the header off-screen with it.
|
||||
- A very wide screen now gets bigger cards instead of more of them. At 3440px the feed used to lay out eleven narrow columns and keep the cards at their minimum size; it now spends the extra width on the cards, which is what a screen you watch from across the room wants. Nothing changes at 1024, 1920 or a normal 2560.
|
||||
- Switching audio or subtitle track in the Plex player finally tells you where you landed. The keys already worked — what was missing was the answer, so you had to open the tracks menu to see which one you had picked.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Ctrl+A in the Plex player selects text again instead of cycling the audio track. Six separate keyboard handlers had drifted apart; the one in the player matched the letter without checking whether Ctrl was held.
|
||||
- Typing into a dropdown or an editable area no longer triggers app shortcuts. Some surfaces checked for a text box and a text area but not for either of those.
|
||||
- The keyboard sheet no longer promises keys that do nothing where you are. Backspace does not claim to go back one level while the player is up, because there it does not.
|
||||
- Choosing a destination from the jump-to box lands you there. A command that navigates used to be undone by the box closing a fraction of a second later.
|
||||
- A button that is switched off no longer lights up under the pointer as though it could be clicked.
|
||||
- Two buttons in Messages announced themselves to a screen reader as just "button"; both are the only way out of the view they sit in.
|
||||
- In Hungarian, the built-in list is called "Megnézem később" everywhere, including in the message explaining that it cannot be renamed — which used to call it "Watch later".
|
||||
|
||||
### Internal
|
||||
|
||||
- E10, four sprints. S1 finished a half-done component adoption: the app's shared button primitive gained the variants the call sites proved were missing, ~60 hand-rolled buttons moved onto it, and the 1024×768 size contract (minimum touch target, the type scale bound to the text-size slider) moved into the primitives instead of living in ~160 call sites. The platform components moved into `components/ui/` and the boundary that makes it a layer — no feature components, no domain API slices — is enforced by eslint. S2 added a right-click/long-press/Shift+F10 input path onto the menu the app already had, rather than a second menu, plus an atomic move endpoint. S3 collapsed six drifting keydown listeners into one table whose handler map makes a table/implementation mismatch a type error, and built the cheat sheet and palette on it. S4 swept the viewport contract by measuring rather than looking.
|
||||
- The closing review ran three rounds, 32 → 15 → 11, each one reviewing the previous round's own fix diff. Round 1's 77 raw findings deduplicated to 64 and 32 survived three-refuter adversarial verification; six died at 2-of-3 and are recorded so they are not raised again. Rounds 2 and 3 each found that the previous round's fixes had introduced regressions, and both reverted rather than attempting a third variant — an aria-disabled menu row that the popover library skips exactly as it skips a disabled one (a comment one file away already said so), and a focus-restore that broke two other surfaces. The eslint rule meant to enforce the new layer boundary turned out to match almost nothing: its patterns were written in plain string literals, where the escape for a literal dot collapses into a wildcard, so the two commonest violations passed a green gate. It now has its own test. One bug was settled by measuring in a real browser after three rounds produced three contradictory explanations of React's commit ordering, and the jsdom harness built to decide it was deleted for reproducing nothing. Residuals are on the roadmap rather than closed: a disabled menu row's explanation still reaches nobody, Escape from an inline rename strands focus, and the overlay history bookkeeping counts buried entries rather than marking them.
|
||||
|
||||
## 0.81.0 — 2026-09-05
|
||||
|
||||
If you run your own Siftlode, this one is mostly for you: there is now a way back from a bad upgrade, backup scripts that actually restore, and a way to let someone in on an instance that cannot send mail. If you just use Siftlode, the visible part is that a download can no longer quietly blow past your storage limit, and that the box refuses addresses on the server's own network.
|
||||
|
||||
@@ -26,8 +26,8 @@ The local gate is **ruff (`check` + `format --check`) + typecheck (3 tsc project
|
||||
keep commits logical and Conventional-Commit-styled.
|
||||
- **No AI attribution in commits.** No `Co-Authored-By` trailer, no "generated by" lines. (This file
|
||||
being AI-written is the intended, visible exception; commits are not.)
|
||||
- **Release: user-owned in timing, agent-run in execution.** The user decides *when* to ship; on their
|
||||
go-ahead *you* run `siftlode release` from the feature branch (merge to `main` → publish → deploy →
|
||||
- **Release: user-owned in timing, agent-run in execution.** The user decides _when_ to ship; on their
|
||||
go-ahead _you_ run `siftlode release` from the feature branch (merge to `main` → publish → deploy →
|
||||
verify `/api/version`). Never self-ship — bring work to a green, committed state and stop for the
|
||||
go-ahead first, then run it yourself.
|
||||
- **Public repo.** Keep ops/topology/secrets out — those live in a separate private repo. Nothing here
|
||||
@@ -41,7 +41,7 @@ how it gets built. Concretely:
|
||||
- **Plan before you build.** For anything non-trivial, present a short plan (sprint-level for larger
|
||||
work) and get a go-ahead before writing code.
|
||||
- **Offer choices, mark the optimal — and explain the trade-off.** When several solutions are viable,
|
||||
list them and mark the recommended one. This is the *one* place to spend extra words: give the *why*
|
||||
list them and mark the recommended one. This is the _one_ place to spend extra words: give the _why_
|
||||
behind each option and the weight of the decision. These decision points are where the product owner
|
||||
engages with the build and learns from it, so make them understandable — a reasoned menu, not a bare one.
|
||||
- **Mockups for design/UX decisions.** For any visual or interaction choice, show mockup options to
|
||||
@@ -59,31 +59,38 @@ how it gets built. Concretely:
|
||||
`downloads/`, `youtube/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
|
||||
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`,
|
||||
`security`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
|
||||
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → components; data
|
||||
via react-query (`qk` factory), per-account `localStorage`, and server prefs; API through `lib/api/*`
|
||||
slices over `core.req<T>`.
|
||||
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
|
||||
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
|
||||
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
|
||||
boundary is one-way and **lint-enforced**, not just described: a platform component may not import a
|
||||
feature component or a domain API slice (`no-restricted-imports` in `eslint.config.js`, with its own
|
||||
test). A new generic component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
|
||||
Data via react-query (`qk` factory), per-account `localStorage`, and server prefs; API through
|
||||
`lib/api/*` slices over `core.req<T>`.
|
||||
|
||||
**Not wanted** (reviewed and rejected as over-engineering for this size): microservices, event bus,
|
||||
CQRS, a repository/DI framework, a router library, a generic CRUD-router factory or base serializer.
|
||||
|
||||
## Canonical patterns — when you have a choice, pick THIS
|
||||
|
||||
| Area | Use | Not |
|
||||
|---|---|---|
|
||||
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
|
||||
| Request body | a Pydantic model | `payload: dict` |
|
||||
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
|
||||
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
||||
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
||||
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
|
||||
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`) | ad-hoc `{"unsubscribed"}`/`{"cleared": n}`/echo |
|
||||
| Transaction commit | the handler/job-wrapper commits; helpers `flush` | helpers that commit (only `quota` owns its session) |
|
||||
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
|
||||
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
||||
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
||||
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
||||
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
|
||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||
| Area | Use | Not |
|
||||
| ----------------------- | ------------------------------------------------------------ | ------------------------------------------------------ |
|
||||
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
|
||||
| Request body | a Pydantic model | `payload: dict` |
|
||||
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
|
||||
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
||||
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
||||
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
|
||||
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`) | ad-hoc `{"unsubscribed"}`/`{"cleared": n}`/echo |
|
||||
| Transaction commit | the handler/job-wrapper commits; helpers `flush` | helpers that commit (only `quota` owns its session) |
|
||||
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
|
||||
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
||||
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
||||
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
||||
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
|
||||
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
||||
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
|
||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||
|
||||
## House rules that bite (learned from real bugs)
|
||||
|
||||
@@ -100,6 +107,6 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
|
||||
---
|
||||
|
||||
*Run-it-locally and the full gate commands live in [`docs/development.md`](docs/development.md); backend
|
||||
_Run-it-locally and the full gate commands live in [`docs/development.md`](docs/development.md); backend
|
||||
code conventions in [`backend/app/README.md`](backend/app/README.md). This file stays the source of
|
||||
truth for the canonical-patterns table and the gate/workflow/collaboration rules above.*
|
||||
truth for the canonical-patterns table and the gate/workflow/collaboration rules above._
|
||||
|
||||
@@ -3,6 +3,8 @@ CRUD (create / rename / delete, add / remove / reorder items) plus YouTube two-w
|
||||
mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert local edits back
|
||||
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select, and_
|
||||
@@ -52,6 +54,16 @@ def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||
return pl
|
||||
|
||||
|
||||
def _touch(pl: Playlist) -> None:
|
||||
"""Bump the playlist's `updated_at` for an edit that only writes `playlist_items`.
|
||||
|
||||
`UpdatedAtMixin`'s `onupdate` fires on an UPDATE of the PLAYLIST row, and add / remove /
|
||||
reorder / move write the item rows instead — so without this the rail's "recently changed"
|
||||
sort would only ever see renames. The assignment is what makes the column mean "last time
|
||||
this list changed", which is what the rail promises the reader."""
|
||||
pl.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _next_playlist_position(db: Session, user_id: int) -> int:
|
||||
"""Append position for a new playlist in this user's rail."""
|
||||
return (
|
||||
@@ -91,6 +103,7 @@ def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) ->
|
||||
)
|
||||
if mark_dirty:
|
||||
recompute_dirty(db, pl)
|
||||
_touch(pl)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@@ -108,6 +121,7 @@ def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool)
|
||||
db.delete(item)
|
||||
if mark_dirty:
|
||||
recompute_dirty(db, pl)
|
||||
_touch(pl)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@@ -129,6 +143,9 @@ def _summary(
|
||||
"item_count": count,
|
||||
"total_duration_seconds": total_duration,
|
||||
"cover_thumbnail": cover,
|
||||
# When this list last CHANGED (rename, add/remove/move/reorder) — the rail's
|
||||
# "recently changed first" sort reads it. See _touch for why it isn't automatic.
|
||||
"updated_at": pl.updated_at,
|
||||
}
|
||||
if has_video is not None:
|
||||
out["has_video"] = has_video
|
||||
@@ -520,6 +537,63 @@ def remove_item(
|
||||
return {"playlist_id": pl.id, "video_id": video_id}
|
||||
|
||||
|
||||
class MoveItemIn(BaseModel):
|
||||
target_playlist_id: int
|
||||
|
||||
|
||||
@router.post("/{playlist_id}/items/{video_id}/move")
|
||||
def move_item(
|
||||
playlist_id: int,
|
||||
video_id: str,
|
||||
body: MoveItemIn,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Move one video from a playlist into another of the caller's playlists, atomically.
|
||||
|
||||
ONE transaction on purpose. The obvious client-side shape — add to the target, then remove
|
||||
from the source — leaves the video in BOTH lists whenever the second call fails, which is
|
||||
exactly the kind of half-applied state this codebase treats as a bug rather than an edge
|
||||
case. Idempotent: if the video is already in the target, the source row is simply dropped
|
||||
(moving into a list that already has it is a removal, not a duplicate)."""
|
||||
src = _own_playlist(db, user, playlist_id)
|
||||
dst = _own_playlist(db, user, body.target_playlist_id)
|
||||
if src.id == dst.id:
|
||||
raise HTTPException(
|
||||
status_code=400, detail={"code": "playlist_move", "reason": "same_playlist"}
|
||||
)
|
||||
item = db.scalar(
|
||||
select(PlaylistItem).where(
|
||||
PlaylistItem.playlist_id == src.id, PlaylistItem.video_id == video_id
|
||||
)
|
||||
)
|
||||
if item is None:
|
||||
# A concurrent edit (another tab, or a YouTube re-pull) already took it out.
|
||||
raise HTTPException(
|
||||
status_code=404, detail={"code": "playlist_move", "reason": "not_in_playlist"}
|
||||
)
|
||||
already = db.scalar(
|
||||
select(PlaylistItem).where(
|
||||
PlaylistItem.playlist_id == dst.id, PlaylistItem.video_id == video_id
|
||||
)
|
||||
)
|
||||
if already is not None:
|
||||
db.delete(item) # the uq_playlist_video constraint would reject a second row anyway
|
||||
else:
|
||||
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
|
||||
# survives the move, and there is no window where the video belongs to neither list.
|
||||
item.playlist_id = dst.id
|
||||
item.position = _next_item_position(db, dst.id)
|
||||
# Both sides changed, so both get the dirty recompute and the timestamp — the asymmetric
|
||||
# version of this (touching only the source) is the recurring smell CLAUDE.md warns about.
|
||||
recompute_dirty(db, src)
|
||||
recompute_dirty(db, dst)
|
||||
_touch(src)
|
||||
_touch(dst)
|
||||
db.commit()
|
||||
return {"ok": True, "from_playlist_id": src.id, "to_playlist_id": dst.id, "video_id": video_id}
|
||||
|
||||
|
||||
class ReorderItemsIn(BaseModel):
|
||||
video_ids: list[str] = []
|
||||
|
||||
@@ -555,5 +629,6 @@ def reorder_items(
|
||||
it.position = pos
|
||||
pos += 1
|
||||
recompute_dirty(db, pl)
|
||||
_touch(pl)
|
||||
db.commit()
|
||||
return {"playlist_id": pl.id, "count": pos}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""E10 S2 — the one-step "move this video to another list" endpoint, and the `updated_at` touch
|
||||
the rail's "recently changed" sort reads.
|
||||
|
||||
Two properties are load-bearing and neither is visible from the happy path alone:
|
||||
|
||||
1. **The move is one transaction.** The client-side shape it replaces (add to the target, then
|
||||
remove from the source) leaves the video in BOTH lists when the second call fails. So the
|
||||
tests assert the *pair* of lists after every call, never just the destination.
|
||||
2. **`updated_at` tracks item edits.** `UpdatedAtMixin.onupdate` only fires on an UPDATE of the
|
||||
playlist row, and add/remove/reorder/move write `playlist_items` — so the timestamp is bumped
|
||||
explicitly, and a regression there would silently degrade the sort to "renames only".
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.models import Channel, Playlist, PlaylistItem, Video
|
||||
|
||||
|
||||
def _seed_videos(db, ids):
|
||||
db.add(Channel(id="UCtest", title="Test"))
|
||||
now = datetime.now(timezone.utc)
|
||||
for v in ids:
|
||||
db.add(Video(id=v, channel_id="UCtest", published_at=now))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _playlist(db, user, name, video_ids, **kw):
|
||||
pl = Playlist(user_id=user.id, name=name, **kw)
|
||||
db.add(pl)
|
||||
db.flush()
|
||||
for i, vid in enumerate(video_ids):
|
||||
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=i))
|
||||
db.commit()
|
||||
return pl
|
||||
|
||||
|
||||
def _ids(db, pl_id):
|
||||
return [
|
||||
it.video_id
|
||||
for it in db.execute(
|
||||
PlaylistItem.__table__.select()
|
||||
.where(PlaylistItem.playlist_id == pl_id)
|
||||
.order_by(PlaylistItem.position)
|
||||
).all()
|
||||
]
|
||||
|
||||
|
||||
def _move(client, src, video_id, dst):
|
||||
return client.post(
|
||||
f"/api/playlists/{src}/items/{video_id}/move", json={"target_playlist_id": dst}
|
||||
)
|
||||
|
||||
|
||||
def test_move_leaves_the_video_in_exactly_one_list(db, make_user, as_user):
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1", "v2"])
|
||||
src = _playlist(db, user, "Source", ["v1", "v2"])
|
||||
dst = _playlist(db, user, "Target", [])
|
||||
|
||||
r = _move(as_user(user), src.id, "v1", dst.id)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {
|
||||
"ok": True,
|
||||
"from_playlist_id": src.id,
|
||||
"to_playlist_id": dst.id,
|
||||
"video_id": "v1",
|
||||
}
|
||||
db.expire_all()
|
||||
assert _ids(db, src.id) == ["v2"]
|
||||
assert _ids(db, dst.id) == ["v1"]
|
||||
|
||||
|
||||
def test_move_appends_to_the_end_of_the_target(db, make_user, as_user):
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1", "v2", "v3"])
|
||||
src = _playlist(db, user, "Source", ["v3"])
|
||||
dst = _playlist(db, user, "Target", ["v1", "v2"])
|
||||
|
||||
assert _move(as_user(user), src.id, "v3", dst.id).status_code == 200
|
||||
db.expire_all()
|
||||
assert _ids(db, dst.id) == ["v1", "v2", "v3"]
|
||||
|
||||
|
||||
def test_move_into_a_list_that_already_has_it_is_a_removal_not_a_duplicate(db, make_user, as_user):
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1"])
|
||||
src = _playlist(db, user, "Source", ["v1"])
|
||||
dst = _playlist(db, user, "Target", ["v1"])
|
||||
|
||||
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
|
||||
db.expire_all()
|
||||
assert _ids(db, src.id) == []
|
||||
assert _ids(db, dst.id) == ["v1"] # exactly one row, not two
|
||||
|
||||
|
||||
def test_move_to_the_same_list_is_refused(db, make_user, as_user):
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1"])
|
||||
src = _playlist(db, user, "Source", ["v1"])
|
||||
|
||||
r = _move(as_user(user), src.id, "v1", src.id)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == {"code": "playlist_move", "reason": "same_playlist"}
|
||||
db.expire_all()
|
||||
assert _ids(db, src.id) == ["v1"]
|
||||
|
||||
|
||||
def test_move_of_a_video_that_left_the_source_reports_it(db, make_user, as_user):
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1"])
|
||||
src = _playlist(db, user, "Source", [])
|
||||
dst = _playlist(db, user, "Target", [])
|
||||
|
||||
r = _move(as_user(user), src.id, "v1", dst.id)
|
||||
assert r.status_code == 404
|
||||
assert r.json()["detail"] == {"code": "playlist_move", "reason": "not_in_playlist"}
|
||||
|
||||
|
||||
def test_move_into_someone_elses_list_404s_and_changes_nothing(db, make_user, as_user):
|
||||
user, other = make_user(), make_user()
|
||||
_seed_videos(db, ["v1"])
|
||||
src = _playlist(db, user, "Mine", ["v1"])
|
||||
theirs = _playlist(db, other, "Theirs", [])
|
||||
|
||||
assert _move(as_user(user), src.id, "v1", theirs.id).status_code == 404
|
||||
db.expire_all()
|
||||
assert _ids(db, src.id) == ["v1"]
|
||||
assert _ids(db, theirs.id) == []
|
||||
|
||||
|
||||
def test_move_marks_both_linked_sides_dirty(db, make_user, as_user):
|
||||
"""A move edits two YouTube mirrors at once — the source lost an item, the target gained one.
|
||||
Marking only the source is the asymmetric fix this repo keeps re-learning."""
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1"])
|
||||
src = _playlist(db, user, "Src", ["v1"], yt_playlist_id="PLsrc", synced_video_ids='["v1"]')
|
||||
dst = _playlist(db, user, "Dst", [], yt_playlist_id="PLdst", synced_video_ids="[]")
|
||||
# Baseline: both are in sync with YouTube before the move.
|
||||
src.dirty = False
|
||||
dst.dirty = False
|
||||
db.commit()
|
||||
|
||||
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(Playlist, src.id).dirty is True
|
||||
assert db.get(Playlist, dst.id).dirty is True
|
||||
|
||||
|
||||
def test_item_edits_bump_updated_at(db, make_user, as_user):
|
||||
"""The rail's "recently changed first" sort reads this. Adding, moving and reordering all
|
||||
write `playlist_items` only, so nothing here is covered by the ORM's `onupdate`."""
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1", "v2"])
|
||||
src = _playlist(db, user, "Source", ["v1", "v2"])
|
||||
dst = _playlist(db, user, "Target", [])
|
||||
old = datetime.now(timezone.utc) - timedelta(days=3)
|
||||
src.updated_at = old
|
||||
dst.updated_at = old
|
||||
db.commit()
|
||||
client = as_user(user)
|
||||
|
||||
assert client.post(f"/api/playlists/{dst.id}/items", json={"video_id": "v1"}).status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(Playlist, dst.id).updated_at > old
|
||||
|
||||
dst.updated_at = old
|
||||
db.commit()
|
||||
assert (
|
||||
client.put(f"/api/playlists/{src.id}/order", json={"video_ids": ["v2", "v1"]}).status_code
|
||||
== 200
|
||||
)
|
||||
db.expire_all()
|
||||
assert db.get(Playlist, src.id).updated_at > old
|
||||
|
||||
src.updated_at = old
|
||||
dst.updated_at = old
|
||||
db.commit()
|
||||
assert _move(client, src.id, "v2", dst.id).status_code == 200
|
||||
db.expire_all()
|
||||
# BOTH sides of a move are "recently changed" — the target is where the reader will look.
|
||||
assert db.get(Playlist, src.id).updated_at > old
|
||||
assert db.get(Playlist, dst.id).updated_at > old
|
||||
|
||||
|
||||
def test_the_list_endpoint_publishes_updated_at(db, make_user, as_user):
|
||||
user = make_user()
|
||||
_playlist(db, user, "Solo", [])
|
||||
rows = as_user(user).get("/api/playlists").json()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["updated_at"] # the rail cannot sort by a field the API never sends
|
||||
@@ -67,5 +67,47 @@ export default tseslint.config(
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// E10 S1: `components/ui/` is the PLATFORM layer, and the rule that makes it a layer rather than
|
||||
// a folder is enforced here, not in prose (see components/ui/README.md). A platform component is
|
||||
// domain-free: it may import sibling primitives and generic `lib/` helpers, never a feature
|
||||
// component (`../Feed`) and never a domain API slice. Without this rule the boundary erodes on
|
||||
// the first "just this once" import and the directory silently becomes another components/.
|
||||
files: ["src/components/ui/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
// NOTE the String.raw: these are REGEX sources, and in a plain "..." literal `\.` is an
|
||||
// unknown escape that collapses to a bare `.` — the any-char wildcard. The first cut of
|
||||
// this rule shipped that way and matched far less than it read like (E10 review round 1).
|
||||
patterns: [
|
||||
{
|
||||
// `../X` (exactly one level up = a feature component). A `regex` rather than a glob:
|
||||
// eslint matches `patterns` gitignore-style, where `../*` also swallows `../../lib/x`.
|
||||
regex: String.raw`^\.\./(?!\.\./)`,
|
||||
message:
|
||||
"components/ui/ is domain-free: a platform component may not import a feature component. Invert the dependency (take a prop/render-prop) or keep the component outside ui/.",
|
||||
},
|
||||
{
|
||||
// The same file by its OTHER spelling. `../../components/Feed` resolves exactly where
|
||||
// `../Feed` does, so the rule above is only half the boundary; `ui/` is carved out
|
||||
// because a sibling primitive is the one thing this path may legitimately name.
|
||||
regex: String.raw`^\.\./\.\./components/(?!ui/)`,
|
||||
message:
|
||||
"components/ui/ is domain-free: a platform component may not import a feature component. Invert the dependency (take a prop/render-prop) or keep the component outside ui/.",
|
||||
},
|
||||
{
|
||||
// `(/|$)` so the BARREL counts too: `../../lib/api` is the spelling every feature
|
||||
// component uses, and a `/`-anchored pattern let exactly that one through.
|
||||
regex: String.raw`^\.\./\.\./lib/api(/|$)`,
|
||||
message:
|
||||
"components/ui/ is domain-free: a platform component may not call a domain API slice. Take the data as props.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
prettier,
|
||||
);
|
||||
|
||||
+17
-4
@@ -29,12 +29,15 @@ import Welcome from "./components/Welcome";
|
||||
import SetupWizard from "./components/SetupWizard";
|
||||
import Header from "./components/Header";
|
||||
import NavSidebar from "./components/NavSidebar";
|
||||
import BackToTop from "./components/BackToTop";
|
||||
import PageShell from "./components/PageShell";
|
||||
import BackToTop from "./components/ui/BackToTop";
|
||||
import PageShell from "./components/ui/PageShell";
|
||||
import GlassTuner from "./components/GlassTuner";
|
||||
import ChatDock from "./components/ChatDock";
|
||||
import Toaster from "./components/Toaster";
|
||||
import { StateMessage } from "./components/QueryState";
|
||||
import Toaster from "./components/ui/Toaster";
|
||||
import KeyboardHelp from "./components/ui/KeyboardHelp";
|
||||
import AppCommandPalette from "./components/AppCommandPalette";
|
||||
import { useGlobalKeymap } from "./lib/keymap";
|
||||
import { StateMessage } from "./components/ui/QueryState";
|
||||
import ErrorDialog from "./components/ErrorDialog";
|
||||
import VersionBanner from "./components/VersionBanner";
|
||||
import DemoBanner from "./components/DemoBanner";
|
||||
@@ -134,6 +137,11 @@ export default function App() {
|
||||
refetchInterval: (query) => (query.state.data ? 60_000 : false),
|
||||
});
|
||||
|
||||
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette. Bound only once
|
||||
// signed in, because that is where both overlays are mounted (bottom of this tree) — see
|
||||
// useGlobalKeymap on why binding it above the signed-out branch is worse than not binding it.
|
||||
useGlobalKeymap(!!meQuery.data);
|
||||
|
||||
// Pin this tab to whatever account it first loaded as. Without this, a tab that never picked
|
||||
// an account (it was riding the session's default) would silently swap identity when ANOTHER
|
||||
// tab changes the default — e.g. that other tab adds/switches an account. Once pinned, this
|
||||
@@ -412,6 +420,11 @@ export default function App() {
|
||||
(collapsed or expanded) without tracking its width. */}
|
||||
<Toaster />
|
||||
</div>
|
||||
{/* The two app-wide overlays: the keyboard cheat sheet (`h`) and the jump-to palette (Mod+K).
|
||||
Mounted once, outside the page area, so every surface answers "what can I press here" and
|
||||
"take me to X" the same way. Their keys are bound by useGlobalKeymap above. */}
|
||||
<KeyboardHelp />
|
||||
<AppCommandPalette />
|
||||
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
|
||||
<Suspense fallback={null}>
|
||||
{aboutOpen && (
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { Info, Sparkles } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { FRONTEND_VERSION, FRONTEND_SHA, FRONTEND_BUILD_DATE } from "../lib/version";
|
||||
import Modal from "./Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
|
||||
function fmtDate(d?: string | null): string {
|
||||
if (!d) return "—";
|
||||
@@ -70,12 +71,9 @@ export default function About({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onOpenReleaseNotes}
|
||||
className="mt-4 w-full inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
|
||||
>
|
||||
<Button variant="primary" size="lg" className="mt-4 w-full" onClick={onOpenReleaseNotes}>
|
||||
<Sparkles className="w-4 h-4" /> {t("about.whatsNew")}
|
||||
</button>
|
||||
</Button>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api } from "../lib/api";
|
||||
import ChannelAboutContent from "./ChannelAbout";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
/**
|
||||
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Clock, Copy, Globe, KeyRound, Mail, Send, ShieldAlert } from "lucide-react";
|
||||
import Modal from "./Modal";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import Modal from "./ui/Modal";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { notify } from "../lib/notifications";
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// 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
|
||||
// and LanguageSwitcher. Use it to keep occasional actions out of a crowded toolbar. The items are
|
||||
// fire-and-close (`onSelect`); a `disabled` item (e.g. a mutation already in flight) is inert.
|
||||
|
||||
export interface ActionMenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode; // an element (so a caller can swap in a spinning icon while pending)
|
||||
onSelect: () => void;
|
||||
disabled?: boolean;
|
||||
title?: string; // optional native tooltip (carries a longer hint the label alone can't)
|
||||
}
|
||||
|
||||
export default function ActionMenu({
|
||||
items,
|
||||
ariaLabel,
|
||||
className,
|
||||
trigger,
|
||||
}: {
|
||||
items: readonly ActionMenuItem[];
|
||||
/** Accessible name for the trigger (e.g. "Catalog actions"). */
|
||||
ariaLabel: string;
|
||||
/** Trigger className override; omit for the default glass ⋯ icon button. */
|
||||
className?: string;
|
||||
/** Custom trigger content; omit for the default ⋯ icon. */
|
||||
trigger?: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// A disabled item (a mutation already in flight) is skipped by roving keyboard nav automatically:
|
||||
// PopoverItem renders it as a native `disabled` button, which useListNavigation reads and never
|
||||
// focuses. No explicit disabledIndices needed. See ActionMenu.dom.test.tsx for the guard.
|
||||
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" });
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
className={
|
||||
className ??
|
||||
"glass-card glass-hover flex items-center justify-center rounded-xl p-2 text-sm transition"
|
||||
}
|
||||
>
|
||||
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
|
||||
</button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={ariaLabel}
|
||||
className="glass-menu min-w-52 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{items.map((it) => (
|
||||
<PopoverItem
|
||||
key={it.id}
|
||||
role="menuitem"
|
||||
disabled={it.disabled}
|
||||
label={it.label}
|
||||
title={it.title}
|
||||
onSelect={() => {
|
||||
it.onSelect();
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm outline-hidden transition disabled:opacity-50"
|
||||
>
|
||||
{it.icon && <span className="shrink-0 text-muted">{it.icon}</span>}
|
||||
<span className="flex-1 truncate">{it.label}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { Filter, X } from "lucide-react";
|
||||
import type { ActiveFilter } from "../lib/useActiveFilters";
|
||||
import { decideChipLayout } from "../lib/chipOverflow";
|
||||
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
// 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
|
||||
@@ -172,7 +172,7 @@ export default function ActiveFilterChips({
|
||||
<div
|
||||
ref={laneRef}
|
||||
data-testid="active-filter-chips"
|
||||
className={`relative flex min-w-0 items-center gap-1.5 ${className ?? ""}`}
|
||||
className={`${chipLaneClass} flex min-w-0 items-center gap-1.5 ${className ?? ""}`}
|
||||
aria-label={t("feed.chips.label")}
|
||||
>
|
||||
{/* Hidden mirror of the full set, used only to measure natural widths. Kept out of the a11y tree
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Check, ListPlus, Plus, Youtube } from "lucide-react";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
|
||||
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
|
||||
|
||||
@@ -26,16 +26,17 @@ import {
|
||||
import { notify } from "../lib/notifications";
|
||||
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
|
||||
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
||||
import Tooltip from "./Tooltip";
|
||||
import InlineNotice from "./InlineNotice";
|
||||
import { Button } from "./ui/Button";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import InlineNotice from "./ui/InlineNotice";
|
||||
import { Section } from "./ui/form";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { LANGUAGES, type LangCode } from "../i18n";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import AccountPanel from "./AccountPanel";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import Tabs, { usePersistedTab } from "./Tabs";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import Tabs, { usePersistedTab } from "./ui/Tabs";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useMe } from "../lib/useMe";
|
||||
|
||||
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
|
||||
@@ -244,14 +245,16 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
: t("users.roles.makeAdmin")
|
||||
}
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
variant="glass"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
onClick={() => onToggle(u)}
|
||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-40 transition"
|
||||
icon={<Shield className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
hint={
|
||||
@@ -264,36 +267,36 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
: t("users.suspend.action")
|
||||
}
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
variant={u.is_suspended ? "outline-warn" : "outline"}
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onSuspend(u)}
|
||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||
aria-label={
|
||||
u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")
|
||||
}
|
||||
className={`shrink-0 p-1.5 rounded-lg border disabled:opacity-40 transition ${
|
||||
u.is_suspended
|
||||
? "border-warn/40 text-warn hover:bg-warn/10"
|
||||
: "border-border text-muted hover:text-warn"
|
||||
}`}
|
||||
>
|
||||
{u.is_suspended ? (
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
) : (
|
||||
<Ban className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
icon={
|
||||
u.is_suspended ? (
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
) : (
|
||||
<Ban className="w-4 h-4" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onDelete(u)}
|
||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||
aria-label={t("users.delete.action")}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-40 transition"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
@@ -435,25 +438,28 @@ function PendingAccessRowView({
|
||||
</div>
|
||||
{!row.email_verified && (
|
||||
<Tooltip hint={t("users.pendingAccess.resendHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onResend}
|
||||
disabled={busy}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-fg disabled:opacity-50 transition"
|
||||
aria-label={t("users.pendingAccess.resend")}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Send className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{row.can_activate && (
|
||||
<Tooltip hint={t("users.pendingAccess.activateHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-accent"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
onClick={onActivate}
|
||||
disabled={busy}
|
||||
className="shrink-0 px-2.5 py-1.5 rounded-lg border border-accent/40 text-accent text-xs font-medium hover:bg-accent/10 disabled:opacity-50 transition"
|
||||
>
|
||||
{t("users.pendingAccess.activate")}
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
@@ -673,12 +679,14 @@ function AdminInvites() {
|
||||
placeholder={t("settings.invites.addPlaceholder")}
|
||||
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
||||
variant="glass"
|
||||
className="shrink-0"
|
||||
icon={<UserPlus className="w-4 h-4" />}
|
||||
>
|
||||
<UserPlus className="w-4 h-4" /> {t("settings.invites.add")}
|
||||
</button>
|
||||
{t("settings.invites.add")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{decided.length > 0 && (
|
||||
@@ -820,14 +828,15 @@ function AdminDemo() {
|
||||
)}
|
||||
</div>
|
||||
<Tooltip hint={t("settings.demo.removeHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onRemove(r)}
|
||||
disabled={remove.isPending}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
||||
aria-label={t("settings.demo.remove")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
@@ -848,12 +857,9 @@ function AdminDemo() {
|
||||
placeholder={t("settings.demo.addPlaceholder")}
|
||||
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
||||
>
|
||||
<Button variant="glass" size="md" className="shrink-0" type="submit">
|
||||
<UserPlus className="w-4 h-4" /> {t("settings.demo.add")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-border">
|
||||
@@ -909,24 +915,26 @@ function InviteRow({
|
||||
)}
|
||||
</div>
|
||||
<Tooltip hint={t("settings.invites.approveHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-accent"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onApprove}
|
||||
disabled={busy}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-accent/40 text-accent hover:bg-accent/10 disabled:opacity-50 transition"
|
||||
aria-label={t("settings.invites.approve")}
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Check className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip hint={t("settings.invites.denyHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onDeny}
|
||||
disabled={busy}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
||||
aria-label={t("settings.invites.deny")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<X className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
{/* Deny records a decision and keeps the row; delete forgets the address. Once an account
|
||||
exists for it the row is that account's approval gate, so it is managed from Users & roles
|
||||
@@ -946,14 +954,15 @@ function InviteRow({
|
||||
</button>
|
||||
) : (
|
||||
<Tooltip hint={t("settings.invites.deleteHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onDelete}
|
||||
disabled={busy}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
||||
aria-label={t("settings.invites.deleteLabel", { email: inv.email })}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Keyboard, ListVideo, Palette, Tv } from "lucide-react";
|
||||
import CommandPalette, { type CommandGroup } from "./ui/CommandPalette";
|
||||
import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { moduleDef, moduleOrder } from "../lib/modules";
|
||||
import { SCHEMES, type Scheme } from "../lib/theme";
|
||||
import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap";
|
||||
import { useNavigationActions } from "./NavigationProvider";
|
||||
import { usePlaylistsActions } from "./PlaylistsProvider";
|
||||
import { usePrefs } from "./PrefsProvider";
|
||||
import { useSavedViews } from "./SavedViewsProvider";
|
||||
|
||||
// What Ctrl/Cmd+K can reach. The generic list/filter/keyboard shell is `ui/CommandPalette`; this is
|
||||
// the domain half — the layer boundary says a primitive may not import `lib/api` or a module.
|
||||
//
|
||||
// Scope decided with the product owner (2026-09-05): navigation + app-wide ACTIONS. The actions here
|
||||
// are the reversible, local ones — the shortcuts sheet and the theme. Deliberately NOT here: "Sync
|
||||
// from YouTube". It is a long-running job against a quota, it wants the progress and error surface
|
||||
// the Channels page already gives it, and one mistyped Enter is a worse failure than the walk it
|
||||
// would have saved.
|
||||
//
|
||||
// Saved views come from whichever module owns the current SavedViewsProvider source (feed on the
|
||||
// feed, Plex on Plex), i.e. exactly the views that are applicable where you stand.
|
||||
export default function AppCommandPalette() {
|
||||
const { t } = useTranslation();
|
||||
const open = usePaletteOpen();
|
||||
const me = useMe();
|
||||
const { setPage, openChannel } = useNavigationActions();
|
||||
const { setSelectedId } = usePlaylistsActions();
|
||||
const { theme, setTheme } = usePrefs();
|
||||
const savedViews = useSavedViews();
|
||||
|
||||
// Only fetch the lists while the palette is actually open — it is a rarely-opened surface and
|
||||
// these are otherwise the feed's and the playlist rail's own queries, shared through the cache.
|
||||
const channelsQ = useQuery({
|
||||
queryKey: qk.channels(),
|
||||
queryFn: () => api.channels(),
|
||||
enabled: open,
|
||||
});
|
||||
const playlistsQ = useQuery({
|
||||
queryKey: qk.playlists(),
|
||||
queryFn: () => api.playlists(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const groups = useMemo<CommandGroup[]>(() => {
|
||||
const out: CommandGroup[] = [];
|
||||
|
||||
// Modules, in nav-rail order and gated by the account — the registry decides, not this file.
|
||||
if (me) {
|
||||
const pages = [...moduleOrder(me), "settings" as const];
|
||||
out.push({
|
||||
id: "modules",
|
||||
label: t("palette.groups.modules"),
|
||||
items: pages.map((page) => {
|
||||
const def = moduleDef(page);
|
||||
const Icon = def.icon;
|
||||
return {
|
||||
id: `page:${page}`,
|
||||
label: t(def.labelKey),
|
||||
icon: <Icon className="w-4 h-4" />,
|
||||
// The longer tab title is a second name for the same place ("Channel manager" for
|
||||
// "Channels"), so match on it even though the row shows the short one.
|
||||
keywords: def.titleKey ? t(def.titleKey) : "",
|
||||
run: () => setPage(page),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const channels = channelsQ.data ?? [];
|
||||
if (channels.length > 0) {
|
||||
out.push({
|
||||
id: "channels",
|
||||
label: t("palette.groups.channels"),
|
||||
items: channels
|
||||
.filter((c) => !c.hidden)
|
||||
.map((c) => ({
|
||||
id: `channel:${c.id}`,
|
||||
label: c.title ?? c.id,
|
||||
hint: c.handle ?? undefined,
|
||||
icon: <Tv className="w-4 h-4" />,
|
||||
keywords: c.handle ?? "",
|
||||
run: () => openChannel(c.id, c.title ?? undefined),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const playlists = playlistsQ.data ?? [];
|
||||
if (playlists.length > 0) {
|
||||
out.push({
|
||||
id: "playlists",
|
||||
label: t("palette.groups.playlists"),
|
||||
items: playlists.map((p) => ({
|
||||
id: `playlist:${p.id}`,
|
||||
label: p.name,
|
||||
hint: t("palette.videoCount", { count: p.item_count }),
|
||||
icon: <ListVideo className="w-4 h-4" />,
|
||||
run: () => {
|
||||
setSelectedId(p.id);
|
||||
setPage("playlists");
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (savedViews.views.length > 0) {
|
||||
out.push({
|
||||
id: "views",
|
||||
label: t("palette.groups.views"),
|
||||
items: savedViews.views.map((v) => ({
|
||||
id: `view:${v.id}`,
|
||||
label: v.name,
|
||||
run: () => savedViews.apply(v),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
out.push({
|
||||
id: "actions",
|
||||
label: t("palette.groups.actions"),
|
||||
items: [
|
||||
{
|
||||
id: "action:shortcuts",
|
||||
label: t("keys.title"),
|
||||
icon: <Keyboard className="w-4 h-4" />,
|
||||
run: toggleKeyboardHelp,
|
||||
},
|
||||
...SCHEMES.map((s) => ({
|
||||
id: `action:theme:${s.id}`,
|
||||
label: t("palette.theme", { name: s.name }),
|
||||
icon: <Palette className="w-4 h-4" />,
|
||||
keywords: s.id,
|
||||
run: () => setTheme({ ...theme, scheme: s.id as Scheme }),
|
||||
})),
|
||||
],
|
||||
});
|
||||
|
||||
return out;
|
||||
}, [
|
||||
t,
|
||||
me,
|
||||
channelsQ.data,
|
||||
playlistsQ.data,
|
||||
savedViews,
|
||||
theme,
|
||||
setTheme,
|
||||
setPage,
|
||||
openChannel,
|
||||
setSelectedId,
|
||||
]);
|
||||
|
||||
return <CommandPalette open={open} onClose={closePalette} groups={groups} />;
|
||||
}
|
||||
@@ -6,10 +6,11 @@ import { Trash2 } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { LS } from "../lib/storage";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Section } from "./ui/form";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import DataTable from "./DataTable";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import DataTable from "./ui/DataTable";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { auditColumns } from "./auditColumns";
|
||||
|
||||
// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run
|
||||
@@ -72,14 +73,15 @@ export default function AuditLog() {
|
||||
persistKey={LS.auditTable}
|
||||
controlsPosition="top"
|
||||
controlsLeading={
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="xs"
|
||||
onClick={onClear}
|
||||
disabled={clear.isPending}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
||||
icon={<Trash2 className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{t("audit.clear")}
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
emptyText={t("audit.empty")}
|
||||
/>
|
||||
|
||||
@@ -12,15 +12,15 @@ import { useCardPager } from "../lib/useCardPager";
|
||||
import { subsColumn } from "./channelColumns";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import Tooltip from "./Tooltip";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import AboutCell from "./AboutCell";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
import DataTable, { type Column } from "./ui/DataTable";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import ColumnSortControl from "./ColumnSortControl";
|
||||
import Pager from "./Pager";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import ColumnSortControl from "./ui/ColumnSortControl";
|
||||
import Pager from "./ui/Pager";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { useChannelLayout } from "./ChannelsProvider";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Column } from "./DataTable";
|
||||
import VirtualGrid from "./VirtualGrid";
|
||||
import { type Column } from "./ui/DataTable";
|
||||
import VirtualGrid from "./ui/VirtualGrid";
|
||||
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
|
||||
|
||||
// The channel manager's `cards` layout (E4 S3), built by reusing the SAME `Column<T>` render closures
|
||||
@@ -84,7 +84,7 @@ export default function ChannelLayoutGrid<T>({
|
||||
items={rows}
|
||||
getKey={rowKey}
|
||||
minCol={spec.minCol}
|
||||
maxCol={null}
|
||||
colMaxWidth={null}
|
||||
rowEst={spec.rowEst}
|
||||
renderItem={renderCard}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import Avatar from "./Avatar";
|
||||
import Tooltip from "./Tooltip";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import { channelYouTubeUrl } from "../lib/format";
|
||||
|
||||
// Avatar + channel name + "open on YouTube" link, shared by the Channel manager and the
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { channelDetailPollMs } from "../lib/channelDetailPoll";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useNavigationActions } from "./NavigationProvider";
|
||||
import { PriorityStepper } from "./PriorityStepper";
|
||||
import { PriorityStepper } from "./ui/PriorityStepper";
|
||||
import { useChannelPriority } from "../lib/useChannelPriority";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -20,11 +20,11 @@ import {
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import Avatar from "./Avatar";
|
||||
import Tabs from "./Tabs";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import Feed from "./Feed";
|
||||
import ChannelAboutContent from "./ChannelAbout";
|
||||
import { PageToolbar, PageToolbarSlot } from "./PageShell";
|
||||
import { PageToolbar, PageToolbarSlot } from "./ui/PageShell";
|
||||
import { useFeedFilters } from "./FeedFiltersProvider";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { notify } from "../lib/notifications";
|
||||
@@ -416,14 +416,14 @@ export default function ChannelPage({
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
{ch?.subscribed && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onShowInFeed}
|
||||
title={t("channel.showInFeed")}
|
||||
aria-label={t("channel.showInFeed")}
|
||||
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-accent border border-border hover:border-accent transition"
|
||||
>
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
/>
|
||||
)}
|
||||
{ch?.subscribed && !me.is_demo && (
|
||||
<div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, type FeedFilters } from "../lib/api";
|
||||
import Combobox, { type ComboboxOption } from "./Combobox";
|
||||
import Combobox, { type ComboboxOption } from "./ui/Combobox";
|
||||
|
||||
// Pick channels to narrow the feed to — several OR together, so every other filter then applies
|
||||
// across all of them. Until this existed the filter was settable only via a shared "?channel=" link:
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type MyStatus } from "../lib/api";
|
||||
import { formatEta } from "../lib/format";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
// 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
|
||||
|
||||
@@ -2,10 +2,10 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Check, Plus, Sparkles, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Tag } from "../lib/api";
|
||||
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
|
||||
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
|
||||
// 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:
|
||||
@@ -129,7 +129,7 @@ function SuggestedChipsLane({
|
||||
}, [hidden, overflowOpen]);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-1 min-w-0 flex-1">
|
||||
<div className={`${chipLaneClass} flex items-center gap-1 min-w-0 flex-1`}>
|
||||
{/* Hidden measurer — real ghost chips at natural width so the fit hook reads their right edges. */}
|
||||
<div
|
||||
aria-hidden
|
||||
@@ -374,7 +374,7 @@ export function ChannelTagsControl({
|
||||
// collapses the rest into "+N". (max-w-full lets a narrower card shrink it.) Unbounded, the row
|
||||
// takes its container's width, so a wide header shows the tags inline instead of collapsing them.
|
||||
<div
|
||||
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
|
||||
className={`${chipLaneClass} flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
|
||||
>
|
||||
{/* 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-
|
||||
|
||||
@@ -10,26 +10,27 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
||||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import DataTable from "./DataTable";
|
||||
import Tabs, { type TabDef } from "./Tabs";
|
||||
import { Button } from "./ui/Button";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import DataTable from "./ui/DataTable";
|
||||
import Tabs, { type TabDef } from "./ui/Tabs";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||
import { useTagSuggestions } from "./useTagSuggestions";
|
||||
import { useChannelPriority } from "../lib/useChannelPriority";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import ColumnSortControl from "./ColumnSortControl";
|
||||
import FilterChipGroup from "./FilterChipGroup";
|
||||
import ColumnSortControl from "./ui/ColumnSortControl";
|
||||
import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
import TagFilterChips, { type FilterChip } from "./TagFilterChips";
|
||||
import ActionMenu, { type ActionMenuItem } from "./ActionMenu";
|
||||
import ActionMenu, { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import ChannelStatsPopover from "./ChannelStatsPopover";
|
||||
import Pager from "./Pager";
|
||||
import Pager from "./ui/Pager";
|
||||
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
|
||||
import { sortRows } from "../lib/columnSort";
|
||||
import { filterRows } from "./tableHeader";
|
||||
import { filterRows } from "./ui/tableHeader";
|
||||
import { useCardPager } from "../lib/useCardPager";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import ChannelDiscovery from "./ChannelDiscovery";
|
||||
import TagManager from "./TagManager";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
@@ -489,13 +490,15 @@ export default function Channels() {
|
||||
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
|
||||
"Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
|
||||
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="shrink-0 uppercase tracking-wide"
|
||||
onClick={() => setTagManagerOpen(true)}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-xs uppercase tracking-wide text-muted transition hover:border-accent hover:text-accent"
|
||||
icon={<Pencil className="h-3 w-3" />}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
{t("channels.tags.label")}
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TagFilterChips chips={tagFilterChips} className="flex-1" />
|
||||
{tagFilter.length > 0 && (
|
||||
@@ -518,12 +521,9 @@ export default function Channels() {
|
||||
</span>
|
||||
</Tooltip>
|
||||
{highConfidence.length > 0 && (
|
||||
<button
|
||||
onClick={acceptAllHighConfidence}
|
||||
className="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:border-accent hover:text-accent transition"
|
||||
>
|
||||
<Button variant="outline" size="xs" onClick={acceptAllHighConfidence}>
|
||||
{t("channels.tags.acceptAllHigh", { count: highConfidence.length })}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -22,8 +22,8 @@ import {
|
||||
hasChannelsParams,
|
||||
type ChannelsUrlState,
|
||||
} from "../lib/channelsUrlState";
|
||||
import { usePersistedTab } from "./Tabs";
|
||||
import type { FilterMap } from "./tableHeader";
|
||||
import { usePersistedTab } from "./ui/Tabs";
|
||||
import type { FilterMap } from "./ui/tableHeader";
|
||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
import type { ChannelsView } from "./Channels";
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
} from "../lib/chatDock";
|
||||
import { onMessage, onUnread } from "../lib/messagesSocket";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import Avatar from "./Avatar";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import ChatThread from "./ChatThread";
|
||||
import Overlay from "./Overlay";
|
||||
import Overlay from "./ui/Overlay";
|
||||
|
||||
// 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
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Send } from "lucide-react";
|
||||
import { api, HttpError, type Message, type MessageUser } from "../lib/api";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { relativeTime } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { partnerPub, POLL_MS, refreshPartnerPub, useKeyState } from "../lib/messaging";
|
||||
@@ -213,15 +214,16 @@ export default function ChatThread({
|
||||
placeholder={t("messages.writePlaceholder")}
|
||||
className="flex-1 resize-none max-h-28 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={submit}
|
||||
disabled={!draft.trim() || send.isPending}
|
||||
className="shrink-0 p-2.5 rounded-xl btn-primary transition disabled:opacity-40"
|
||||
aria-label={t("messages.send")}
|
||||
title={t("messages.send")}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Send className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -5,12 +5,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plug, RotateCcw, Send } from "lucide-react";
|
||||
import { api, type ConfigItem, type PlexTestResult } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import InlineNotice from "./InlineNotice";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import InlineNotice from "./ui/InlineNotice";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import Tabs, { usePersistedTab } from "./Tabs";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import Tabs, { usePersistedTab } from "./ui/Tabs";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { Switch } from "./ui/form";
|
||||
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
|
||||
import { LS } from "../lib/storage";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CalendarDays, Check, ChevronDown } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
import { type FeedFilters } from "../lib/api";
|
||||
import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import Banner from "./Banner";
|
||||
import Banner from "./ui/Banner";
|
||||
|
||||
// Permanent, non-dismissible bar shown to the shared demo account so it's always clear the
|
||||
// state is communal. (Replaces the old login-time toast, which re-popped on every reload.)
|
||||
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
} from "lucide-react";
|
||||
import Tabs from "./Tabs";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
|
||||
// used (the video editor in particular is heavy — filmstrip + scrubber).
|
||||
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
||||
|
||||
@@ -2,7 +2,7 @@ import { lazy, Suspense, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import { api } from "../lib/api";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { dismissError, getError, subscribeError } from "../lib/errorDialog";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// Renders the current app error (if any) as a modal the user must acknowledge.
|
||||
|
||||
@@ -38,12 +38,13 @@ import { FEED_VIEWS, type FeedView } from "../lib/feedView";
|
||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||
import ActiveFilterChips from "./ActiveFilterChips";
|
||||
import SelectedViewChip from "./SelectedViewChip";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useWizard } from "./WizardProvider";
|
||||
import SortControl from "./SortControl";
|
||||
import FilterSelect from "./FilterSelect";
|
||||
import ItemCount from "./ItemCount";
|
||||
import SortControl from "./ui/SortControl";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
import ItemCount from "./ui/ItemCount";
|
||||
import ViewSwitcher from "./ViewSwitcher";
|
||||
import VirtualFeed from "./VirtualFeed";
|
||||
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
||||
@@ -530,20 +531,18 @@ export default function Feed({
|
||||
{isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")}
|
||||
</p>
|
||||
{isDemo ? (
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => setFilters({ ...filters, scope: "all" })}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold btn-primary transition"
|
||||
>
|
||||
{t("feed.browseLibrary")}
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={onOpenWizard}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold btn-primary transition"
|
||||
>
|
||||
<Button variant="primary" size="lg" onClick={onOpenWizard}>
|
||||
{t("feed.setUp")}
|
||||
</button>
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, scope: "all" })}
|
||||
className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Library, User } from "lucide-react";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
|
||||
// The feed's Mine/Library scope, relocated from the filter sidebar to sit just right of the header
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { defaultLayout, type PanelId, type PanelLayout } from "../lib/panelLayout";
|
||||
import SidePanel from "./SidePanel";
|
||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||
import SidePanel from "./ui/SidePanel";
|
||||
import PanelGroups, { type PanelItem } from "./ui/PanelGroups";
|
||||
|
||||
// The shared filter-rail shell, extracted from the three rails that all render the same SidePanel
|
||||
// chrome around a PanelGroups body: the feed Sidebar, the PlexSidebar, and the PlaylistsRail. It owns
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Overlay from "./Overlay";
|
||||
import Overlay from "./ui/Overlay";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { usePrefs } from "./PrefsProvider";
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Lock, ShieldCheck } from "lucide-react";
|
||||
import { api, HttpError } from "../lib/api";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import { Button } from "./ui/Button";
|
||||
import { inputCls } from "./ui/form";
|
||||
|
||||
// Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device.
|
||||
@@ -107,21 +108,17 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
)}
|
||||
{err && <div className="text-sm text-danger">{err}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={cancelReset}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-xl text-sm border border-border hover:border-accent transition disabled:opacity-40"
|
||||
>
|
||||
<Button variant="outline" onClick={cancelReset} disabled={busy}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={doReset}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-danger/90 text-danger-fg hover:bg-danger transition disabled:opacity-40"
|
||||
icon={<AlertTriangle className="w-4 h-4" />}
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
{t("messages.resetButton")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -157,14 +154,10 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
/>
|
||||
)}
|
||||
{err && <div className="text-sm text-danger">{err}</div>}
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || !pass}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition disabled:opacity-40"
|
||||
>
|
||||
<Button variant="primary" size="md" onClick={submit} disabled={busy || !pass}>
|
||||
<Lock className="w-4 h-4" />
|
||||
{configured ? t("messages.unlock") : t("messages.setUp")}
|
||||
</button>
|
||||
</Button>
|
||||
{configured && (
|
||||
<button onClick={toReset} className="block text-xs text-muted hover:text-fg">
|
||||
{t("messages.forgotPass")}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Check, Globe } from "lucide-react";
|
||||
import { LANGUAGES, type LangCode } from "../i18n";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
// Compact language picker (globe + current code). Presentational: the parent decides what
|
||||
// changing the language does (set it; persist server-side when signed in).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { jobTitle } from "./downloadRow";
|
||||
|
||||
|
||||
@@ -5,13 +5,14 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
|
||||
import { api, type Conversation, type MessageUser } from "../lib/api";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { relativeTime } from "../lib/format";
|
||||
import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging";
|
||||
import { useHistorySubview } from "../lib/history";
|
||||
import { openChat } from "../lib/chatDock";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import Avatar from "./Avatar";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import KeyGate from "./KeyGate";
|
||||
import { inputCls } from "./ui/form";
|
||||
import ChatThread from "./ChatThread";
|
||||
@@ -123,13 +124,10 @@ function ConversationList({
|
||||
{t("messages.conversations")}
|
||||
</div>
|
||||
{ready && (
|
||||
<button
|
||||
onClick={onNew}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg btn-primary transition"
|
||||
>
|
||||
<Button variant="primary" size="xs" onClick={onNew}>
|
||||
<SquarePen className="w-4 h-4" />
|
||||
{t("messages.new")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(keyState === "setup" || keyState === "unlock") && <KeyGate meId={meId} />}
|
||||
@@ -213,12 +211,15 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
// An icon-only button needs a name: both of these announced themselves as a bare "button",
|
||||
// and they are the only way out of the sub-view (E10 review round 1).
|
||||
label={t("channel.back")}
|
||||
onClick={onBack}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<ArrowLeft className="w-4 h-4" />}
|
||||
/>
|
||||
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.newTitle")}</div>
|
||||
</div>
|
||||
<input
|
||||
@@ -280,12 +281,15 @@ function PageThread({
|
||||
return (
|
||||
<div className="flex flex-col h-[65vh] min-h-80">
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-border">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
// An icon-only button needs a name: both of these announced themselves as a bare "button",
|
||||
// and they are the only way out of the sub-view (E10 review round 1).
|
||||
label={t("channel.back")}
|
||||
onClick={onBack}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<ArrowLeft className="w-4 h-4" />}
|
||||
/>
|
||||
<Avatar
|
||||
src={seedAvatar}
|
||||
fallback={name}
|
||||
|
||||
@@ -12,10 +12,11 @@ import type { Page } from "../lib/urlState";
|
||||
import { moduleDef, moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { type LangCode } from "../i18n";
|
||||
import AvatarImg from "./Avatar";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { Button } from "./ui/Button";
|
||||
import AvatarImg from "./ui/Avatar";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
|
||||
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
|
||||
@@ -221,22 +222,22 @@ export default function NavSidebar({
|
||||
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
|
||||
<div className="flex flex-col items-center justify-center gap-2 pb-2 mb-1 border-b border-border">
|
||||
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onOpenAbout}
|
||||
title={t("header.account.about")}
|
||||
aria-label={t("header.account.about")}
|
||||
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<Info className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
icon={<Info className="w-5 h-5" />}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={logout}
|
||||
title={t("header.account.signOut")}
|
||||
aria-label={t("header.account.signOut")}
|
||||
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
</button>
|
||||
icon={<LogOut className="w-5 h-5" />}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip hint={t("header.account.settings")} side="right" always openDelay={350}>
|
||||
<button
|
||||
|
||||
@@ -37,9 +37,10 @@ import {
|
||||
type VideoWatchedMeta,
|
||||
} from "../lib/notifications";
|
||||
import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
|
||||
import { LEVEL_STYLE } from "./Toaster";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LEVEL_STYLE } from "./ui/Toaster";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { useNavigationActions } from "./NavigationProvider";
|
||||
import { useChannelsActions } from "./ChannelsProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
@@ -132,22 +133,24 @@ export default function NotificationsPanel() {
|
||||
</div>
|
||||
{hasAny && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={markAll}
|
||||
disabled={!hasUnread || readAllMut.isPending}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40"
|
||||
icon={<CheckCheck className="w-4 h-4" />}
|
||||
>
|
||||
<CheckCheck className="w-4 h-4" />
|
||||
{t("inbox.markAllRead")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={clearEverything}
|
||||
disabled={clearMut.isPending}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40"
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t("inbox.clearAll")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -305,43 +308,45 @@ function NotificationRow({
|
||||
</ul>
|
||||
)}
|
||||
{isScheduler && (
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm mt-1.5"
|
||||
onClick={onOpenScheduler}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1.5"
|
||||
icon={<Activity className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Activity className="w-3.5 h-3.5" />
|
||||
{t("inbox.openScheduler")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{isDownloadDone && (
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm mt-1.5"
|
||||
onClick={onOpenDownloads}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1.5"
|
||||
icon={<Download className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{t("inbox.openDownloads")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{!n.read && (
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onRead}
|
||||
title={t("inbox.markRead")}
|
||||
aria-label={t("inbox.markRead")}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Check className="w-4 h-4" />}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDismiss}
|
||||
title={t("inbox.dismiss")}
|
||||
aria-label={t("inbox.dismiss")}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<X className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -396,47 +401,52 @@ function ClientActivityRow({
|
||||
</div>
|
||||
{hidden ? (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onFind(hidden)}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||
icon={<Search className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
{t("notifications.findInFeed")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onRevert(hidden)}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||
icon={<Eye className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
{t("notifications.unhide")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : watched ? (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onFind(watched)}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||
icon={<Search className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
{t("notifications.findInFeed")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onRevert(watched)}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||
icon={<RotateCcw className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t("notifications.unwatch")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : subscribed ? (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onFocusChannel(subscribed.channelName)}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||
icon={<Tv className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Tv className="w-3.5 h-3.5" />
|
||||
{t("notifications.openInManager")}
|
||||
</button>
|
||||
</Button>
|
||||
<a
|
||||
href={channelYouTubeUrl(subscribed.channelId)}
|
||||
target="_blank"
|
||||
@@ -448,36 +458,39 @@ function ClientActivityRow({
|
||||
</a>
|
||||
</div>
|
||||
) : access ? (
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm mt-1"
|
||||
onClick={() => onReviewAccess()}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1"
|
||||
icon={<UserPlus className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
{t("common.accessRequestsReview")}
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
n.action &&
|
||||
!n.dismissed && (
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="mt-1 text-sm"
|
||||
onClick={() => {
|
||||
n.action!.onClick();
|
||||
dismissClient(n.id);
|
||||
}}
|
||||
className="mt-1 text-accent text-sm font-semibold hover:underline"
|
||||
>
|
||||
{n.action.label}
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onRemove}
|
||||
title={t("inbox.dismiss")}
|
||||
aria-label={t("inbox.dismiss")}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<X className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { qk } from "../lib/queryKeys";
|
||||
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
|
||||
import { api, type Me } from "../lib/api";
|
||||
import { beginGrant, endOnboarding } from "../lib/onboarding";
|
||||
import Overlay from "./Overlay";
|
||||
import { Button } from "./ui/Button";
|
||||
import Overlay from "./ui/Overlay";
|
||||
|
||||
// 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
|
||||
@@ -159,12 +160,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
>
|
||||
<Youtube className="w-4 h-4" /> {t("onboarding.read.connect")}
|
||||
</button>
|
||||
<button
|
||||
onClick={close}
|
||||
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
|
||||
>
|
||||
<Button variant="ghost" size="md" onClick={close}>
|
||||
{t("onboarding.read.skip")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -225,15 +223,16 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" /> {t("onboarding.write.enableUnsubscribe")}
|
||||
</button>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
onClick={() => {
|
||||
endOnboarding(false);
|
||||
onClose();
|
||||
}}
|
||||
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
|
||||
>
|
||||
{t("onboarding.write.keepReadOnly")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -17,13 +17,13 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import { useSavedViews, asFilters } from "./SavedViewsProvider";
|
||||
import { useActiveFilters } from "../lib/useActiveFilters";
|
||||
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
||||
import { useUndoable } from "../lib/useUndoable";
|
||||
import UndoToolbar from "./UndoToolbar";
|
||||
import UndoToolbar from "./ui/UndoToolbar";
|
||||
|
||||
// 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
|
||||
|
||||
@@ -28,9 +28,9 @@ import {
|
||||
VolumeX,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Avatar from "./Avatar";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
import { api, type Video, type VideoStatus } from "../lib/api";
|
||||
@@ -44,7 +44,8 @@ import {
|
||||
} from "../lib/format";
|
||||
import { renderDescription } from "../lib/descriptionLinks";
|
||||
import { fullscreenElement } from "../lib/fullscreen";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
||||
import { closeThenNavigate, useBackToClose } from "../lib/history";
|
||||
import {
|
||||
useNativeControlsYield,
|
||||
useYtBigView,
|
||||
@@ -329,72 +330,76 @@ export default function PlayerModal({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcuts (Esc close / F fullscreen / Space play-pause) + background scroll lock.
|
||||
useKeyScope("ytPlayer");
|
||||
|
||||
// Keyboard shortcuts + background scroll lock. The chords live in the shared keymap (`ytPlayer`
|
||||
// scope) so the help sheet lists them; only the behaviour behind each id is here.
|
||||
// These fire only while focus is on our page, not inside the cross-origin player iframe — so
|
||||
// we focus the modal card on open and again on player-ready (autoplay can grab focus).
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
|
||||
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
|
||||
// maximise or close the player underneath it. The consumed-event flag is the reliable signal
|
||||
// (UX-8): the layer's `document` listener runs before ours and its close can pop it off the
|
||||
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
|
||||
if (escapeHandled(e)) return;
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
|
||||
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
||||
if (fullscreenElement()) return;
|
||||
// Same for our own maximise: Esc steps back out of it before it closes the player.
|
||||
if (maximizedRef.current) {
|
||||
e.preventDefault();
|
||||
setMaximized(false);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
// Volume by keyboard (YouTube/Plex-style ±5). The only volume control that survives fullscreen,
|
||||
// where the wheel goes to YouTube's own iframe. But the modal card scrolls (a long description)
|
||||
// and Up/Down is how you scroll it from the keyboard — so defer whenever the focused element can
|
||||
// still scroll that way, exactly like Space defers to a focused button. NOT while the video fills
|
||||
// the screen though — in fullscreen OR in our own maximise — because the card is behind/off-screen
|
||||
// there: deferring would scroll something invisible and leave the viewer with no volume control at
|
||||
// all. Both flags, not just fullscreen: since F stopped taking the browser's lock, maximise is the
|
||||
// common case and checking only `isFullscreen` made the keys dead exactly where they matter most.
|
||||
const volume = (e: KeyboardEvent, up: boolean) => {
|
||||
const covered = isFullscreenRef.current || maximizedRef.current;
|
||||
if (!covered && canScrollBy(document.activeElement as HTMLElement | null, up ? -1 : 1))
|
||||
return;
|
||||
}
|
||||
const el = document.activeElement as HTMLElement | null;
|
||||
const tag = (el?.tagName || "").toLowerCase();
|
||||
const typing =
|
||||
tag === "input" || tag === "textarea" || tag === "select" || !!el?.isContentEditable;
|
||||
if (e.key === "f" || e.key === "F") {
|
||||
if (typing) return;
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
} else if (e.key === " " || e.code === "Space") {
|
||||
// Let Space activate a focused button/link; otherwise toggle playback (and stop the
|
||||
// page from scrolling).
|
||||
if (typing || tag === "button" || tag === "a") return;
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
// Volume by keyboard (YouTube/Plex-style ±5). The only volume control that survives
|
||||
// fullscreen, where the wheel goes to YouTube's own iframe. But the modal card scrolls
|
||||
// (a long description) and Up/Down is how you scroll it from the keyboard — so defer
|
||||
// whenever the focused element can still scroll that way, exactly like Space defers to a
|
||||
// focused button. NOT while the video fills the screen though — in fullscreen OR in our own
|
||||
// maximise — because the card is behind/off-screen there: deferring would scroll something
|
||||
// invisible and leave the viewer with no volume control at all. Both flags, not just
|
||||
// fullscreen: since F stopped taking the browser's lock, maximise is the common case and
|
||||
// checking only `isFullscreen` made the keys dead exactly where they matter most.
|
||||
const up = e.key === "ArrowUp";
|
||||
const covered = isFullscreenRef.current || maximizedRef.current;
|
||||
if (typing || (!covered && canScrollBy(el, up ? -1 : 1))) return;
|
||||
e.preventDefault();
|
||||
nudgeVolume(up ? 5 : -5);
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
// Plain arrows seek within the video (YouTube-style ±5s); Shift+arrow steps to the
|
||||
// previous/next video in the queue (the feed's order or a playlist).
|
||||
if (typing) return;
|
||||
e.preventDefault();
|
||||
const forward = e.key === "ArrowRight";
|
||||
if (e.shiftKey) {
|
||||
if (forward) goNext();
|
||||
else goPrev();
|
||||
} else {
|
||||
nudgeSeek(forward ? 5 : -5);
|
||||
}
|
||||
}
|
||||
e.preventDefault();
|
||||
nudgeVolume(up ? 5 : -5);
|
||||
};
|
||||
const seek = (e: KeyboardEvent, secs: number) => {
|
||||
e.preventDefault();
|
||||
nudgeSeek(secs);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
dispatch(e, "ytPlayer", {
|
||||
close: (ev) => {
|
||||
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
|
||||
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
|
||||
// maximise or close the player underneath it. The consumed-event flag is the reliable signal
|
||||
// (UX-8): the layer's `document` listener runs before ours and its close can pop it off the
|
||||
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
|
||||
if (escapeHandled(ev)) return;
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
|
||||
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
||||
if (fullscreenElement()) return;
|
||||
// Same for our own maximise: Esc steps back out of it before it closes the player.
|
||||
if (maximizedRef.current) {
|
||||
ev.preventDefault();
|
||||
setMaximized(false);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
fullscreen: (ev) => {
|
||||
ev.preventDefault();
|
||||
toggleFullscreen();
|
||||
},
|
||||
playPause: (ev) => {
|
||||
// Let Space activate a focused button/link; otherwise toggle playback (and stop the
|
||||
// page from scrolling).
|
||||
if (isActivationTarget(ev)) return;
|
||||
ev.preventDefault();
|
||||
togglePlay();
|
||||
},
|
||||
volumeUp: (ev) => volume(ev, true),
|
||||
volumeDown: (ev) => volume(ev, false),
|
||||
seekBack: (ev) => seek(ev, -5),
|
||||
seekFwd: (ev) => seek(ev, 5),
|
||||
prevVideo: (ev) => {
|
||||
ev.preventDefault();
|
||||
goPrev();
|
||||
},
|
||||
nextVideo: (ev) => {
|
||||
ev.preventDefault();
|
||||
goNext();
|
||||
},
|
||||
});
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
focusModal();
|
||||
@@ -751,16 +756,11 @@ export default function PlayerModal({
|
||||
onClick={() => {
|
||||
const cid = active.channel_id;
|
||||
const cname = active.channel_title ?? undefined;
|
||||
// Closing the player pops its own history entry (useBackToClose → history.back()).
|
||||
// Open the channel only AFTER that pop's popstate — pushing the channel entry
|
||||
// before it would let the back remove it again (the bug: clicking the name only
|
||||
// closed the player). One-shot listener fires after the popstate handler.
|
||||
const open = () => {
|
||||
window.removeEventListener("popstate", open);
|
||||
openChannel(cid, cname);
|
||||
};
|
||||
window.addEventListener("popstate", open);
|
||||
onClose();
|
||||
// Close first, open the channel only once this player's own Back-to-close
|
||||
// entry has been reclaimed — otherwise that reclaim undoes the push and
|
||||
// clicking the name merely closes the player. The rule lives in history.ts
|
||||
// now; the command palette needs exactly the same one.
|
||||
closeThenNavigate(onClose, () => openChannel(cid, cname));
|
||||
}}
|
||||
className="font-medium hover:text-accent truncate text-left"
|
||||
>
|
||||
|
||||
@@ -21,8 +21,13 @@ import {
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ArrowDownToLine,
|
||||
ArrowUp,
|
||||
ArrowUpToLine,
|
||||
Check,
|
||||
ExternalLink,
|
||||
FolderInput,
|
||||
GripVertical,
|
||||
ListPlus,
|
||||
Pencil,
|
||||
@@ -31,9 +36,10 @@ import {
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
Youtube,
|
||||
} from "lucide-react";
|
||||
import { api, HttpError, type Video } from "../lib/api";
|
||||
import { api, HttpError, type Playlist, type Video } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
@@ -42,10 +48,15 @@ import { cycleSort, type SortState } from "../lib/columnSort";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { applyView, sameOrder } from "../lib/playlistView";
|
||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
import UndoToolbar from "./UndoToolbar";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { useChoice, useConfirm } from "./ConfirmProvider";
|
||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||
import { Button } from "./ui/Button";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import UndoToolbar from "./ui/UndoToolbar";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { usePlaylistActions } from "./usePlaylistActions";
|
||||
import { usePlaylists } from "./PlaylistsProvider";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import {
|
||||
ColumnHead,
|
||||
@@ -54,7 +65,7 @@ import {
|
||||
isActive,
|
||||
type Column,
|
||||
type FilterMap,
|
||||
} from "./tableHeader";
|
||||
} from "./ui/tableHeader";
|
||||
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
|
||||
|
||||
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
|
||||
@@ -87,11 +98,16 @@ function Row({
|
||||
columns,
|
||||
dragDisabled,
|
||||
dragHint,
|
||||
menuProps,
|
||||
}: {
|
||||
video: Video;
|
||||
columns: Column<Video>[];
|
||||
dragDisabled: boolean;
|
||||
dragHint: string;
|
||||
/** useContextMenu's trigger props. On the WHOLE row, not a handle: a right-click anywhere in the
|
||||
* row opens the menu, the way a native list does. The row's own buttons keep their left-click
|
||||
* behaviour — a right-click just isn't a button press. */
|
||||
menuProps: Record<string, unknown>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
@@ -111,6 +127,7 @@ function Row({
|
||||
// transition-COLORS, not `transition` — the bare utility animates transform too, which is
|
||||
// the same lag by another route (150ms behind the pointer, on every row).
|
||||
className="border-b border-border/50 hover:bg-card/40 transition-colors"
|
||||
{...menuProps}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
@@ -152,11 +169,10 @@ export default function Playlists() {
|
||||
// The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the
|
||||
// list + its rail controls). This detail pane reacts to the selection.
|
||||
const { selectedId } = usePlaylists();
|
||||
const { setSelectedId } = usePlaylistsActions();
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const choose = useChoice();
|
||||
const plActions = usePlaylistActions();
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
// The stored order is undoable: drag and "save this order" both go through `order.set`, so each
|
||||
@@ -201,6 +217,9 @@ export default function Playlists() {
|
||||
// a change of view would otherwise slide a different video under the index — or unmount the player
|
||||
// mid-playback. PlayerModal already tracks its own queue by id; this is the parent half of that.
|
||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||
// ONE context menu for the whole table, not one popover per <tr> (see ui/ContextMenu): the row it
|
||||
// belongs to is `rowMenu.target`, and the panel anchors to the pointer.
|
||||
const rowMenu = useContextMenu<Video>();
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: qk.playlists(),
|
||||
@@ -482,58 +501,65 @@ export default function Playlists() {
|
||||
const removeRef = useRef(removeItem);
|
||||
removeRef.current = removeItem;
|
||||
|
||||
async function moveItemTo(video: Video, target: Playlist) {
|
||||
if (selectedId == null) return;
|
||||
// Optimistic, exactly like removeItem: from THIS list's point of view a move is a removal, and
|
||||
// rebasing keeps the undo stack valid for what's left instead of throwing it away.
|
||||
const next = items.filter((v) => v.id !== video.id);
|
||||
order.rebase((list) => list.filter((v) => v.id !== video.id));
|
||||
lastSetRef.current = `${selectedId}:${idsKey(next)}`;
|
||||
try {
|
||||
// ONE request. The two-call version (add there, remove here) leaves the video in BOTH lists
|
||||
// whenever the second call fails — see the endpoint's docstring.
|
||||
await api.moveToPlaylist(selectedId, video.id, target.id);
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("playlists.movedToast", { title: video.title, name: plName(target) }),
|
||||
});
|
||||
} catch (e) {
|
||||
// NOT a silent revert. `req` deliberately leaves 401/403/404 to the caller as control flow, and
|
||||
// every move failure a user can actually reach is a 404 (the 400 `same_playlist` case is already
|
||||
// impossible — the submenu never offers the current list). So the row used to snap back with no
|
||||
// explanation and the shipped moveErrors strings were unreachable (E10 review round 1).
|
||||
// ONLY the statuses `req` deliberately leaves to the caller. It already opens the blocking
|
||||
// error dialog for 400/409/422 and 5xx and raises the connectivity notice for a bad gateway,
|
||||
// so toasting unconditionally reported a transport fault twice (E10 review round 2).
|
||||
if (e instanceof HttpError && (e.status === 404 || e.status === 403))
|
||||
notify({ level: "error", message: e.detail || t("playlists.moveFailed") });
|
||||
}
|
||||
// BOTH sides changed, and so did this video's membership. `qk.playlist(selectedId)` is an exact
|
||||
// key, so it cannot cover the target — invalidate the whole `["playlist"]` prefix, exactly as
|
||||
// AddToPlaylist does for the same reason.
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||
qc.invalidateQueries({ queryKey: qk.playlistMembership(video.id) });
|
||||
}
|
||||
|
||||
// Keyboard/menu reordering, for the same stored order the drag handle writes — so it goes through
|
||||
// `order.set` (undoable + persisted), not a direct API call. Gated by the same rule as the drag
|
||||
// handle: a view (sort/group/filter) is not the stored order, so moving "up" inside one would
|
||||
// write something the user never saw.
|
||||
function moveInOrder(video: Video, to: "top" | "up" | "down" | "bottom") {
|
||||
const from = items.findIndex((v) => v.id === video.id);
|
||||
if (from < 0) return;
|
||||
const target =
|
||||
to === "top" ? 0 : to === "bottom" ? items.length - 1 : to === "up" ? from - 1 : from + 1;
|
||||
if (target === from || target < 0 || target >= items.length) return;
|
||||
order.set(arrayMove(items, from, target)); // onApply persists
|
||||
}
|
||||
|
||||
async function saveRename() {
|
||||
if (selectedId == null) return;
|
||||
const name = renameValue.trim();
|
||||
setRenaming(false);
|
||||
if (name && name !== detail?.name) {
|
||||
try {
|
||||
await api.renamePlaylist(selectedId, name);
|
||||
} catch (e) {
|
||||
notifyYouTubeActionError(e, t("playlists.renameFailed"));
|
||||
}
|
||||
refreshAll();
|
||||
}
|
||||
if (name && name !== detail?.name) await plActions.rename(selectedId, name);
|
||||
}
|
||||
|
||||
async function deletePlaylist() {
|
||||
if (selectedId == null || !detail) return;
|
||||
// ONE dialog with every outcome. It used to be two chained confirms, where the second one's
|
||||
// Cancel/Escape/backdrop meant "delete here only" — dismissing the dialog deleted the playlist.
|
||||
const offerYoutube = linked && canWrite;
|
||||
const picked = await choose({
|
||||
title: t("playlists.delete"),
|
||||
message: offerYoutube
|
||||
? t("playlists.confirmDeleteLinked", { name: plName(detail) })
|
||||
: t("playlists.confirmDelete", { name: plName(detail) }),
|
||||
choices: offerYoutube
|
||||
? [
|
||||
{ id: "here", label: t("playlists.deleteHereOnly"), danger: true },
|
||||
{ id: "both", label: t("playlists.deleteBoth"), danger: true },
|
||||
]
|
||||
: [{ id: "here", label: t("playlists.delete"), danger: true }],
|
||||
});
|
||||
if (picked == null) return;
|
||||
const onYoutube = picked === "both";
|
||||
const deletedId = selectedId;
|
||||
// Pick the next selection from what's left *now* (don't go via null, or the
|
||||
// auto-select effect would re-pick the just-deleted id from the stale list).
|
||||
const remaining = playlists.filter((p) => p.id !== deletedId);
|
||||
setSelectedId(remaining[0]?.id ?? null);
|
||||
try {
|
||||
await api.deletePlaylist(deletedId, onYoutube);
|
||||
} catch (err) {
|
||||
// Prefer the server's specific reason (e.g. a local vs YouTube-side failure) over a blanket
|
||||
// "couldn't delete on YouTube" that misleads when the failure wasn't the YouTube side.
|
||||
notify({
|
||||
level: "warning",
|
||||
message:
|
||||
(err instanceof HttpError && err.detail) ||
|
||||
(onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")),
|
||||
});
|
||||
}
|
||||
qc.removeQueries({ queryKey: qk.playlist(deletedId) });
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
if (!detail) return;
|
||||
// The dialog, the "delete on YouTube too?" branch and the re-selection live in
|
||||
// usePlaylistActions — the rail's context menu deletes through the SAME code.
|
||||
await plActions.remove(detail);
|
||||
}
|
||||
|
||||
function playerState(id: string, status: string) {
|
||||
@@ -573,6 +599,85 @@ export default function Playlists() {
|
||||
: t("playlists.dragBlockedSort");
|
||||
const playingIndex = playingId ? shown.findIndex((v) => v.id === playingId) : -1;
|
||||
|
||||
// The row menu, built from the row the user right-clicked. Same item shape (and therefore same
|
||||
// rows, same paint, same keyboard model) as any ⋯ ActionMenu in the app.
|
||||
const moveTargets = playlists.filter((p) => p.id !== selectedId);
|
||||
const storedOrderEditable = canEdit && !viewActive;
|
||||
const ctxRow = rowMenu.target;
|
||||
const ctxItems: ActionMenuItem[] = !ctxRow
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "play",
|
||||
label: t("card.play"),
|
||||
icon: <Play className="w-4 h-4" />,
|
||||
onSelect: () => setPlayingId(ctxRow.id),
|
||||
},
|
||||
...(canEdit
|
||||
? ([
|
||||
moveTargets.length
|
||||
? {
|
||||
id: "move",
|
||||
label: t("playlists.moveTo"),
|
||||
icon: <FolderInput className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
items: moveTargets.map((pl) => ({
|
||||
id: `move-${pl.id}`,
|
||||
label: plName(pl),
|
||||
icon:
|
||||
pl.source === "youtube" || pl.yt_playlist_id ? (
|
||||
<Youtube className="w-4 h-4" />
|
||||
) : (
|
||||
<ListPlus className="w-4 h-4" />
|
||||
),
|
||||
onSelect: () => void moveItemTo(ctxRow, pl),
|
||||
})),
|
||||
}
|
||||
: // Kept visible and disabled rather than dropped: an action that vanishes teaches
|
||||
// nobody why. KNOWN GAP: the `title` that carries the why currently reaches nobody
|
||||
// either — see ActionMenu's `title` prop, where the two failed attempts at fixing
|
||||
// that are recorded. The row is honest about being off; it is not yet honest about
|
||||
// the reason.
|
||||
{
|
||||
id: "move",
|
||||
label: t("playlists.moveTo"),
|
||||
icon: <FolderInput className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
disabled: true,
|
||||
title: t("playlists.moveNoTargets"),
|
||||
onSelect: () => {},
|
||||
},
|
||||
...(
|
||||
[
|
||||
["top", t("playlists.orderTop"), <ArrowUpToLine className="w-4 h-4" />],
|
||||
["up", t("playlists.orderUp"), <ArrowUp className="w-4 h-4" />],
|
||||
["down", t("playlists.orderDown"), <ArrowDown className="w-4 h-4" />],
|
||||
["bottom", t("playlists.orderBottom"), <ArrowDownToLine className="w-4 h-4" />],
|
||||
] as const
|
||||
).map(([to, label, icon], i) => ({
|
||||
id: `order-${to}`,
|
||||
label,
|
||||
icon,
|
||||
separatorBefore: i === 0,
|
||||
// Same gate as the drag handle. NOTE the drag handle explains itself and this row
|
||||
// does not: the handle is `aria-disabled` (focusable, tooltip-capable) while a menu
|
||||
// row cannot be — see ActionMenu's `title` prop for why.
|
||||
disabled: !storedOrderEditable,
|
||||
title: storedOrderEditable ? undefined : dragHint,
|
||||
onSelect: () => moveInOrder(ctxRow, to),
|
||||
})),
|
||||
{
|
||||
id: "remove",
|
||||
label: t("playlists.removeItem"),
|
||||
icon: <X className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
danger: true,
|
||||
onSelect: () => void removeItem(ctxRow),
|
||||
},
|
||||
] satisfies ActionMenuItem[])
|
||||
: []),
|
||||
];
|
||||
|
||||
if (selectedId == null) {
|
||||
return <div className="text-muted text-sm p-4">{t("playlists.pickOne")}</div>;
|
||||
}
|
||||
@@ -585,12 +690,9 @@ export default function Playlists() {
|
||||
<AlertTriangle className="w-4 h-4 text-danger" />
|
||||
{t("playlists.loadFailed")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => detailQuery.refetch()}
|
||||
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted hover:text-fg hover:border-accent transition"
|
||||
>
|
||||
<Button variant="outline" size="xs" onClick={() => detailQuery.refetch()}>
|
||||
{t("common.retry")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -703,25 +805,26 @@ export default function Playlists() {
|
||||
</button>
|
||||
)}
|
||||
{linked && detail.dirty && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={revertToYoutube}
|
||||
disabled={reverting}
|
||||
title={t("playlists.revert")}
|
||||
aria-label={t("playlists.revert")}
|
||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{editable && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
onClick={deletePlaylist}
|
||||
title={t("playlists.delete")}
|
||||
aria-label={t("playlists.delete")}
|
||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
icon={<Trash2 className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -755,12 +858,14 @@ export default function Playlists() {
|
||||
</button>
|
||||
)}
|
||||
{canSaveOrder && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline-accent"
|
||||
size="xs"
|
||||
onClick={saveOrder}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
|
||||
icon={<Check className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
|
||||
</button>
|
||||
{t("playlists.saveOrder")}
|
||||
</Button>
|
||||
)}
|
||||
{/* ONE sentence that names the state. Three loose texts used to pile up here
|
||||
(reset link + save hint + "reordering is off"), and none of them answered the
|
||||
@@ -829,6 +934,7 @@ export default function Playlists() {
|
||||
columns={columns}
|
||||
dragDisabled={!canEdit || viewActive}
|
||||
dragHint={dragHint}
|
||||
menuProps={rowMenu.triggerProps(v)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -843,6 +949,9 @@ export default function Playlists() {
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* One surface for the whole table, outside the rows — it anchors to the pointer, not the row. */}
|
||||
<ContextMenu menu={rowMenu} ariaLabel={t("playlists.rowMenu")} items={ctxItems} />
|
||||
|
||||
{playingIndex >= 0 && (
|
||||
<Suspense fallback={null}>
|
||||
<PlayerModal
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
|
||||
import { ListVideo, Pencil, Pin, Plus, RefreshCw, Trash2, Youtube } from "lucide-react";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
@@ -10,20 +10,29 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
||||
import { type PanelLayout } from "../lib/panelLayout";
|
||||
import FilterRail from "./FilterRail";
|
||||
import { type PanelItem } from "./PanelGroups";
|
||||
import SortControl from "./SortControl";
|
||||
import { Button } from "./ui/Button";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { type PanelItem } from "./ui/PanelGroups";
|
||||
import SortControl from "./ui/SortControl";
|
||||
import { type SortOption } from "../lib/columnSort";
|
||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
import { usePlaylistActions } from "./usePlaylistActions";
|
||||
|
||||
type PlSort = {
|
||||
key: "custom" | "name" | "count" | "duration";
|
||||
key: "custom" | "name" | "count" | "duration" | "recent";
|
||||
dir: "asc" | "desc";
|
||||
dirtyFirst: boolean;
|
||||
};
|
||||
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
|
||||
|
||||
// One geometry for the row in both of its shapes (the selectable button, and the rename field it
|
||||
// becomes) — the row must not resize the moment you rename it.
|
||||
const ROW_BASE = "w-full flex items-center gap-2.5 p-2 rounded-lg border transition";
|
||||
|
||||
// The Playlists module's left rail — lifted to App level (like the filter rails) so it floats in the
|
||||
// shared SidePanel with the same collapse-to-tab behavior. It owns the list query + its own rail
|
||||
// controls (sort / new / sync); the selected playlist is App state so the module's detail pane
|
||||
@@ -52,12 +61,23 @@ export default function PlaylistsRail({
|
||||
{ key: "name", label: t("playlists.railSortName"), defaultDir: "asc" },
|
||||
{ key: "count", label: t("playlists.railSortCount"), defaultDir: "desc" },
|
||||
{ key: "duration", label: t("playlists.railSortDuration"), defaultDir: "desc" },
|
||||
// E10 S2 — "the one I just touched, first". Reads `updated_at`, which the API bumps for item
|
||||
// adds/removes/moves/reorders too, not just renames (see the backend's _touch). NOT the
|
||||
// default: a rail that reshuffles itself while you look at it costs more than it saves.
|
||||
{ key: "recent", label: t("playlists.railSortRecent"), defaultDir: "desc" },
|
||||
];
|
||||
useEffect(() => {
|
||||
writeAccount(LS.plSort, plSort);
|
||||
}, [plSort]);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const plActions = usePlaylistActions();
|
||||
// Right-click / long-press anywhere on a rail row. ONE menu for the whole list (see ui/ContextMenu).
|
||||
const railMenu = useContextMenu<Playlist>();
|
||||
// Renaming happens IN the row that was right-clicked, not in the detail header — the point of the
|
||||
// context menu is that the action lands where the gesture did.
|
||||
const [renamingId, setRenamingId] = useState<number | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
||||
const scrolledRef = useRef(false);
|
||||
|
||||
@@ -95,6 +115,8 @@ export default function PlaylistsRail({
|
||||
return sign * (a.item_count - b.item_count);
|
||||
case "duration":
|
||||
return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0));
|
||||
case "recent":
|
||||
return sign * (Date.parse(a.updated_at) - Date.parse(b.updated_at));
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
@@ -141,20 +163,93 @@ export default function PlaylistsRail({
|
||||
},
|
||||
});
|
||||
|
||||
// KNOWN GAP: Enter and Escape unmount the focused <input> and re-render the row as a <button> that
|
||||
// nothing focuses, so a keyboard user is dropped on <body> and loses their place in the rail (E10
|
||||
// review round 1). A refocus-the-row fix was written in round 1 and REMOVED in round 3 because a
|
||||
// real-browser check showed it never worked: Escape's handler does run (typing then Escape leaves
|
||||
// the name unchanged, i.e. it cancels rather than blur-commits) and the row button is focusable
|
||||
// when focused from outside — yet activeElement is still BODY afterwards. Three explanations were
|
||||
// tried and none survived contact, so the machinery went rather than a fourth guess. Whoever picks
|
||||
// this up: instrument the effect in a real browser first, do not reason about the commit order.
|
||||
const renameInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// UNRESOLVED — do not "simplify" this back to `autoFocus` without reading the whole story.
|
||||
//
|
||||
// Opening this field from the context menu is a focus race: `MenuItemList` runs `onSelect()` and
|
||||
// `onClose()` in one batch, so the field mounts in the same commit that unmounts the popover, and
|
||||
// floating-ui's FloatingFocusManager hands focus back from a `queueMicrotask` queued in its
|
||||
// layout-effect cleanup. If that microtask wins, the field is blurred straight back out and its
|
||||
// own onBlur commits and closes it — "the rename field flashes and vanishes" (E10 review round 1).
|
||||
//
|
||||
// Three answers have been tried and none is VERIFIED. Round 1 turned floating-ui's returnFocus off
|
||||
// platform-wide and broke two other surfaces (a non-focusable <tr> trigger; a scroll-dismiss that
|
||||
// scrolled the list back). Round 2 replaced it with this effect, on the reasoning that a passive
|
||||
// effect is scheduled after the microtask. Round 3 disputed that (React flushes passive effects
|
||||
// synchronously at the end of a SyncLane commit, which would put the microtask LAST) — and a jsdom
|
||||
// harness built to settle it reproduced no failure for ANY of autoFocus / this effect / a
|
||||
// microtask, i.e. jsdom does not exercise the race, because focus never enters the menu there.
|
||||
//
|
||||
// So: this is the shape that is deliberate rather than accidental, and the question is open. It
|
||||
// needs ONE real-browser check (open the rail's context menu, pick Rename, see whether the field
|
||||
// survives) — not a fourth guess. Logged in the E10 plan.
|
||||
useEffect(() => {
|
||||
if (renamingId !== null) renameInputRef.current?.focus();
|
||||
}, [renamingId]);
|
||||
|
||||
async function commitRename(pl: Playlist) {
|
||||
const name = renameValue.trim();
|
||||
setRenamingId(null);
|
||||
if (name && name !== pl.name) await plActions.rename(pl.id, name);
|
||||
}
|
||||
|
||||
const ctxPl = railMenu.target;
|
||||
// The built-in Watch later list is renameable/deletable nowhere else in the UI either; the rows
|
||||
// stay visible and disabled so the menu doesn't change shape from list to list.
|
||||
const ctxBuiltin = ctxPl?.kind === "watch_later";
|
||||
// Interpolated, not spelled out: the HU copy said "Watch later", a name the Hungarian UI never
|
||||
// shows for this list ("Megnézem később") — so the message named a list the reader had never seen
|
||||
// (E10 review round 1). Passing the localized name makes the two impossible to drift.
|
||||
const lockedTitle = ctxBuiltin
|
||||
? t("playlists.builtinLocked", { name: t("playlists.watchLater") })
|
||||
: undefined;
|
||||
const railItems: ActionMenuItem[] = !ctxPl
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "rename",
|
||||
label: t("playlists.rename"),
|
||||
icon: <Pencil className="w-4 h-4" />,
|
||||
disabled: ctxBuiltin,
|
||||
title: lockedTitle,
|
||||
onSelect: () => {
|
||||
setRenameValue(ctxPl.name);
|
||||
setRenamingId(ctxPl.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
label: t("playlists.delete"),
|
||||
icon: <Trash2 className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
danger: true,
|
||||
disabled: ctxBuiltin,
|
||||
title: lockedTitle,
|
||||
// The same dialog (and the same "what gets selected next" rule) as the detail header's
|
||||
// trash button — one implementation, two entry points.
|
||||
onSelect: () => void plActions.remove(ctxPl),
|
||||
},
|
||||
];
|
||||
|
||||
const items: PanelItem[] = [
|
||||
{
|
||||
id: "manage",
|
||||
title: t("playlists.manageGroup"),
|
||||
body: (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={syncYoutube}
|
||||
disabled={syncing}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted hover:border-accent hover:text-accent disabled:opacity-40 transition"
|
||||
>
|
||||
<Button variant="outline" size="xs" fullWidth onClick={syncYoutube} disabled={syncing}>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? "animate-spin" : ""}`} />
|
||||
{t("playlists.syncYoutube")}
|
||||
</button>
|
||||
</Button>
|
||||
{playlists.length > 1 && (
|
||||
<SortControl
|
||||
label={t("playlists.sortLabel")}
|
||||
@@ -219,40 +314,70 @@ export default function PlaylistsRail({
|
||||
<div className="text-xs text-muted px-1 py-1">{t("playlists.noMatches")}</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{visiblePlaylists.map((pl) => (
|
||||
<button
|
||||
key={pl.id}
|
||||
ref={pl.id === selectedId ? selectedRef : null}
|
||||
onClick={() => setSelectedId(pl.id)}
|
||||
className={`w-full flex items-center gap-2.5 p-2 rounded-lg border transition text-left ${
|
||||
pl.id === selectedId
|
||||
? "bg-accent/15 border-accent"
|
||||
: "border-transparent hover:bg-card/60"
|
||||
}`}
|
||||
>
|
||||
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
|
||||
{pl.cover_thumbnail && (
|
||||
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1 text-[0.8125rem] text-fg">
|
||||
<span className="truncate">{plName(pl)}</span>
|
||||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
||||
<Youtube
|
||||
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
|
||||
/>
|
||||
{visiblePlaylists.map((pl) =>
|
||||
renamingId === pl.id ? (
|
||||
// The row becomes its own rename field. A <button> can't contain an <input>, so the
|
||||
// whole row swaps rather than the label alone — but it KEEPS the cover, because
|
||||
// that is what holds the row at its two-line height. Without it the rail jumps by
|
||||
// ~12px the moment you start renaming, and every row below it moves under the
|
||||
// pointer.
|
||||
<div key={pl.id} className={`${ROW_BASE} bg-accent/10 border-accent`}>
|
||||
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
|
||||
{pl.cover_thumbnail && (
|
||||
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-2xs text-muted">
|
||||
{t("playlists.itemCount", { count: pl.item_count })}
|
||||
{pl.total_duration_seconds > 0 &&
|
||||
` · ${formatRuntime(pl.total_duration_seconds)}`}
|
||||
</div>
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={() => void commitRename(pl)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void commitRename(pl);
|
||||
// Unmounting the input fires no blur in React, so Escape really does cancel.
|
||||
if (e.key === "Escape") setRenamingId(null);
|
||||
}}
|
||||
aria-label={t("playlists.rename")}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
) : (
|
||||
<button
|
||||
key={pl.id}
|
||||
ref={pl.id === selectedId ? selectedRef : null}
|
||||
onClick={() => setSelectedId(pl.id)}
|
||||
{...railMenu.triggerProps(pl)}
|
||||
className={`${ROW_BASE} text-left ${
|
||||
pl.id === selectedId
|
||||
? "bg-accent/15 border-accent"
|
||||
: "border-transparent hover:bg-card/60"
|
||||
}`}
|
||||
>
|
||||
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
|
||||
{pl.cover_thumbnail && (
|
||||
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1 text-[0.8125rem] text-fg">
|
||||
<span className="truncate">{plName(pl)}</span>
|
||||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
||||
<Youtube
|
||||
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-2xs text-muted">
|
||||
{t("playlists.itemCount", { count: pl.item_count })}
|
||||
{pl.total_duration_seconds > 0 &&
|
||||
` · ${formatRuntime(pl.total_duration_seconds)}`}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<ContextMenu menu={railMenu} ariaLabel={t("playlists.railMenu")} items={railItems} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -25,20 +25,21 @@ import { useDebounced } from "../lib/useDebounced";
|
||||
import { useHistorySubview } from "../lib/history";
|
||||
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import SortControl from "./SortControl";
|
||||
import FilterSelect from "./FilterSelect";
|
||||
import SortControl from "./ui/SortControl";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
import PlexDiscover from "./PlexDiscover";
|
||||
import { PlexCollectionsBrowse, PlexPlaylistsBrowse } from "./plexBrowseLists";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import ActiveFilterChips from "./ActiveFilterChips";
|
||||
import SelectedViewChip from "./SelectedViewChip";
|
||||
import ItemCount from "./ItemCount";
|
||||
import { StateMessage } from "./QueryState";
|
||||
import ItemCount from "./ui/ItemCount";
|
||||
import { StateMessage } from "./ui/QueryState";
|
||||
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
import { usePublishPlexView } from "./PlexViewBridge";
|
||||
import { EpisodeCard, PlexPosterCard } from "./plexCards";
|
||||
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
|
||||
import { dispatch, useKeyScope } from "../lib/keymap";
|
||||
|
||||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
||||
@@ -64,6 +65,20 @@ const LASTWATCHED_SORT: NonNullable<SortState> = Object.freeze({
|
||||
// I looking at" control now, like the feed's Watch-state pill, not a rail widget).
|
||||
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
||||
|
||||
// The drill-down views Backspace can step out of. "player" is deliberately absent: the player owns
|
||||
// its own Backspace (stop & back to the grid), and this handler must not fire underneath it.
|
||||
const BACKABLE_VIEWS: readonly string[] = [
|
||||
"grid",
|
||||
"playlists",
|
||||
"collections",
|
||||
"show",
|
||||
"season",
|
||||
"info",
|
||||
"person",
|
||||
"playlist",
|
||||
"collection",
|
||||
];
|
||||
|
||||
export default function PlexBrowse() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
@@ -163,35 +178,24 @@ export default function PlexBrowse() {
|
||||
}, [sub.view.kind]);
|
||||
|
||||
// Backspace steps back one drill-down level (grid ← show ← season, and out of info/playlist),
|
||||
// matching the browser/mouse Back. The player has its OWN Backspace handling, so we skip it here;
|
||||
// and never hijack Backspace while typing in a field.
|
||||
// matching the browser/mouse Back; the chord and the typing guard come from the shared keymap.
|
||||
// BACKABLE_VIEWS is the ONE list behind both the guard and the scope registration, so the cheat
|
||||
// sheet can never promise a "Back one level" the handler declines to perform.
|
||||
const canStepBack = BACKABLE_VIEWS.includes(sub.view.kind);
|
||||
useKeyScope("plexBrowse", canStepBack);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Backspace") return;
|
||||
const el = document.activeElement as HTMLElement | null;
|
||||
const tag = (el?.tagName || "").toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable)
|
||||
return;
|
||||
if (
|
||||
[
|
||||
"grid",
|
||||
"playlists",
|
||||
"collections",
|
||||
"show",
|
||||
"season",
|
||||
"info",
|
||||
"person",
|
||||
"playlist",
|
||||
"collection",
|
||||
].includes(sub.view.kind)
|
||||
) {
|
||||
e.preventDefault();
|
||||
sub.back();
|
||||
}
|
||||
dispatch(e, "plexBrowse", {
|
||||
back: (ev) => {
|
||||
if (!canStepBack) return;
|
||||
ev.preventDefault();
|
||||
sub.back();
|
||||
},
|
||||
});
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [sub]);
|
||||
}, [sub, canStepBack]);
|
||||
|
||||
// D3: entering the in-progress watch-state view auto-applies the "last watched" sort (most recently
|
||||
// watched first); leaving restores the sort that was active before entering. Only the ENTER edge
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Check, Plus } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { notifyCollectionEditError } from "../lib/actionErrors";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import Modal from "./Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
|
||||
// Admin-only "manage this movie's collections" dialog (Collections P2). Lists the library's EDITABLE
|
||||
@@ -138,14 +139,16 @@ export default function PlexCollectionEditor({
|
||||
placeholder={t("plex.collEditor.newPlaceholder")}
|
||||
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
variant="glass"
|
||||
size="md"
|
||||
className="shrink-0"
|
||||
onClick={createNew}
|
||||
disabled={!newName.trim() || !!busy}
|
||||
className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("plex.collEditor.create")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi";
|
||||
import { PlexPosterCard } from "./plexCards";
|
||||
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -8,9 +8,9 @@ import PosterTile from "./PosterTile";
|
||||
import { plexLang } from "../i18n";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { usePlex } from "./PlexProvider";
|
||||
import { Carousel } from "./Carousel";
|
||||
import { Carousel } from "./ui/Carousel";
|
||||
import { PlexPosterCard } from "./plexCards";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
|
||||
// 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
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "../lib/plexDetailUi";
|
||||
import { usePref } from "../lib/usePref";
|
||||
import i18n from "../i18n";
|
||||
import { Carousel } from "./Carousel";
|
||||
import { Carousel } from "./ui/Carousel";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||
import { PLAYABLE_TINT, playableClass } from "./plexCards";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { LoadingState } from "./QueryState";
|
||||
import { LoadingState } from "./ui/QueryState";
|
||||
|
||||
// A debounced library search-and-pick list, shared by the "Add title" panel (PlexCollectionView) and
|
||||
// the "New collection" first-title panel (plexBrowseLists) — both search the library the same way and
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
Square,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, HttpError } from "../lib/api";
|
||||
import {
|
||||
@@ -53,6 +52,16 @@ import {
|
||||
useWallClock,
|
||||
} from "../lib/plexPlayerHooks";
|
||||
import { fullscreenElement } from "../lib/fullscreen";
|
||||
import {
|
||||
dispatch,
|
||||
isActivationTarget,
|
||||
isTypingTarget,
|
||||
toggleKeyboardHelp,
|
||||
useHelpConditions,
|
||||
useKeyboardHelpOpen,
|
||||
useKeyScope,
|
||||
usePaletteOpen,
|
||||
} from "../lib/keymap";
|
||||
import { LS, useAccountPersistedObject } from "../lib/storage";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
@@ -176,10 +185,33 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
},
|
||||
[tracksFade.ref],
|
||||
);
|
||||
// Keyboard-shortcut cheat sheet (toggled with "h") + a live wall clock for the top bar.
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
// The keyboard cheat sheet is app-wide now (ui/KeyboardHelp, opened by the global "h"), not this
|
||||
// player's own overlay. Mirror its open state into a ref because usePlayerVolume still needs to
|
||||
// know an overlay is up before it steals a wheel scroll for volume.
|
||||
const helpOpen = useKeyboardHelpOpen();
|
||||
const helpOpenRef = useRef(false);
|
||||
helpOpenRef.current = helpOpen;
|
||||
// Read for the Backspace guard below: these two overlays open OVER the player and own the key.
|
||||
const paletteOpenRef = useRef(false);
|
||||
paletteOpenRef.current = usePaletteOpen();
|
||||
|
||||
// On-screen confirmation for a KEYBOARD track switch. A/S have cycled audio and subtitles since
|
||||
// E5, but silently — you changed the track and the only way to see what you landed on was to open
|
||||
// the tracks menu, which is exactly the modal round-trip the shortcut exists to avoid. The menu
|
||||
// itself needs no OSD: it shows the selection where you clicked.
|
||||
const [osd, setOsd] = useState<string | null>(null);
|
||||
const osdTimer = useRef<number | null>(null);
|
||||
const flashOsd = useCallback((text: string) => {
|
||||
setOsd(text);
|
||||
if (osdTimer.current) window.clearTimeout(osdTimer.current);
|
||||
osdTimer.current = window.setTimeout(() => setOsd(null), 2200);
|
||||
}, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (osdTimer.current) window.clearTimeout(osdTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
// "I" toggles a rich info overlay (poster/cast/ratings) over the running video.
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const infoOpenRef = useRef(false);
|
||||
@@ -577,15 +609,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
useEffect(() => setUpNextOpen(false), [id]);
|
||||
|
||||
// "Back" cascade shared by the on-screen Back arrow AND the keyboard/remote Backspace·Escape: peel
|
||||
// off whatever is layered on top (a tuning/tracks menu, then the shortcuts sheet, then the info
|
||||
// overlay) before leaving the player. The arrow used to call onClose() unconditionally, so Back
|
||||
// with the info/shortcuts panel open jumped straight to the feed instead of just closing the panel.
|
||||
// off whatever is layered on top (a tuning/tracks menu, then the info overlay) before leaving the
|
||||
// player. The arrow used to call onClose() unconditionally, so Back with the info panel open
|
||||
// jumped straight to the feed instead of just closing the panel. The shortcuts sheet is no longer
|
||||
// in this cascade: it is a Modal above us now, and owns its own Escape layer and Back-to-close.
|
||||
const handleBack = useCallback(() => {
|
||||
if (menuOpenRef.current) {
|
||||
setMenuOpen(false);
|
||||
setTracksOpen(false);
|
||||
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||
else if (infoOpenRef.current) setInfoOpen(false);
|
||||
} else if (infoOpenRef.current) setInfoOpen(false);
|
||||
else if (statsOpenRef.current) setStatsOpen(false);
|
||||
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||
else onClose();
|
||||
@@ -603,6 +635,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
handleBack();
|
||||
}, [handleBack, skipProgressRef, cancelAutoSkipRef]);
|
||||
|
||||
// Tell the shared cheat sheet which keys are live here, and whether ⇧← / ⇧→ apply (they step
|
||||
// episodes, so they are listed only inside a series).
|
||||
useKeyScope("plexPlayer");
|
||||
useHelpConditions({ episode: detail?.kind === "episode" });
|
||||
|
||||
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (its own
|
||||
// settings/tracks menus included) takes Escape first and this player defers (R8 S3).
|
||||
const layerIdRef = useRef<number | null>(null);
|
||||
@@ -617,92 +654,101 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
|
||||
// --- keyboard --------------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const seekBy = (e: KeyboardEvent, dir: -1 | 1, fine: boolean) => {
|
||||
e.preventDefault();
|
||||
seekTo(absRef.current + dir * (fine ? fineSeekStepRef.current : seekStepRef.current));
|
||||
};
|
||||
const step = (e: KeyboardEvent, id: string | null | undefined) => {
|
||||
e.preventDefault();
|
||||
go(id);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||
wake(); // keyboard use also reveals the controls
|
||||
switch (e.key) {
|
||||
case " ":
|
||||
// Keyboard use also reveals the controls — but typing in a player field is not "using the
|
||||
// remote", and `dispatch` still lets Escape through from inside a field (the only key that
|
||||
// opts in), which is why this is a guard around `wake` rather than an early return.
|
||||
if (!isTypingTarget(e)) wake();
|
||||
dispatch(e, "plexPlayer", {
|
||||
playPause: (ev) => {
|
||||
// Let Space activate a focused button/link natively (e.g. "Skip intro"); only toggle
|
||||
// playback when the focus isn't on a control — matching PlayerModal's guard.
|
||||
if (tag === "BUTTON" || tag === "A") return;
|
||||
e.preventDefault();
|
||||
// playback when the focus isn't on a control — the same shared rule PlayerModal uses.
|
||||
if (isActivationTarget(ev)) return;
|
||||
ev.preventDefault();
|
||||
togglePlay();
|
||||
break;
|
||||
case "k":
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
break;
|
||||
case "f":
|
||||
toggleFs();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) go(prevId);
|
||||
else seekTo(absRef.current - (e.ctrlKey ? fineSeekStepRef.current : seekStepRef.current));
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) go(nextId);
|
||||
else seekTo(absRef.current + (e.ctrlKey ? fineSeekStepRef.current : seekStepRef.current));
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
},
|
||||
fullscreen: () => toggleFs(),
|
||||
seekBack: (ev) => seekBy(ev, -1, false),
|
||||
seekFwd: (ev) => seekBy(ev, 1, false),
|
||||
fineBack: (ev) => seekBy(ev, -1, true),
|
||||
fineFwd: (ev) => seekBy(ev, 1, true),
|
||||
prevEpisode: (ev) => step(ev, prevId),
|
||||
nextEpisode: (ev) => step(ev, nextId),
|
||||
volumeUp: (ev) => {
|
||||
ev.preventDefault();
|
||||
nudgeVolume(0.05);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
},
|
||||
volumeDown: (ev) => {
|
||||
ev.preventDefault();
|
||||
nudgeVolume(-0.05);
|
||||
break;
|
||||
case "m":
|
||||
toggleMute();
|
||||
break;
|
||||
case "a":
|
||||
case "A":
|
||||
cycleAudio();
|
||||
break;
|
||||
case "s":
|
||||
case "S":
|
||||
cycleSubtitle();
|
||||
break;
|
||||
case "h":
|
||||
case "H":
|
||||
setHelpOpen((v) => !v);
|
||||
break;
|
||||
case "i":
|
||||
case "I":
|
||||
setInfoOpen((v) => !v);
|
||||
break;
|
||||
case "d":
|
||||
case "D":
|
||||
setStatsOpen((v) => !v);
|
||||
break;
|
||||
case "Backspace":
|
||||
},
|
||||
mute: () => toggleMute(),
|
||||
audio: () => {
|
||||
const ord = cycleAudio();
|
||||
if (ord === undefined) return; // one track (or none): nothing to say
|
||||
const label = detail?.audio_streams.find((a) => a.ord === (ord ?? 0))?.label;
|
||||
flashOsd(`${t("plex.player.audio")} · ${label ?? "—"}`);
|
||||
},
|
||||
subtitle: () => {
|
||||
const ord = cycleSubtitle();
|
||||
if (ord === undefined) return;
|
||||
const label =
|
||||
ord == null
|
||||
? t("plex.player.subOff")
|
||||
: (detail?.subtitle_streams.find((x) => x.ord === ord)?.label ?? "—");
|
||||
flashOsd(`${t("plex.player.subtitles")} · ${label}`);
|
||||
},
|
||||
info: () => setInfoOpen((v) => !v),
|
||||
stats: () => setStatsOpen((v) => !v),
|
||||
back: (ev) => {
|
||||
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
|
||||
e.preventDefault();
|
||||
// Who owns ⌫ when something is open over the player. Modal stopPropagation()s Escape ONLY
|
||||
// and its focused close button is not a typing target, so the key reaches us regardless.
|
||||
// Two wrong answers so far: deferring to nothing at all (round 1's bug — the cheat sheet
|
||||
// took ⌫ and the player stopped underneath it), then deferring to `anyLayerAbove` outright
|
||||
// (round 1's fix, round 2's bug — the player's OWN gear/tracks popovers push layers too, so
|
||||
// ⌫ stopped closing them), then naming just the sheet and palette (round 2's fix, round 3's
|
||||
// bug — the app-wide ErrorDialog is neither, so ⌫ tore the player down under it).
|
||||
// The rule that holds: the player's own panels answer first, because `handleBack`'s cascade
|
||||
// is what closes them; anything else layered above us takes the key.
|
||||
const ownPanelOpen =
|
||||
menuOpenRef.current ||
|
||||
infoOpenRef.current ||
|
||||
statsOpenRef.current ||
|
||||
upNextOpenRef.current;
|
||||
if (!ownPanelOpen && layerIdRef.current != null && anyLayerAbove(layerIdRef.current))
|
||||
return;
|
||||
ev.preventDefault();
|
||||
if (skipProgressRef.current != null)
|
||||
cancelAutoSkipRef.current(); // cancel auto-skip, don't go back
|
||||
else handleBack();
|
||||
break;
|
||||
case "Escape":
|
||||
},
|
||||
escape: (ev) => {
|
||||
// Defer to any layer opened above the player (Modal or anchored popover) — let it take the
|
||||
// Escape, not us. A player MENU registers as a layer too, so it closes first and the next
|
||||
// Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug). Check the
|
||||
// consumed-event flag first (UX-8): a layer's `document` listener runs before this `window`
|
||||
// one and its close can pop it off the stack synchronously, so `anyLayerAbove` alone misses it.
|
||||
if (escapeHandled(e)) break;
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) break;
|
||||
if (escapeHandled(ev)) return;
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
|
||||
if (skipProgressRef.current != null) cancelAutoSkipRef.current();
|
||||
else if (menuOpenRef.current) {
|
||||
setMenuOpen(false);
|
||||
setTracksOpen(false);
|
||||
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||
else if (infoOpenRef.current) setInfoOpen(false);
|
||||
} else if (infoOpenRef.current) setInfoOpen(false);
|
||||
else if (statsOpenRef.current) setStatsOpen(false);
|
||||
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||
else if (!fullscreenElement()) onClose();
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
@@ -715,6 +761,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
toggleMute,
|
||||
cycleAudio,
|
||||
cycleSubtitle,
|
||||
flashOsd,
|
||||
t,
|
||||
detail,
|
||||
prevId,
|
||||
nextId,
|
||||
@@ -846,15 +894,30 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard track-switch OSD. Sits ABOVE the control bar's own space so it reads even while the
|
||||
bar is showing, and inside the player element so it survives fullscreen. `aria-live` because
|
||||
the whole point is confirming something you cannot otherwise see.
|
||||
The REGION is always mounted and only the bubble is conditional: a polite region has to
|
||||
pre-exist in the accessibility tree for a later insertion to be announced, so one created
|
||||
together with its own first message is unreliably read — and since `osd` clears after 2.2s,
|
||||
conditioning the wrapper re-created it for every switch, not just the first (E10 review
|
||||
round 1). Toaster.tsx carries the same rule and the same shape. */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-24 z-menu flex justify-center px-4"
|
||||
aria-live="polite"
|
||||
>
|
||||
{osd && (
|
||||
<div className="max-w-full truncate rounded-full border border-white/15 bg-black/75 px-4 py-2 text-sm text-white shadow-2xl">
|
||||
{osd}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* While a panel/menu is open or an auto-skip countdown is running, take over the browser/mouse
|
||||
Back button (history popstate) so it closes/cancels that instead of dropping to the grid. */}
|
||||
{(menuOpen ||
|
||||
tracksOpen ||
|
||||
helpOpen ||
|
||||
infoOpen ||
|
||||
statsOpen ||
|
||||
upNextOpen ||
|
||||
skipProgress != null) && <BackClose onBack={backIntent} />}
|
||||
{(menuOpen || tracksOpen || infoOpen || statsOpen || upNextOpen || skipProgress != null) && (
|
||||
<BackClose onBack={backIntent} />
|
||||
)}
|
||||
|
||||
{/* Top bar */}
|
||||
<div
|
||||
@@ -1197,7 +1260,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
|
||||
<Info className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.help")} onClick={() => setHelpOpen((v) => !v)}>
|
||||
<Ctrl label={t("plex.player.help")} onClick={toggleKeyboardHelp}>
|
||||
<Keyboard className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.download")} onClick={download}>
|
||||
@@ -1236,70 +1299,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
|
||||
{helpOpen && (
|
||||
<div
|
||||
className="absolute inset-0 z-menu grid place-items-center bg-black/60 p-4"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setHelpOpen(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-[min(92vw,26rem)] rounded-2xl border border-white/15 bg-neutral-900/95 p-5 text-white shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<Keyboard className="w-5 h-5" /> {t("plex.player.help")}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setHelpOpen(false)}
|
||||
className="p-1 rounded-lg hover:bg-white/10"
|
||||
aria-label={t("plex.player.close")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2.5 text-sm">
|
||||
{(
|
||||
[
|
||||
[["Space", "K"], "plex.player.keys.playPause"],
|
||||
[["←", "→"], "plex.player.keys.seek"],
|
||||
[["Ctrl ←", "Ctrl →"], "plex.player.keys.fineSeek"],
|
||||
...(detail?.kind === "episode"
|
||||
? [[["⇧ ←", "⇧ →"], "plex.player.keys.episode"] as [string[], string]]
|
||||
: []),
|
||||
[["↑", "↓"], "plex.player.keys.volume"],
|
||||
[["M"], "plex.player.keys.mute"],
|
||||
[["A"], "plex.player.keys.audio"],
|
||||
[["S"], "plex.player.keys.subtitle"],
|
||||
[["F"], "plex.player.keys.fullscreen"],
|
||||
[["I"], "plex.player.keys.info"],
|
||||
[["D"], "plex.player.keys.stats"],
|
||||
[["⌫", "Esc"], "plex.player.keys.back"],
|
||||
[["H"], "plex.player.keys.help"],
|
||||
] as [string[], string][]
|
||||
).map(([keys, labelKey]) => (
|
||||
<Fragment key={labelKey}>
|
||||
<dt className="flex flex-wrap gap-1">
|
||||
{keys.map((k) => (
|
||||
<kbd
|
||||
key={k}
|
||||
className="rounded-sm bg-white/15 px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
|
||||
>
|
||||
{k}
|
||||
</kbd>
|
||||
))}
|
||||
</dt>
|
||||
<dd className="text-white/80">{t(labelKey)}</dd>
|
||||
</Fragment>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rich info overlay (poster/cast/ratings) — "i" or the info button; video keeps playing. */}
|
||||
{infoOpen && detail && (
|
||||
<div
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Minus, Plus } from "lucide-react";
|
||||
import { api, type PlexPlaylist } from "../lib/api";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import Modal from "./Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
|
||||
// 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.
|
||||
@@ -102,14 +103,16 @@ export default function PlexPlaylistAdd({
|
||||
placeholder={t("plex.playlistAdd.newPlaceholder")}
|
||||
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
variant="glass"
|
||||
size="md"
|
||||
className="shrink-0"
|
||||
onClick={createNew}
|
||||
disabled={!newName.trim() || busy}
|
||||
className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("plex.playlistAdd.create")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -35,7 +35,8 @@ import { api, type PlexCard } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
|
||||
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
|
||||
@@ -514,13 +515,10 @@ export default function PlexPlaylistView({
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-[90%] max-w-[1100px] p-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="glass-card glass-hover mb-4 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs"
|
||||
>
|
||||
<Button variant="glass" size="xs" className="mb-4" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("plex.backToLibrary")}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<div className="glass rounded-2xl p-4 sm:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
import { usePlexViewControls } from "./PlexViewBridge";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown, Film, Tv } from "lucide-react";
|
||||
import SearchBar from "./SearchBar";
|
||||
import ActionMenu from "./ActionMenu";
|
||||
import ActionMenu from "./ui/ActionMenu";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
@@ -9,10 +9,10 @@ import { type PanelLayout } from "../lib/panelLayout";
|
||||
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import FilterRail from "./FilterRail";
|
||||
import { type PanelItem } from "./PanelGroups";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import FilterChipGroup from "./FilterChipGroup";
|
||||
import Combobox, { type ComboboxOption } from "./Combobox";
|
||||
import { type PanelItem } from "./ui/PanelGroups";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
import Combobox, { type ComboboxOption } from "./ui/Combobox";
|
||||
import SavedViewsPopover from "./SavedViewsPopover";
|
||||
import PersonalViewsModal from "./PersonalViewsModal";
|
||||
import { useSavedViews } from "./SavedViewsProvider";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { usePlexViewControls } from "./PlexViewBridge";
|
||||
|
||||
// The Discover ⟷ Browse switch, hosted in the header next to the library-scope pill (All/Movies/Shows).
|
||||
|
||||
@@ -3,7 +3,7 @@ import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Lock, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
|
||||
import { inputCls } from "./ui/form";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bug, Sparkles } from "lucide-react";
|
||||
import { RELEASE_NOTES } from "../lib/releaseNotes";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
|
||||
function fmtDate(d: string): string {
|
||||
const t = new Date(d);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bookmark, Check, Settings2 } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
import { useSavedViews } from "./SavedViewsProvider";
|
||||
|
||||
// The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as
|
||||
|
||||
@@ -19,11 +19,12 @@ import {
|
||||
} from "lucide-react";
|
||||
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { relativeTime } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
|
||||
const POLL_MS = 4000;
|
||||
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
|
||||
@@ -283,13 +284,9 @@ function MaintenanceSettings({
|
||||
aria-label={t("scheduler.maintenance.batchLabel")}
|
||||
className="w-28 bg-surface border border-border rounded-sm px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={!dirty || saving}
|
||||
className="glass-card glass-hover px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
|
||||
>
|
||||
<Button variant="glass" size="sm" onClick={save} disabled={!dirty || saving}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-2xs text-muted mt-1.5">
|
||||
{t("scheduler.maintenance.batchNote", {
|
||||
|
||||
@@ -7,17 +7,17 @@ import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-r
|
||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||
import { api, HttpError, type Me } from "../lib/api";
|
||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||
import Avatar from "./Avatar";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import { notify, type NotifSettings } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import { inputCls, Section, SettingRow, Switch } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
import { type SaveState } from "./ui/DraftSaveBar";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { StateMessage } from "./QueryState";
|
||||
import { StateMessage } from "./ui/QueryState";
|
||||
import { useWizard } from "./WizardProvider";
|
||||
import { usePrefs } from "./PrefsProvider";
|
||||
import Tabs from "./Tabs";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import { useMe } from "../lib/useMe";
|
||||
|
||||
// The Settings page edits server-persisted preferences. Each change applies locally for instant
|
||||
@@ -324,12 +324,9 @@ function AccessRow({
|
||||
</span>
|
||||
) : (
|
||||
<Tooltip hint={t("settings.account.enableHint")}>
|
||||
<button
|
||||
onClick={onEnable}
|
||||
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
|
||||
>
|
||||
<Button variant="glass" size="sm" className="shrink-0" onClick={onEnable}>
|
||||
{t("settings.account.enable")}
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
@@ -498,13 +495,15 @@ function SignInMethods({ me }: { me: Me }) {
|
||||
{t("settings.account.sessions.hint")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="glass"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={logoutOthers}
|
||||
disabled={sessBusy}
|
||||
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition disabled:opacity-40"
|
||||
>
|
||||
{t("settings.account.sessions.action")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
@@ -676,7 +675,7 @@ export function DownloadAuth() {
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{h.configured && (
|
||||
<Button
|
||||
variant="danger-outline"
|
||||
variant="danger-soft"
|
||||
size="sm"
|
||||
onClick={() => onRemove(h.host_key)}
|
||||
disabled={remove.isPending}
|
||||
@@ -833,13 +832,10 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||
<p className="text-xs text-muted leading-relaxed mb-2">
|
||||
{t("settings.account.deleteHint")}
|
||||
</p>
|
||||
<button
|
||||
onClick={onDeleteAccount}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm border border-danger/40 text-danger hover:bg-danger/10 transition"
|
||||
>
|
||||
<Button variant="danger-soft" size="md" onClick={onDeleteAccount}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t("settings.account.deleteAccount")}
|
||||
</button>
|
||||
</Button>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Check, ChevronLeft, ChevronRight, Loader2, Send } from "lucide-react";
|
||||
import { api, HttpError } from "../lib/api";
|
||||
import { setLanguage, type LangCode } from "../i18n";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { Button } from "./ui/Button";
|
||||
import { inputCls } from "./ui/form";
|
||||
|
||||
// First-run install wizard. Shown by App.tsx while the instance is unconfigured. The one-time
|
||||
@@ -239,15 +240,17 @@ export default function SetupWizard() {
|
||||
placeholder={t("setup.smtp.testTo")}
|
||||
className={inputCls}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
variant="glass"
|
||||
size="md"
|
||||
className="shrink-0"
|
||||
type="button"
|
||||
onClick={sendTest}
|
||||
disabled={testState === "sending" || !testTo.trim()}
|
||||
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<Send className={`w-4 h-4 ${testState === "sending" ? "animate-pulse" : ""}`} />
|
||||
{t("setup.smtp.test")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{testState === "ok" && (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
@@ -138,31 +138,39 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
||||
value={fullUrl}
|
||||
className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-hidden"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={copy}
|
||||
aria-label={t("downloads.share.copy")}
|
||||
title={t("downloads.share.copy")}
|
||||
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg transition"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
{/* All three on the same rung. Only Copy was migrated in S1, which left one 2rem button
|
||||
between two 1.75rem ones with a different radius and hover fill — and, on a coarse
|
||||
pointer, `.touch-target` grew ONLY the migrated one to 2.75rem while the destructive
|
||||
Revoke stayed small (E10 review round 1). */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
setNewPass("");
|
||||
setEditPass((v) => !v);
|
||||
}}
|
||||
title={
|
||||
label={
|
||||
link.has_password
|
||||
? t("downloads.share.changePassword")
|
||||
: t("downloads.share.setPassword")
|
||||
}
|
||||
className={clsx(
|
||||
"p-1.5 rounded-md hover:bg-surface transition",
|
||||
link.has_password ? "text-accent" : "text-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
// On the ICON, not the button: `clsx` does not merge Tailwind, so a colour className loses
|
||||
// to the variant's own `text-muted`/`hover:text-fg` by stylesheet order and the state was
|
||||
// simply invisible (E10 review round 2). The Copy button above already does it this way.
|
||||
icon={<Lock className={`w-4 h-4 ${link.has_password ? "text-accent" : ""}`} />}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={async () => {
|
||||
if (
|
||||
await confirm({
|
||||
@@ -174,11 +182,9 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
||||
)
|
||||
revoke.mutate();
|
||||
}}
|
||||
title={t("downloads.share.revoke")}
|
||||
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-danger transition"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
label={t("downloads.share.revoke")}
|
||||
icon={<Trash2 className="w-4 h-4 text-danger" />}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted flex-wrap">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
|
||||
@@ -11,12 +11,12 @@ import TagManager from "./TagManager";
|
||||
import SavedViewsPopover from "./SavedViewsPopover";
|
||||
import { useSavedViews } from "./SavedViewsProvider";
|
||||
import FilterRail from "./FilterRail";
|
||||
import { type PanelItem } from "./PanelGroups";
|
||||
import { type PanelItem } from "./ui/PanelGroups";
|
||||
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||
import ChannelPicker from "./ChannelPicker";
|
||||
import DateFilterSelect from "./DateFilterSelect";
|
||||
import YearFilter from "./YearFilter";
|
||||
import Combobox, { type ComboboxOption } from "./Combobox";
|
||||
import Combobox, { type ComboboxOption } from "./ui/Combobox";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
import { useChannelsActions } from "./ChannelsProvider";
|
||||
import { useMe } from "../lib/useMe";
|
||||
|
||||
@@ -9,10 +9,10 @@ import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { useWizard } from "./WizardProvider";
|
||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import FilterChipGroup from "./FilterChipGroup";
|
||||
import Tabs from "./Tabs";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import { Section, SettingRow } from "./ui/form";
|
||||
import { useMe } from "../lib/useMe";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
|
||||
import { api, type MyStatus } from "../lib/api";
|
||||
import { formatViews } from "../lib/format";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
// 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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { filterChipClass } from "./FilterChipGroup";
|
||||
import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { filterChipClass } from "./ui/FilterChipGroup";
|
||||
import { useAnchoredPopover, PopoverSurface } from "./ui/AnchoredPopover";
|
||||
|
||||
// A single-line row of toggle filter chips that collapses its overflow into a trailing "+N" pill whose
|
||||
// popover holds the rest (still toggleable) — the same compression the feed's applied-filter row uses
|
||||
@@ -74,7 +74,7 @@ export default function TagFilterChips({
|
||||
const moreLabel = t("channels.tags.moreCount", { count: hidden });
|
||||
|
||||
return (
|
||||
<div className={`relative flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
|
||||
<div className={`${chipLaneClass} flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
|
||||
{/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can
|
||||
read each chip's right edge, the rem gap and the pill width off real boxes. */}
|
||||
<div
|
||||
|
||||
@@ -6,9 +6,10 @@ import { Plus, Trash2 } from "lucide-react";
|
||||
import { api, type Tag } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import Modal from "./Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
// 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
|
||||
@@ -203,13 +204,15 @@ export default function TagManager({
|
||||
placeholder={t("tagManager.newPlaceholder")}
|
||||
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={!newName.trim()}
|
||||
className="shrink-0 px-3 py-1.5 rounded-md border border-border text-sm text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-40 transition"
|
||||
>
|
||||
{t("tagManager.add")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { UploadCloud, File as FileIcon } from "lucide-react";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import { api } from "../lib/api";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, AlertTriangle } from "lucide-react";
|
||||
import Banner from "./Banner";
|
||||
import Banner from "./ui/Banner";
|
||||
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
|
||||
// The top-of-app outcome of an emailed verification link opened in a browser that ALREADY holds a
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { FRONTEND_VERSION, SEEN_VERSION_KEY } from "../lib/version";
|
||||
import Banner from "./Banner";
|
||||
import Banner from "./ui/Banner";
|
||||
|
||||
// Eye-catching, dismissible bar shown once after the running build's version changes
|
||||
// (compares the baked frontend version to the last one the user acknowledged).
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
// Aliased: `Video` is this app's own domain type (lib/api), and it wins the name.
|
||||
Video as VideoIcon,
|
||||
} from "lucide-react";
|
||||
import Avatar from "./Avatar";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
import VideoInfoCard from "./VideoInfoCard";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Crop, Eye, EyeOff, Pause, Play, Scissors, SplitSquareHorizontal, X } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { api, type DownloadJob, type EditSpec } from "../lib/api";
|
||||
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
|
||||
|
||||
@@ -6,8 +6,8 @@ import { qk } from "../lib/queryKeys";
|
||||
import { api, type Video } from "../lib/api";
|
||||
import { formatDate, formatDuration, formatViews } from "../lib/format";
|
||||
import { linkify } from "../lib/linkify";
|
||||
import Avatar from "./Avatar";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
// 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Check, ChevronDown, type LucideIcon } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
export interface ViewOption<T extends string> {
|
||||
id: T;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Video, VideoStatus } from "../lib/api";
|
||||
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||
import VideoCard from "./VideoCard";
|
||||
import VirtualGrid from "./VirtualGrid";
|
||||
import VirtualGrid from "./ui/VirtualGrid";
|
||||
|
||||
// Estimated row heights before measurement; real heights are measured per-row so these only affect
|
||||
// the very first paint and the scrollbar's initial guess. Taken from measuring each mode in the
|
||||
@@ -44,13 +44,14 @@ export default function VirtualFeed({
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
}: VirtualFeedProps) {
|
||||
const { minCol, maxCol, thumb } = FEED_VIEW_SPEC[view];
|
||||
const { minCol, colMaxWidth, maxCols, thumb } = FEED_VIEW_SPEC[view];
|
||||
return (
|
||||
<VirtualGrid
|
||||
items={items}
|
||||
getKey={(v) => v.id}
|
||||
minCol={minCol}
|
||||
maxCol={maxCol}
|
||||
colMaxWidth={colMaxWidth}
|
||||
maxCols={maxCols}
|
||||
rowEst={ROW_EST[view]}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { api, deadResetLink, loginBlocked, type LoginBlocked } from "../lib/api"
|
||||
import { HttpError } from "../lib/api";
|
||||
import { setLanguage, type LangCode } from "../i18n";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { Button } from "./ui/Button";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { entryHash, entrySearch } from "../lib/entryUrl";
|
||||
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
@@ -550,9 +551,9 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
{verifyOutcome === "ok_reset" && (
|
||||
<Banner tone="warn">
|
||||
{t("welcome.auth.verifyOkReset")}{" "}
|
||||
<button onClick={goForgot} className="font-semibold text-accent hover:underline">
|
||||
<Button variant="link" onClick={goForgot}>
|
||||
{t("welcome.auth.verifyOkResetCta")}
|
||||
</button>
|
||||
</Button>
|
||||
</Banner>
|
||||
)}
|
||||
{/* No resend CTA here, unlike the invalid banner below: the token was NOT spent — we simply
|
||||
@@ -564,9 +565,9 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
{verifyOutcome === "invalid" && (
|
||||
<Banner tone="warn">
|
||||
{t("welcome.auth.verifyInvalid")}{" "}
|
||||
<button onClick={goResend} className="font-semibold text-accent hover:underline">
|
||||
<Button variant="link" onClick={goResend}>
|
||||
{t("welcome.auth.resendCta")}
|
||||
</button>
|
||||
</Button>
|
||||
</Banner>
|
||||
)}
|
||||
{bounced === "requested" &&
|
||||
@@ -679,15 +680,15 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
<div className="flex items-center justify-between mt-3 text-xs">
|
||||
{mode === "signin" ? (
|
||||
<>
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
reset();
|
||||
setMode("register");
|
||||
}}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{t("welcome.auth.createLink")}
|
||||
</button>
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => {
|
||||
reset();
|
||||
@@ -699,15 +700,15 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
reset();
|
||||
setMode("signin");
|
||||
}}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{t("welcome.auth.backToSignin")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -820,9 +821,9 @@ function StatePanel({
|
||||
</p>
|
||||
{children}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<button onClick={onBack} className="text-sm text-accent hover:underline">
|
||||
<Button variant="link" className="text-sm" onClick={onBack}>
|
||||
{t("welcome.auth.backToSignin")}
|
||||
</button>
|
||||
</Button>
|
||||
{cta && (
|
||||
<button
|
||||
onClick={cta.onClick}
|
||||
|
||||
@@ -4,9 +4,9 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, type FeedFilters } from "../lib/api";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { filterChipClass } from "./FilterChipGroup";
|
||||
import CountBadge from "./CountBadge";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
import { filterChipClass } from "./ui/FilterChipGroup";
|
||||
import CountBadge from "./ui/CountBadge";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
|
||||
// The feed rail's "Year" control (item 8 / E8·S2): quick chips for the most recent years plus a
|
||||
|
||||
@@ -53,7 +53,7 @@ vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
|
||||
|
||||
// The tab strip is rendered into an app-level toolbar slot that does not exist here, so pin the
|
||||
// active tab directly — this file is about the Access-requests panel, not the tab chrome.
|
||||
vi.mock("./Tabs", () => ({
|
||||
vi.mock("./ui/Tabs", () => ({
|
||||
default: () => null,
|
||||
usePersistedTab: () => ["access", () => {}],
|
||||
}));
|
||||
|
||||
@@ -46,7 +46,7 @@ const meMock = {
|
||||
vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
|
||||
|
||||
// The tab strip renders into an app-level toolbar slot that does not exist here; pin the tab.
|
||||
vi.mock("./Tabs", () => ({
|
||||
vi.mock("./ui/Tabs", () => ({
|
||||
default: () => null,
|
||||
usePersistedTab: () => ["access", () => {}],
|
||||
}));
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { TFunction } from "i18next";
|
||||
import { relativeTime, formatDate } from "../lib/format";
|
||||
import i18n from "../i18n";
|
||||
import type { AuditRow } from "../lib/api";
|
||||
import type { Column } from "./DataTable";
|
||||
import type { Column } from "./ui/DataTable";
|
||||
|
||||
function actionLabel(t: TFunction, action: string): string {
|
||||
// Canonical key → localized label; falls back to the raw key for any unmapped action.
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useTranslation } from "react-i18next";
|
||||
import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react";
|
||||
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
||||
import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format";
|
||||
import type { Column } from "./DataTable";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { PriorityStepper } from "./PriorityStepper";
|
||||
import type { Column } from "./ui/DataTable";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import { PriorityStepper } from "./ui/PriorityStepper";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import AboutCell from "./AboutCell";
|
||||
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Settings2 } from "lucide-react";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { api } from "../lib/api";
|
||||
import { formatBytes } from "../lib/format";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Play, X } from "lucide-react";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { Video } from "../lib/api";
|
||||
import { formatDuration } from "../lib/format";
|
||||
import type { Column } from "./tableHeader";
|
||||
import type { Column } from "./ui/tableHeader";
|
||||
|
||||
// Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`.
|
||||
//
|
||||
|
||||
@@ -8,7 +8,7 @@ import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollection } from "..
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { notifyCollectionEditError } from "../lib/actionErrors";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import PosterTile from "./PosterTile";
|
||||
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, Circle, Film, Info, ListPlus, Play, Star, Tv2 } from "lucide-react";
|
||||
import { type CSSProperties } from "react";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { Carousel } from "./Carousel";
|
||||
import { Carousel } from "./ui/Carousel";
|
||||
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
|
||||
|
||||
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
|
||||
|
||||
@@ -22,10 +22,11 @@ import {
|
||||
WatchedToggle,
|
||||
} from "../lib/plexDetailUi";
|
||||
import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards";
|
||||
import { Carousel } from "./Carousel";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Carousel } from "./ui/Carousel";
|
||||
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
|
||||
// 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,
|
||||
@@ -56,13 +57,10 @@ export function PlexInfoView({
|
||||
return (
|
||||
<div className="w-[90%] max-w-[1600px] mx-auto">
|
||||
<div className="p-4 pb-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs"
|
||||
>
|
||||
<Button variant="glass" size="xs" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("plex.backToLibrary")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
@@ -166,13 +164,10 @@ export function PlexPersonView({
|
||||
return (
|
||||
<div className="w-[90%] max-w-[1600px] mx-auto">
|
||||
<div className="p-4 pb-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs"
|
||||
>
|
||||
<Button variant="glass" size="xs" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("plex.backToLibrary")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{q.isError && !d ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { Fragment, useState, type ReactNode } from "react";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// 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
|
||||
// and LanguageSwitcher. Use it to keep occasional actions out of a crowded toolbar. The items are
|
||||
// fire-and-close (`onSelect`); a `disabled` item (e.g. a mutation already in flight) is inert.
|
||||
//
|
||||
// E10 S2 split the ITEM LIST out of the trigger (`MenuItemList`) so the ⋯ menu and the right-click
|
||||
// context menu (`ContextMenu`) render the same rows from the same item type. That is the whole point
|
||||
// of the split: a context menu that drifts from the overflow menu beside it is two menus, and the
|
||||
// user has to learn both.
|
||||
|
||||
/** Shared surface paint for every menu built on this file — the ⋯ menu, its submenus, and
|
||||
* `ContextMenu`. One constant so a context menu can never look like a different component. */
|
||||
export const MENU_SURFACE = "glass-menu min-w-52 rounded-xl p-1.5 animate-[popIn_0.16s_ease]";
|
||||
const ROW =
|
||||
"flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm outline-hidden transition disabled:opacity-50";
|
||||
|
||||
interface MenuItemBase {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode; // an element (so a caller can swap in a spinning icon while pending)
|
||||
disabled?: boolean;
|
||||
/** Optional native tooltip (carries a longer hint the label alone can't).
|
||||
*
|
||||
* KNOWN GAP, deliberately still open: on a `disabled` row this reaches nobody — a disabled button
|
||||
* fires no pointer events, so no tooltip, and floating-ui's roving focus skips it. E10 review
|
||||
* round 1 tried an `aria-disabled` "inert" row to fix that; round 2 found `useListNavigation`
|
||||
* skips `aria-disabled` identically (`isListIndexDisabled`), which AnchoredPopover's own comment
|
||||
* already said — so the swap fixed nothing and made the mouse worse (the row took the active
|
||||
* highlight, then no-opped on click). Reverted. A real fix has to put the reason somewhere a
|
||||
* disabled row is not: a visible hint line in the menu, or an `aria-describedby` note. */
|
||||
title?: string;
|
||||
/** Draw a divider above this item. Ignored on the first item — a menu never opens with a rule. */
|
||||
separatorBefore?: boolean;
|
||||
}
|
||||
|
||||
interface ActionItem extends MenuItemBase {
|
||||
onSelect: () => void;
|
||||
/** Destructive action: painted in the danger colour, like the outline-danger buttons. */
|
||||
danger?: boolean;
|
||||
items?: never;
|
||||
}
|
||||
|
||||
interface SubmenuItem extends MenuItemBase {
|
||||
/** Non-empty → this row opens a submenu instead of firing. */
|
||||
items: readonly ActionMenuItem[];
|
||||
onSelect?: never;
|
||||
}
|
||||
|
||||
// A discriminated union rather than "onSelect and items are both optional": the invariant is
|
||||
// "a row either fires or it opens a submenu, never both and never neither", and this codebase's
|
||||
// house rule is that such a rule lives in the type, not in a comment above it.
|
||||
export type ActionMenuItem = ActionItem | SubmenuItem;
|
||||
|
||||
const isSubmenu = (it: ActionMenuItem): it is SubmenuItem => Array.isArray(it.items);
|
||||
|
||||
/** The rows of a menu: leaf actions, submenus and separators, from one item array. Rendered inside a
|
||||
* `PopoverSurface` (which supplies the list registry) — see `ActionMenu` and `ContextMenu`.
|
||||
* `onClose` closes the ROOT menu, so selecting deep inside a submenu dismisses the whole stack. */
|
||||
export function MenuItemList({
|
||||
items,
|
||||
onClose,
|
||||
}: {
|
||||
items: readonly ActionMenuItem[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{items.map((it, i) => (
|
||||
<Fragment key={it.id}>
|
||||
{it.separatorBefore && i > 0 && (
|
||||
<div role="separator" className="my-1 h-px bg-border" aria-hidden="true" />
|
||||
)}
|
||||
{isSubmenu(it) ? (
|
||||
<SubMenu item={it} onClose={onClose} />
|
||||
) : (
|
||||
<PopoverItem
|
||||
role="menuitem"
|
||||
disabled={it.disabled}
|
||||
label={it.label}
|
||||
title={it.title}
|
||||
onSelect={() => {
|
||||
it.onSelect();
|
||||
onClose();
|
||||
}}
|
||||
className={`${ROW}${it.danger ? " text-danger" : ""}`}
|
||||
>
|
||||
{it.icon && (
|
||||
<span className={`shrink-0 ${it.danger ? "text-danger" : "text-muted"}`}>
|
||||
{it.icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1 truncate">{it.label}</span>
|
||||
</PopoverItem>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One row that opens a nested menu. It is a real `PopoverItem`, so the parent's roving focus and
|
||||
* typeahead reach it; the nested popover anchors to that same button.
|
||||
*
|
||||
* Why it nests correctly with no extra wiring: this component renders INSIDE the parent's
|
||||
* `PopoverSurface`, i.e. inside its `<FloatingNode>`, so `useFloatingNodeId()` picks the parent up
|
||||
* as its tree parent. That is what makes Floating UI's outside-press tree-aware — a press inside
|
||||
* the submenu is not "outside" the parent, and Escape closes the topmost layer only. */
|
||||
function SubMenu({ item, onClose }: { item: SubmenuItem; onClose: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
placement: "right-start",
|
||||
offset: 2,
|
||||
// A mouse expects the submenu to fall open under the pointer; a finger has no hover and taps.
|
||||
hoverAlso: true,
|
||||
// Target lists can be long (every playlist you own), so shrink to the viewport instead of
|
||||
// running off the bottom at 768px.
|
||||
fitHeight: true,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<PopoverItem
|
||||
elementRef={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
label={item.label}
|
||||
title={item.title}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onKeyDown={(e) => {
|
||||
// The native-menu keys. Enter/Space already open it via useClick; ArrowRight is the one
|
||||
// the click handler doesn't cover.
|
||||
if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
className={ROW}
|
||||
>
|
||||
{item.icon && <span className="shrink-0 text-muted">{item.icon}</span>}
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted" aria-hidden="true" />
|
||||
</PopoverItem>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={item.label}
|
||||
className={`${MENU_SURFACE} overflow-y-auto`}
|
||||
style={{ maxHeight: "min(24rem, var(--fit-max-h))" }}
|
||||
>
|
||||
{/* ArrowLeft steps back out to the parent row — Escape would close the whole stack, and a
|
||||
submenu you can only leave by dismissing everything is not a submenu. Focus returns to
|
||||
the trigger via the surface's own returnFocus. */}
|
||||
<div
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItemList items={item.items} onClose={onClose} />
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ActionMenu({
|
||||
items,
|
||||
ariaLabel,
|
||||
className,
|
||||
trigger,
|
||||
}: {
|
||||
items: readonly ActionMenuItem[];
|
||||
/** Accessible name for the trigger (e.g. "Catalog actions"). */
|
||||
ariaLabel: string;
|
||||
/** Trigger className override; omit for the default glass ⋯ icon button. */
|
||||
className?: string;
|
||||
/** Custom trigger content; omit for the default ⋯ icon. */
|
||||
trigger?: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// A disabled item (a mutation already in flight) is skipped by roving keyboard nav automatically:
|
||||
// PopoverItem renders it as a native `disabled` button, which useListNavigation reads and never
|
||||
// focuses. No explicit disabledIndices needed. See ActionMenu.dom.test.tsx for the guard.
|
||||
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" });
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
className={
|
||||
className ??
|
||||
"glass-card glass-hover flex items-center justify-center rounded-xl p-2 text-sm transition"
|
||||
}
|
||||
>
|
||||
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
|
||||
</button>
|
||||
<PopoverSurface popover={pop} ariaLabel={ariaLabel} className={MENU_SURFACE}>
|
||||
<MenuItemList items={items} onClose={() => setOpen(false)} />
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+35
-6
@@ -36,8 +36,8 @@ import {
|
||||
type Placement,
|
||||
type UseRoleProps,
|
||||
} from "@floating-ui/react";
|
||||
import { useLayer } from "../lib/layerStack";
|
||||
import { useFullscreenElement } from "../lib/fullscreen";
|
||||
import { useLayer } from "../../lib/layerStack";
|
||||
import { useFullscreenElement } from "../../lib/fullscreen";
|
||||
|
||||
// R8 popover platform. ONE anchored-popover primitive over @floating-ui/react, replacing the ~9
|
||||
// hand-rolled positioning engines (measured flip / width clamp / rAF scroll-follow / scroller-relative
|
||||
@@ -103,10 +103,21 @@ export interface AnchoredPopoverOptions {
|
||||
* focus-item-on-hover, so neither typed characters nor a pointer passing over a list item steals
|
||||
* focus/keystrokes from the field. Set it instead of `typeahead:false` for such menus. */
|
||||
hostsInput?: boolean;
|
||||
/** Menu mode only: ALSO open on mouse hover, keeping the click/tap toggle. The submenu idiom —
|
||||
* a mouse expects a submenu to fall open under the pointer, a finger has no hover at all and
|
||||
* must tap. `mouseOnly` is forced on so a tap's synthetic mouseenter can't open the panel a
|
||||
* hair before the click handler toggles it shut again. */
|
||||
hoverAlso?: boolean;
|
||||
/** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then
|
||||
* lands the roving focus on it instead of the first item — the behaviour a "pick one of these
|
||||
* modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
|
||||
selectedIndex?: number | null;
|
||||
/** Close when a scroll container under the panel scrolls. Off by default, because a panel anchored
|
||||
* to a real trigger element simply MOVES with it. Turn it on when the anchor is a fixed point
|
||||
* rather than an element — a right-click menu, which otherwise stays parked over whatever row
|
||||
* scrolls under it while its actions still target the row you opened it on (E10 review round 1).
|
||||
* This is also what a native context menu does. */
|
||||
dismissOnScroll?: boolean;
|
||||
}
|
||||
|
||||
// Internal: call sites use the inferred hook return, they don't name the type.
|
||||
@@ -194,9 +205,14 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
// a click on the input must place the caret, not close the just-opened list.
|
||||
const click = useClick(context, { enabled: !isHover && !isCombobox });
|
||||
const hover = useHover(context, {
|
||||
enabled: isHover,
|
||||
enabled: isHover || !!opts.hoverAlso,
|
||||
move: false,
|
||||
delay: opts.hoverDelay ?? { open: 120, close: 140 },
|
||||
// A hover-card is a pointer affordance either way; a hover-ALSO menu must ignore touch, or the
|
||||
// synthetic mouseenter a tap emits would race the click toggle (open, then immediately shut).
|
||||
mouseOnly: !isHover,
|
||||
// A submenu wants a shorter intent delay than a hover card: the pointer is already inside a
|
||||
// menu, so the "did they mean it" question is mostly answered.
|
||||
delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }),
|
||||
handleClose: safePolygon(),
|
||||
});
|
||||
const focus = useFocus(context, { enabled: isHover });
|
||||
@@ -205,7 +221,10 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
// off entirely. (Leaving it on for hover made an open tooltip's document-level handler either
|
||||
// stopPropagation the Escape away from a Modal/player underneath, or double-close both.) Outside-
|
||||
// press stays with floating-ui (tree-aware).
|
||||
const dismiss = useDismiss(context, { escapeKey: false });
|
||||
const dismiss = useDismiss(context, {
|
||||
escapeKey: false,
|
||||
ancestorScroll: opts.dismissOnScroll ?? false,
|
||||
});
|
||||
// `role:"combobox"` makes Floating UI wire the whole typeable-combobox ARIA tree: the reference input
|
||||
// → role=combobox + aria-expanded/controls/autocomplete + aria-activedescendant (from the virtual nav),
|
||||
// the surface → role=listbox, the items → role=option + their option ids. (`combobox` maps to the
|
||||
@@ -339,6 +358,7 @@ export function PopoverItem({
|
||||
keepSelectedPaint,
|
||||
disabled,
|
||||
className,
|
||||
elementRef,
|
||||
/** Typeahead match string; defaults to the text child. Set explicitly when children aren't plain text. */
|
||||
label,
|
||||
...rest
|
||||
@@ -353,6 +373,10 @@ export function PopoverItem({
|
||||
keepSelectedPaint?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
/** Hand the item's DOM node to the caller, ON TOP of the list registration this component owns.
|
||||
* The submenu idiom needs it: the nested popover anchors to the very menuitem that opens it, and
|
||||
* aria-haspopup/aria-expanded belong on that button — not on a wrapper div around it. */
|
||||
elementRef?: (node: HTMLButtonElement | null) => void;
|
||||
label?: string;
|
||||
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "role" | "children" | "className">) {
|
||||
const ctx = useContext(PopoverListContext);
|
||||
@@ -364,6 +388,11 @@ export function PopoverItem({
|
||||
const generatedId = useId();
|
||||
const textLabel = label ?? (typeof children === "string" ? children : undefined);
|
||||
const { ref, index } = useListItem({ label: textLabel });
|
||||
// Both refs, in that order: the registry first (it owns roving focus), then the caller's.
|
||||
const setNode = (node: HTMLButtonElement | null) => {
|
||||
ref(node);
|
||||
elementRef?.(node);
|
||||
};
|
||||
// Roving tabindex / DOM focus follow the KEYBOARD position (activeIndex); the visible highlight
|
||||
// follows the pointer when hovering, else the keyboard (highlightIndex = hoverIndex ?? activeIndex).
|
||||
const focusActive = ctx?.activeIndex === index;
|
||||
@@ -409,7 +438,7 @@ export function PopoverItem({
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
ref={setNode}
|
||||
type="button"
|
||||
role={role}
|
||||
{...handlers}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user