diff --git a/CHANGELOG.md b/CHANGELOG.md index 3387919f..994a5912 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 8db5c549..caca1a13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. +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`. **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 `` | +| 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._ diff --git a/VERSION b/VERSION index c85a67dc..92fc430a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.81.0 \ No newline at end of file +0.82.0 diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index dfae5af9..3a1bcd12 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -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} diff --git a/backend/tests/test_playlist_move.py b/backend/tests/test_playlist_move.py new file mode 100644 index 00000000..8590c2a8 --- /dev/null +++ b/backend/tests/test_playlist_move.py @@ -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 diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index c8f32bc6..7a85a064 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -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, ); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4534ef55..7fdb2b6e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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. */} + {/* 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. */} + + {meQuery.data && !meQuery.data.is_demo && } {aboutOpen && ( diff --git a/frontend/src/components/About.tsx b/frontend/src/components/About.tsx index 46d2888c..9da74868 100644 --- a/frontend/src/components/About.tsx +++ b/frontend/src/components/About.tsx @@ -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({ ))} - + ); } diff --git a/frontend/src/components/AboutCell.tsx b/frontend/src/components/AboutCell.tsx index e26bc63d..86d9ddf4 100644 --- a/frontend/src/components/AboutCell.tsx +++ b/frontend/src/components/AboutCell.tsx @@ -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" diff --git a/frontend/src/components/AccountPanel.tsx b/frontend/src/components/AccountPanel.tsx index a7e25382..4b1377ca 100644 --- a/frontend/src/components/AccountPanel.tsx +++ b/frontend/src/components/AccountPanel.tsx @@ -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"; diff --git a/frontend/src/components/ActionMenu.tsx b/frontend/src/components/ActionMenu.tsx deleted file mode 100644 index b14bb497..00000000 --- a/frontend/src/components/ActionMenu.tsx +++ /dev/null @@ -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 ( - <> - - - {items.map((it) => ( - { - 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 && {it.icon}} - {it.label} - - ))} - - - ); -} diff --git a/frontend/src/components/ActiveFilterChips.tsx b/frontend/src/components/ActiveFilterChips.tsx index 28e42875..47253798 100644 --- a/frontend/src/components/ActiveFilterChips.tsx +++ b/frontend/src/components/ActiveFilterChips.tsx @@ -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({
{/* Hidden mirror of the full set, used only to measure natural widths. Kept out of the a11y tree diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index b7fb452d..716279ef 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -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 diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index d61b2737..55333a27 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -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") } > - + - + icon={ + u.is_suspended ? ( + + ) : ( + + ) + } + /> - + icon={} + />
); @@ -435,25 +438,28 @@ function PendingAccessRowView({ {!row.email_verified && ( - + icon={} + /> )} {row.can_activate && ( - + )} @@ -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" /> - + {t("settings.invites.add")} + {decided.length > 0 && ( @@ -820,14 +828,15 @@ function AdminDemo() { )} - + icon={} + /> ))} @@ -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" /> - +
@@ -909,24 +915,26 @@ function InviteRow({ )}
- + icon={} + /> - + icon={} + /> {/* 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({ ) : ( - + icon={} + /> )} diff --git a/frontend/src/components/AppCommandPalette.tsx b/frontend/src/components/AppCommandPalette.tsx new file mode 100644 index 00000000..500ef172 --- /dev/null +++ b/frontend/src/components/AppCommandPalette.tsx @@ -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(() => { + 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: , + // 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: , + 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: , + 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: , + run: toggleKeyboardHelp, + }, + ...SCHEMES.map((s) => ({ + id: `action:theme:${s.id}`, + label: t("palette.theme", { name: s.name }), + icon: , + 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 ; +} diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx index f19b0457..e382a24a 100644 --- a/frontend/src/components/AuditLog.tsx +++ b/frontend/src/components/AuditLog.tsx @@ -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={ - + } emptyText={t("audit.empty")} /> diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index caa64822..5798f0c1 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -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"; diff --git a/frontend/src/components/ChannelLayoutGrid.tsx b/frontend/src/components/ChannelLayoutGrid.tsx index 76c988de..58544afe 100644 --- a/frontend/src/components/ChannelLayoutGrid.tsx +++ b/frontend/src/components/ChannelLayoutGrid.tsx @@ -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` render closures @@ -84,7 +84,7 @@ export default function ChannelLayoutGrid({ items={rows} getKey={rowKey} minCol={spec.minCol} - maxCol={null} + colMaxWidth={null} rowEst={spec.rowEst} renderItem={renderCard} /> diff --git a/frontend/src/components/ChannelLink.tsx b/frontend/src/components/ChannelLink.tsx index 2fc67126..a422dbea 100644 --- a/frontend/src/components/ChannelLink.tsx +++ b/frontend/src/components/ChannelLink.tsx @@ -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 diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 682388d3..e5b91992 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -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({ {ch?.subscribed && ( - + icon={} + /> )} {ch?.subscribed && !me.is_demo && (
diff --git a/frontend/src/components/ChannelPicker.tsx b/frontend/src/components/ChannelPicker.tsx index 3c4386de..bebc16d2 100644 --- a/frontend/src/components/ChannelPicker.tsx +++ b/frontend/src/components/ChannelPicker.tsx @@ -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: diff --git a/frontend/src/components/ChannelStatsPopover.tsx b/frontend/src/components/ChannelStatsPopover.tsx index 5c8a3b13..3d20d933 100644 --- a/frontend/src/components/ChannelStatsPopover.tsx +++ b/frontend/src/components/ChannelStatsPopover.tsx @@ -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 diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx index f580d7a6..01050987 100644 --- a/frontend/src/components/ChannelTags.tsx +++ b/frontend/src/components/ChannelTags.tsx @@ -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 ( -
+
{/* Hidden measurer — real ghost chips at natural width so the fit hook reads their right edges. */}
{/* 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- diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index ca9c1426..1fff9b9c 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -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. */} - + {tagFilter.length > 0 && ( @@ -518,12 +521,9 @@ export default function Channels() { {highConfidence.length > 0 && ( - + )}
)} diff --git a/frontend/src/components/ChannelsProvider.tsx b/frontend/src/components/ChannelsProvider.tsx index 3504a49d..4111b803 100644 --- a/frontend/src/components/ChannelsProvider.tsx +++ b/frontend/src/components/ChannelsProvider.tsx @@ -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"; diff --git a/frontend/src/components/ChatDock.tsx b/frontend/src/components/ChatDock.tsx index e638a306..1d3c1669 100644 --- a/frontend/src/components/ChatDock.tsx +++ b/frontend/src/components/ChatDock.tsx @@ -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 diff --git a/frontend/src/components/ChatThread.tsx b/frontend/src/components/ChatThread.tsx index 76689e30..cfcd63b8 100644 --- a/frontend/src/components/ChatThread.tsx +++ b/frontend/src/components/ChatThread.tsx @@ -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" /> - + icon={} + />
)} diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index f16f1631..0b8b35ee 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -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"; diff --git a/frontend/src/components/ConfirmProvider.tsx b/frontend/src/components/ConfirmProvider.tsx index 47cc9dd3..191df296 100644 --- a/frontend/src/components/ConfirmProvider.tsx +++ b/frontend/src/components/ConfirmProvider.tsx @@ -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 diff --git a/frontend/src/components/DateFilterSelect.tsx b/frontend/src/components/DateFilterSelect.tsx index f4fe6587..ff4a5d43 100644 --- a/frontend/src/components/DateFilterSelect.tsx +++ b/frontend/src/components/DateFilterSelect.tsx @@ -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"; diff --git a/frontend/src/components/DemoBanner.tsx b/frontend/src/components/DemoBanner.tsx index d9b6cb94..e7837a92 100644 --- a/frontend/src/components/DemoBanner.tsx +++ b/frontend/src/components/DemoBanner.tsx @@ -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.) diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 819890dd..a4fa1787 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -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")); diff --git a/frontend/src/components/DownloadDialog.tsx b/frontend/src/components/DownloadDialog.tsx index 1c6d3eb4..0ad44a56 100644 --- a/frontend/src/components/DownloadDialog.tsx +++ b/frontend/src/components/DownloadDialog.tsx @@ -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"; diff --git a/frontend/src/components/ErrorDialog.tsx b/frontend/src/components/ErrorDialog.tsx index 5b8968bb..34ba1805 100644 --- a/frontend/src/components/ErrorDialog.tsx +++ b/frontend/src/components/ErrorDialog.tsx @@ -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. diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index a4253c7d..9d55c5bf 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -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")}

{isDemo ? ( - + ) : ( <> - + - +
); @@ -157,14 +154,10 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa /> )} {err &&
{err}
} - + {configured && ( + )} {(keyState === "setup" || keyState === "unlock") && } @@ -213,12 +211,15 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac return (
- + icon={} + />
{t("messages.newTitle")}
- + icon={} + />
- - + icon={} + />
- +
)}
@@ -305,43 +308,45 @@ function NotificationRow({ )} {isScheduler && ( - + )} {isDownloadDone && ( - + )}
{!n.read && ( - + icon={} + /> )} - + icon={} + />
); @@ -396,47 +401,52 @@ function ClientActivityRow({ {hidden ? (
- - +
) : watched ? (
- - +
) : subscribed ? (
- +
) : access ? ( - + ) : ( n.action && !n.dismissed && ( - + ) )} - + icon={} + /> ); } diff --git a/frontend/src/components/OnboardingWizard.tsx b/frontend/src/components/OnboardingWizard.tsx index c963bf0b..abbf3f6d 100644 --- a/frontend/src/components/OnboardingWizard.tsx +++ b/frontend/src/components/OnboardingWizard.tsx @@ -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: () > {t("onboarding.read.connect")} - + )} @@ -225,15 +223,16 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () > {t("onboarding.write.enableUnsubscribe")} - + )} diff --git a/frontend/src/components/PersonalViewsModal.tsx b/frontend/src/components/PersonalViewsModal.tsx index 2a66c37e..ad5ea85e 100644 --- a/frontend/src/components/PersonalViewsModal.tsx +++ b/frontend/src/components/PersonalViewsModal.tsx @@ -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 diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 67ca30ad..c9666d11 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -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" > diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index da772928..fa03016d 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -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