Merge: promote dev to prod

This commit is contained in:
2026-07-21 04:32:10 +02:00
123 changed files with 7482 additions and 2693 deletions
+10 -1
View File
@@ -6,8 +6,17 @@
**/*.pyc
.venv/
venv/
node_modules/
# Docker patterns are anchored at the context root with NO implicit `**/` (unlike .gitignore, where
# this same line matches at any depth) — a bare `node_modules/` missed frontend/node_modules, so
# `COPY frontend/ ./` overlaid the host's Windows tree on top of what `npm ci` had just installed.
**/node_modules/
frontend/dist/
# Host-only test artifacts. e2e/.auth/state.json is a live Playwright session: it never reached the
# runtime image (stage 2 copies only /fe/dist), but it has no business in the build context either.
frontend/e2e/.auth/
frontend/playwright-report/
frontend/test-results/
**/*.log
backups/
*.dump
README.md
+6
View File
@@ -0,0 +1,6 @@
# Revisions listed here are skipped by `git blame` (git >= 2.23) so mechanical,
# whole-repo reformatting does not bury the real authorship of each line.
# Configure once per clone: git config blame.ignoreRevsFile .git-blame-ignore-revs
#
# Prettier one-time repo-wide format pass (R2 S1b, 2026-07-21)
51c36c2071728d2cd7ae580b7bfdf21f6867a347
+1 -1
View File
@@ -1 +1 @@
0.48.0
0.49.0
+12
View File
@@ -0,0 +1,12 @@
# Test-only image — the app's Python deps + pytest/ruff, and NOTHING else. Code is bind-mounted at
# run time (not baked), so pytest always sees the working tree, and this file never touches the prod
# Dockerfile, so the published image can never carry test tooling. Same python + same pinned
# requirements.txt as the runtime stage, so tests run against the prod dependency set. Rebuilt only
# when a requirements file changes (the pip layer is cache-keyed on them); otherwise a no-op.
#
# docker build -f backend/Dockerfile.test -t siftlode-test:local backend
# docker run --rm -v "<repo>/backend:/app" -w /app siftlode-test:local python -m pytest
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt requirements-dev.txt ./
RUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt
+6
View File
@@ -0,0 +1,6 @@
[pytest]
# Run from backend/ (the image's /app). `pythonpath = .` puts the app package on sys.path so tests
# import it as `app.*`, matching runtime. The suite is pure-logic only (no DB/network) — see tests/.
testpaths = tests
pythonpath = .
addopts = -q
+7
View File
@@ -0,0 +1,7 @@
# Test + lint tooling — NEVER in the prod image. Installed only by backend/Dockerfile.test (the
# throwaway test image), which the prod build (root Dockerfile) never references. Pinned so the
# suite is reproducible: pytest for the pure-logic tests, and ruff at the SAME version the gate's
# backend lint lane currently runs on the host (see the run_lint note in siftlode.sh). httpx is
# already an app dependency (requirements.txt), so its TestClient needs nothing extra here.
pytest==8.4.2
ruff==0.15.21
+53
View File
@@ -0,0 +1,53 @@
"""Signed-grant HMAC for password-protected share links: a grant proves the viewer unlocked a
token, rides in the file URL (a <video src> can't send a header), and expires."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from app.downloads.links import _GRANT_TTL, check_grant, is_expired, make_grant, new_token
def test_new_token_is_unguessable_and_unique():
a, b = new_token(), new_token()
assert a != b
assert len(a) >= 30 # token_urlsafe(24) → ~32 chars
class TestGrant:
def test_a_fresh_grant_verifies_for_its_own_token(self):
grant = make_grant("tok")
assert check_grant("tok", grant) is True
def test_a_grant_does_not_verify_for_a_different_token(self):
# The signature binds the token, so a grant minted for one link can't unlock another.
assert check_grant("other", make_grant("tok")) is False
def test_a_tampered_signature_is_rejected(self):
exp, _sig = make_grant("tok").split(".", 1)
assert check_grant("tok", f"{exp}.deadbeef") is False
def test_a_tampered_expiry_is_rejected(self):
# Extending the exp field invalidates the signature (which covers token.exp).
_exp, sig = make_grant("tok").split(".", 1)
future = int(datetime.now(timezone.utc).timestamp()) + 10 * _GRANT_TTL
assert check_grant("tok", f"{future}.{sig}") is False
def test_an_expired_grant_is_rejected(self):
past = datetime.now(timezone.utc).timestamp() - 2 * _GRANT_TTL
assert check_grant("tok", make_grant("tok", now=past)) is False
def test_malformed_grants_are_rejected_not_crashed(self):
for bad in [None, "", "no-dot", "notanumber.sig"]:
assert check_grant("tok", bad) is False
class TestIsExpired:
def test_none_expiry_never_expires(self):
assert is_expired(SimpleNamespace(expires_at=None)) is False
def test_a_future_expiry_is_live(self):
future = datetime.now(timezone.utc) + timedelta(hours=1)
assert is_expired(SimpleNamespace(expires_at=future)) is False
def test_a_past_expiry_is_expired(self):
past = datetime.now(timezone.utc) - timedelta(hours=1)
assert is_expired(SimpleNamespace(expires_at=past)) is True
+26
View File
@@ -0,0 +1,26 @@
"""App-assembly smoke test — DB-free.
Uses TestClient WITHOUT its context-manager form on purpose: that skips the lifespan (which opens a
DB session + starts the scheduler), so the app object can be built and its OpenAPI schema generated
with no database. That alone proves every included router imported and wired cleanly — a broken
route registration or import fails right here.
Endpoints that actually answer (/healthz, /api/version, the auth/feed/downloads routers) all touch
the database, so exercising THEM belongs in a DB-backed integration lane (needs a test Postgres +
migrations) — deferred; see siftlode-ops/ROADMAP.md R2 S2b.
"""
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app) # no `with` → lifespan/startup does not run → no DB required
def test_app_assembles_and_serves_openapi():
schema = client.get("/openapi.json")
assert schema.status_code == 200
paths = schema.json()["paths"]
# A handful of endpoints that must always exist — a rename/removal is a real contract change,
# and their absence here would mean a router failed to register.
for path in ["/healthz", "/api/version", "/api/feed", "/api/me"]:
assert path in paths, f"missing route {path} — a router failed to wire up"
+107
View File
@@ -0,0 +1,107 @@
"""On-disk layout helpers: name sanitation, the Plex/flat path shapes, and — most importantly —
the safe_abs_path traversal guard that every file-serving endpoint depends on."""
from datetime import date
from app.downloads.storage import (
MediaMeta,
download_filename,
rel_path,
safe_abs_path,
sanitize,
)
from pathlib import Path
class TestSanitize:
def test_spaces_become_underscores_and_result_has_no_separators(self):
out = sanitize("My Great Video")
assert out == "My_Great_Video"
assert "/" not in out and "\\" not in out and " " not in out
def test_illegal_and_emoji_chars_are_removed(self):
out = sanitize('a/b:c*?"<>| 🔥')
for bad in '/\\:*?"<>|🔥':
assert bad not in out
def test_repeated_punctuation_collapses(self):
assert sanitize("wow!!!___---end") == "wow!_-end"
def test_length_is_capped(self):
assert len(sanitize("x" * 500, limit=10)) <= 10
def test_empty_or_all_junk_falls_back_to_untitled(self):
assert sanitize("") == "untitled"
assert sanitize("🔥🔥") == "untitled"
class TestRelPath:
meta = MediaMeta(
video_id="abc123",
title="Cool Clip",
uploader="Some Channel",
upload_date=date(2024, 5, 2),
)
def test_flat_layout_is_a_single_file(self):
assert rel_path(self.meta, "mp4", layout="flat") == "Cool_Clip_[abc123].mp4"
def test_plex_layout_nests_channel_and_season(self):
out = rel_path(self.meta, "mp4", layout="plex")
assert out == "Some_Channel/Season_2024/Some_Channel_-_2024-05-02_-_Cool_Clip_[abc123].mp4"
def test_missing_date_uses_year_zero_and_placeholder_date(self):
meta = MediaMeta(video_id="x", title="t", uploader="c", upload_date=None)
out = rel_path(meta, "mp4")
assert "Season_0/" in out and "0000-00-00" in out
class TestDownloadFilename:
def test_appends_the_container_extension(self):
assert download_filename("My Video", "mp4", Path("/x/f.webm")) == "My Video.mp4"
def test_does_not_double_an_extension_already_present(self):
assert download_filename("clip.mp4", "mp4", Path("/x/f.mp4")) == "clip.mp4"
def test_falls_back_to_the_file_suffix_when_no_container(self):
assert download_filename("song", None, Path("/x/f.m4a")) == "song.m4a"
class TestSafeAbsPath:
"""The path-traversal guard: only paths that resolve INSIDE download_root AND exist are returned."""
def test_returns_the_path_for_a_file_inside_the_root(self, tmp_path: Path):
(tmp_path / "sub").mkdir()
f = tmp_path / "sub" / "video.mp4"
f.write_text("data")
got = safe_abs_path(str(tmp_path), "sub/video.mp4")
assert got is not None and got == f.resolve()
def test_rejects_dot_dot_traversal_out_of_the_root(self, tmp_path: Path):
root = tmp_path / "root"
root.mkdir()
secret = tmp_path / "secret.txt"
secret.write_text("nope")
# ../secret.txt escapes root even though the file exists — must be refused.
assert safe_abs_path(str(root), "../secret.txt") is None
def test_rejects_an_absolute_path_escape(self, tmp_path: Path):
root = tmp_path / "root"
root.mkdir()
outside = tmp_path / "outside.txt"
outside.write_text("nope")
assert safe_abs_path(str(root), str(outside)) is None
def test_rejects_a_sibling_dir_whose_name_prefixes_the_root(self, tmp_path: Path):
# The one input that distinguishes the correct `root not in path.parents` guard from the
# classic buggy `str(path).startswith(str(root))`: /root vs a real sibling /rootx. A file in
# rootx is OUTSIDE root, but its path DOES start with the root string — a prefix check would
# wrongly serve it. This pins the guard against that regression.
root = tmp_path / "root"
root.mkdir()
sibling = tmp_path / "rootx"
sibling.mkdir()
(sibling / "steal.mp4").write_text("nope")
assert safe_abs_path(str(root), "../rootx/steal.mp4") is None
def test_returns_none_for_a_nonexistent_file_inside_the_root(self, tmp_path: Path):
assert safe_abs_path(str(tmp_path), "does/not/exist.mp4") is None
+41
View File
@@ -0,0 +1,41 @@
"""normalize_title: de-shout / de-clutter video titles without mangling non-English text."""
from app.titles import normalize_title
def test_empty_and_whitespace_pass_through_unchanged():
assert normalize_title(None) is None
assert normalize_title("") == ""
assert normalize_title(" ") == " "
def test_plain_title_is_left_alone():
assert normalize_title("How I built a boat") == "How I built a boat"
def test_shouting_title_is_de_capsed_and_first_letter_kept_upper():
# >=2 all-caps runs and >=50% caps → treated as shouting: title-case the long words.
out = normalize_title("THIS IS A HUGE ANNOUNCEMENT")
assert out is not None
assert out == out[0].upper() + out[1:] # first alpha char stays capitalized
assert "HUGE" not in out and "ANNOUNCEMENT" not in out
def test_trailing_hashtags_are_stripped():
assert normalize_title("Great cooking video #food #yummy") == "Great cooking video"
def test_emoji_and_symbols_are_dropped():
assert "🔥" not in (normalize_title("Fire recipe 🔥🔥") or "")
def test_repeated_punctuation_is_collapsed():
assert normalize_title("Wait what!!!!") == "Wait what!"
def test_accented_non_english_letters_survive():
# Siftlode is trilingual — Hungarian/German letters must not be ASCII-folded away.
for title in ["Gulyásleves főzés", "Über die Straße"]:
out = normalize_title(title) or ""
for ch in "áöüőűé":
if ch in title:
assert ch in out
+6
View File
@@ -0,0 +1,6 @@
dist
node_modules
playwright-report
test-results
e2e/.auth
package-lock.json
+6
View File
@@ -0,0 +1,6 @@
{
"printWidth": 100,
"singleQuote": false,
"semi": true,
"trailingComma": "all"
}
+1 -1
View File
@@ -2,7 +2,7 @@
Playwright tests that lock the app's five core flows — **feed** (scroll + filter), **channel**
(open → back), **player** (open/close), **settings** (dirty-save + guard), and **page switching**
so the Platform refactor can *prove* it changed nothing. They assert current, correct behavior;
so the Platform refactor can _prove_ it changed nothing. They assert current, correct behavior;
they deliberately do **not** encode the known Platform bugs (those get their own specs when the
sprints that fix them land).
+29 -8
View File
@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { openApp } from "./helpers";
import { clickVisibleChannelLink, openApp } from "./helpers";
// Locks channel-view entry + exit: opening a channel from a feed card replaces the view, and
// both the in-page Back and the browser Back return to the feed. (This orthogonal channel branch
@@ -44,7 +44,11 @@ test.describe("channel view", () => {
const saved = await main.evaluate((el) => el.scrollTop);
expect(saved).toBeGreaterThan(1500);
await page.getByTestId("video-card-channel").first().click();
// Click a channel card that is actually ON SCREEN — a real user clicks what they see. Clicking
// `.first()` here would grab an overscan card ABOVE the viewport, and Playwright's scroll-into-
// view would move the feed before navigating, so Back would restore that moved position, not
// the one under test. (This is what made the test fail while the app was in fact correct.)
expect(await clickVisibleChannelLink(page)).toBe(true);
await expect(page.getByTestId("channel-title")).toBeVisible();
await page.getByTestId("channel-back").click();
await expect(page.getByTestId("video-card").first()).toBeVisible();
@@ -59,7 +63,9 @@ test.describe("channel view", () => {
// Bug 2 (Platform S5): opening a channel no longer FORCES Live/Upcoming on — the channel view
// seeds its content-type prefs (normal / shorts / live) from the global feed. Locked both ways so
// a future refactor can't silently re-hardcode it.
test("the channel view inherits the feed's content filters (no forced Live/Upcoming)", async ({ page }) => {
test("the channel view inherits the feed's content filters (no forced Live/Upcoming)", async ({
page,
}) => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
const feedLive = page.getByTestId("feed-content-includeLive");
@@ -68,7 +74,10 @@ test.describe("channel view", () => {
// Inherit OFF: the channel toolbar's Live/Upcoming stays off, not forced on.
await page.getByTestId("video-card-channel").first().click();
await expect(page.getByTestId("channel-title")).toBeVisible();
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute("aria-pressed", "false");
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
"aria-pressed",
"false",
);
await page.getByTestId("channel-back").click();
await expect(page.getByTestId("channel-title")).toHaveCount(0);
@@ -78,7 +87,10 @@ test.describe("channel view", () => {
await expect(page.getByTestId("video-card").first()).toBeVisible();
await page.getByTestId("video-card-channel").first().click();
await expect(page.getByTestId("channel-title")).toBeVisible();
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
"aria-pressed",
"true",
);
});
// Bug 2 (Platform S5): the channel view is its OWN filter scope — a content toggle made there
@@ -87,18 +99,27 @@ test.describe("channel view", () => {
test("a channel's content toggle does not leak into the feed", async ({ page }) => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute("aria-pressed", "false");
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
"aria-pressed",
"false",
);
// Turn Live/Upcoming ON inside the channel view.
await page.getByTestId("video-card-channel").first().click();
await expect(page.getByTestId("channel-title")).toBeVisible();
await page.getByTestId("feed-content-includeLive").click();
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
"aria-pressed",
"true",
);
// Back on the feed, its Live/Upcoming is still off — the channel's change stayed local.
await page.getByTestId("channel-back").click();
await expect(page.getByTestId("channel-title")).toHaveCount(0);
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute("aria-pressed", "false");
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
"aria-pressed",
"false",
);
});
// Platform S4b: the whole channel chrome (banner, identity, tabs, toolbar) is fixed — it stays
+1 -3
View File
@@ -74,8 +74,6 @@ test.describe("feed", () => {
// Switch the show filter → a different result set → back to the top.
await page.getByTestId("feed-show-all").click();
await expect
.poll(() => page.getByRole("main").evaluate((el) => el.scrollTop))
.toBeLessThan(50);
await expect.poll(() => page.getByRole("main").evaluate((el) => el.scrollTop)).toBeLessThan(50);
});
});
+11 -1
View File
@@ -37,7 +37,17 @@ setup("seed + authenticate", async ({ page }) => {
try {
const out = execFileSync(
"docker",
["exec", "-i", "-e", `E2E_EMAIL=${EMAIL}`, "-e", `E2E_PASSWORD=${PASSWORD}`, CONTAINER, "python", "-"],
[
"exec",
"-i",
"-e",
`E2E_EMAIL=${EMAIL}`,
"-e",
`E2E_PASSWORD=${PASSWORD}`,
CONTAINER,
"python",
"-",
],
{ input: readFileSync(SEED_SCRIPT) },
);
console.log(`[e2e seed] ${out.toString().trim()}`);
+21
View File
@@ -12,6 +12,27 @@ export async function gotoPage(page: Page, id: string): Promise<void> {
await expect(page.getByTestId(`nav-${id}`)).toHaveAttribute("aria-current", "page");
}
/**
* Click a channel link that is currently WITHIN the viewport — what a real user does. Never use
* `.first()` for this after scrolling: the first rendered card lives in the virtualizer's overscan
* ABOVE the viewport, so Playwright would scroll it into view to click it, moving the scroll
* position — fatal to any test that then asserts on where the feed was. Returns false if none found.
*/
export async function clickVisibleChannelLink(page: Page): Promise<boolean> {
const links = page.getByTestId("video-card-channel").filter({ visible: true });
const viewport = page.viewportSize();
const height = viewport?.height ?? 800;
const count = await links.count();
for (let i = 0; i < count; i++) {
const box = await links.nth(i).boundingBox();
if (box && box.y >= 0 && box.y + box.height <= height) {
await links.nth(i).click();
return true;
}
}
return false;
}
/** Open the in-app player from the first feed card (clicks its thumbnail link). */
export async function openFirstVideo(page: Page): Promise<void> {
await expect(page.getByTestId("video-card").first()).toBeVisible();
+11 -1
View File
@@ -7,7 +7,17 @@ import { openApp } from "./helpers";
test.describe("page switching", () => {
// Core + admin modules (the E2E account is an admin, matching the real primary user), so this
// also exercises the render ternary's admin branches (scheduler / config / users / audit).
const RAIL = ["channels", "playlists", "stats", "scheduler", "config", "users", "audit", "settings", "feed"];
const RAIL = [
"channels",
"playlists",
"stats",
"scheduler",
"config",
"users",
"audit",
"settings",
"feed",
];
test("each rail item activates its page", async ({ page }) => {
await openApp(page);
+3
View File
@@ -8,6 +8,9 @@
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
+53
View File
@@ -0,0 +1,53 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import prettier from "eslint-config-prettier";
import globals from "globals";
// Flat config (ESLint 9). Deliberately NARROW: the high-value rule here is
// react-hooks/exhaustive-deps — 33 suppressions across the app exist for it, and until this
// sprint nothing ran to make them mean anything. tsc already owns unused vars, undefined names
// and types (noUnusedLocals/Parameters + strict), so typescript-eslint's non-type-checked
// `recommended` is layered on but its overlap with tsc is turned off below to avoid double-reporting
// and a churny diff. `eslint-config-prettier` is LAST so formatting rules never fight Prettier.
//
// reportUnusedDisableDirectives: a `// eslint-disable` that no longer suppresses anything is itself
// an error — that is what keeps inert disable comments from re-accumulating (the S1b mandate).
export default tseslint.config(
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
{
files: ["src/**/*.{ts,tsx}"],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2022,
globals: { ...globals.browser },
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
linterOptions: {
reportUnusedDisableDirectives: "error",
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
// finding is reported once, by one tool, and this config stays about hooks + refresh safety.
"@typescript-eslint/no-unused-vars": "off",
"no-undef": "off",
// The 19 `any`s all live in the api.ts god-module, which R7 (Frontend API layer) rewrites.
// Turning the rule on now would either block the gate on pre-existing debt or scatter 19
// suppressions — R7 flips this back to "error" when it types that layer. Tracked, not ignored.
"@typescript-eslint/no-explicit-any": "off",
// Side-effect ternaries/short-circuits are an established idiom here (`cond ? a() : b()`,
// `v.paused ? v.play() : v.pause()`) — allow them rather than rewrite working code to if/else.
"@typescript-eslint/no-unused-expressions": [
"error",
{ allowShortCircuit: true, allowTernary: true },
],
},
},
prettier,
);
+2435
View File
File diff suppressed because it is too large Load Diff
+20 -2
View File
@@ -4,10 +4,19 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "npm run typecheck:app && npm run typecheck:node && npm run typecheck:e2e",
"typecheck:app": "tsc --noEmit -p tsconfig.json",
"typecheck:node": "tsc -p tsconfig.node.json",
"typecheck:e2e": "tsc --noEmit -p e2e/tsconfig.json",
"build": "npm run typecheck:app && npm run typecheck:node && vite build",
"preview": "vite preview",
"knip": "knip",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"e2e": "playwright test"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -25,15 +34,24 @@
"react-i18next": "^14.1.2"
},
"devDependencies": {
"@eslint/js": "9.39.1",
"@playwright/test": "^1.61.1",
"@types/node": "^26.1.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"eslint": "9.39.1",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.24",
"globals": "16.5.0",
"knip": "6.27.0",
"postcss": "^8.4.39",
"prettier": "3.6.2",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3",
"typescript-eslint": "8.46.0",
"vite": "^5.3.4",
"vitest": "^3.2.7"
}
+35 -46
View File
@@ -1,12 +1,7 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
api,
getActiveAccount,
setActiveAccount,
setUnauthorizedHandler,
} from "./lib/api";
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
import { setLanguage, isSupported, type LangCode } from "./i18n";
import { hasFilterParams, stripUrlParams } from "./lib/urlState";
import type { Page } from "./lib/urlState";
@@ -19,12 +14,7 @@ import {
type PanelLayout,
} from "./lib/panelLayout";
import { notify, removeByMetaKind } from "./lib/notifications";
import {
LS,
readAccount,
setAccountRaw,
writeAccount,
} from "./lib/storage";
import { LS, readAccount, setAccountRaw, writeAccount } from "./lib/storage";
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
import { useChannelLayout } from "./components/ChannelsProvider";
@@ -99,15 +89,15 @@ export default function App() {
// initial render synchronously so nothing flashes open before the server prefs arrive.
// Default unpinned (slim + hover-expand overlay); accounts that explicitly pin keep their choice.
const [navCollapsed, setNavCollapsedState] = useState<boolean>(() =>
Boolean(readAccount(LS.navCollapsed, true))
Boolean(readAccount(LS.navCollapsed, true)),
);
// Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
Boolean(readAccount(LS.filterCollapsed, true))
Boolean(readAccount(LS.filterCollapsed, true)),
);
// The Playlists rail has its own collapse flag (it's a list/navigator, not filters).
const [playlistsCollapsed, setPlaylistsCollapsedState] = useState<boolean>(() =>
Boolean(readAccount(LS.playlistsCollapsed, true))
Boolean(readAccount(LS.playlistsCollapsed, true)),
);
function setNavCollapsed(next: boolean) {
setNavCollapsedState(next);
@@ -134,8 +124,7 @@ export default function App() {
// post-login effect (which knows the account id). Here we only clean the address bar.
stripUrlParams();
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, []);
const queryClient = useQueryClient();
// First-run install gate: a fresh instance reports configured=false and runs in setup mode.
@@ -364,38 +353,38 @@ export default function App() {
<div className="relative flex-1 min-w-0 flex flex-col">
{channelView ? (
<PageShell scrollKey={`channel:${channelView.id}`}>
<Suspense fallback={pageFallback}>
<ChannelPage
key={channelView.id}
channelId={channelView.id}
initialName={channelView.name}
me={meQuery.data!}
view={view}
setView={setView}
onShowInFeed={() => {
// Keep the rest of the feed's filters — the point is "this channel, the way I
// normally read": unwatched, my tags, my date window.
// ADD to the picked set, don't replace it: channels OR together now, so arriving
// from a second channel page should widen the feed, not silently drop the first.
// Deduped — clicking it again for a channel already picked is a no-op.
setFilters({
...filters,
channelIds: [...new Set([...filters.channelIds, channelView.id])],
});
// setPage already leaves an open channel page and pushes a clean history entry.
// Calling closeChannel() first ran history.back(), whose popstate landed AFTER this
// and restored whatever page the channel had been opened from.
setPage("feed");
}}
/>
</Suspense>
<Suspense fallback={pageFallback}>
<ChannelPage
key={channelView.id}
channelId={channelView.id}
initialName={channelView.name}
me={meQuery.data!}
view={view}
setView={setView}
onShowInFeed={() => {
// Keep the rest of the feed's filters — the point is "this channel, the way I
// normally read": unwatched, my tags, my date window.
// ADD to the picked set, don't replace it: channels OR together now, so arriving
// from a second channel page should widen the feed, not silently drop the first.
// Deduped — clicking it again for a channel already picked is a no-op.
setFilters({
...filters,
channelIds: [...new Set([...filters.channelIds, channelView.id])],
});
// setPage already leaves an open channel page and pushes a clean history entry.
// Calling closeChannel() first ran history.back(), whose popstate landed AFTER this
// and restored whatever page the channel had been opened from.
setPage("feed");
}}
/>
</Suspense>
</PageShell>
) : (
<PageShell
scrollKey={normalScrollKey}
// Channels: the registry disables the top fade for its sticky-header table, but the card
// layout has no sticky header, so re-enable it there.
fadeTop={activePage === "channels" ? channelLayout === "cards" : fadeTop ?? true}
fadeTop={activePage === "channels" ? channelLayout === "cards" : (fadeTop ?? true)}
header={<Header me={meQuery.data!} onYtSearch={enterYtSearch} />}
banners={
<>
@@ -404,9 +393,9 @@ export default function App() {
</>
}
>
<Suspense fallback={pageFallback}>
<PageContent />
</Suspense>
<Suspense fallback={pageFallback}>
<PageContent />
</Suspense>
</PageShell>
)}
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail.
+4 -1
View File
@@ -24,7 +24,10 @@ export default function About({
const { data } = useQuery({ queryKey: ["version"], queryFn: api.version, staleTime: 5 * 60_000 });
const rows: [string, string][] = [
[t("about.frontend"), FRONTEND_VERSION + (FRONTEND_SHA !== "unknown" ? ` · ${FRONTEND_SHA}` : "")],
[
t("about.frontend"),
FRONTEND_VERSION + (FRONTEND_SHA !== "unknown" ? ` · ${FRONTEND_SHA}` : ""),
],
[
t("about.backend"),
data ? data.app_version + (data.git_sha !== "unknown" ? ` · ${data.git_sha}` : "") : "…",
+10 -15
View File
@@ -37,7 +37,6 @@ export default function AddToPlaylist({
// its content — hence height — changes, so the flip-above decision uses the true size.
useLayoutEffect(() => {
if (open) place();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, membership.data]);
function place() {
@@ -134,14 +133,14 @@ export default function AddToPlaylist({
className="z-overlay glass-menu rounded-xl p-2 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="text-xs text-muted px-1.5 pb-1.5">
{t("playlists.addToPlaylist")}
</div>
<div ref={fade.ref} style={fade.style} className="max-h-56 overflow-y-auto no-scrollbar">
<div className="text-xs text-muted px-1.5 pb-1.5">{t("playlists.addToPlaylist")}</div>
<div
ref={fade.ref}
style={fade.style}
className="max-h-56 overflow-y-auto no-scrollbar"
>
{lists.length === 0 && !membership.isLoading && (
<div className="text-xs text-muted px-1.5 py-2">
{t("playlists.noneYet")}
</div>
<div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div>
)}
{lists.map((pl) => (
<button
@@ -152,16 +151,12 @@ export default function AddToPlaylist({
>
<span
className={`grid place-items-center w-4 h-4 rounded border ${
pl.has_video
? "bg-accent border-accent text-accent-fg"
: "border-border"
pl.has_video ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{pl.has_video && <Check className="w-3 h-3" />}
</span>
<span className="truncate flex-1 text-left">
{playlistName(pl, t)}
</span>
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube className="w-3 h-3 shrink-0 text-muted" />
)}
@@ -181,7 +176,7 @@ export default function AddToPlaylist({
/>
</form>
</div>,
document.body
document.body,
)}
</>
);
+21 -5
View File
@@ -45,7 +45,13 @@ export default function AdminUsers() {
);
}
function Badge({ tone, children }: { tone: "accent" | "muted" | "warning"; children: React.ReactNode }) {
function Badge({
tone,
children,
}: {
tone: "accent" | "muted" | "warning";
children: React.ReactNode;
}) {
const cls =
tone === "accent"
? "border-accent/40 text-accent"
@@ -53,7 +59,9 @@ function Badge({ tone, children }: { tone: "accent" | "muted" | "warning"; child
? "border-amber-500/40 text-amber-500"
: "border-border text-muted";
return (
<span className={`shrink-0 text-[11px] px-2 py-0.5 rounded-full border ${cls}`}>{children}</span>
<span className={`shrink-0 text-[11px] px-2 py-0.5 rounded-full border ${cls}`}>
{children}
</span>
);
}
@@ -209,17 +217,25 @@ function UsersRoles({ me }: { me: Me }) {
<button
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")}
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-amber-500/40 text-amber-500 hover:bg-amber-500/10"
: "border-border text-muted hover:text-amber-500"
}`}
>
{u.is_suspended ? <RotateCcw className="w-4 h-4" /> : <Ban className="w-4 h-4" />}
{u.is_suspended ? (
<RotateCcw className="w-4 h-4" />
) : (
<Ban className="w-4 h-4" />
)}
</button>
</Tooltip>
<Tooltip hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}>
<Tooltip
hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}
>
<button
onClick={() => onDelete(u)}
disabled={u.is_demo || self || pendingIds.has(u.id)}
+5 -2
View File
@@ -40,7 +40,10 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
}, [dep, threshold]);
const toTop = () =>
(scrollerRef.current ?? document.querySelector("main"))?.scrollTo({ top: 0, behavior: "smooth" });
(scrollerRef.current ?? document.querySelector("main"))?.scrollTo({
top: 0,
behavior: "smooth",
});
// No aria-label/title attributes: ad-block "annoyance" cosmetic filters (e.g. Brave Shields)
// commonly hide floating corner buttons via attribute selectors like [aria-label="Back to top"].
@@ -55,6 +58,6 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
<ArrowUp className="w-7 h-7" aria-hidden="true" />
<span className="sr-only">{t("common.backToTop")}</span>
</button>,
document.body
document.body,
);
}
+10 -2
View File
@@ -5,8 +5,16 @@ import { X } from "lucide-react";
type BannerTone = "accent" | "warning";
const TONES: Record<BannerTone, { bar: string; icon: string; action: string }> = {
accent: { bar: "bg-accent/15 border-accent/30", icon: "text-accent", action: "bg-accent text-accent-fg" },
warning: { bar: "bg-amber-500/15 border-amber-500/30", icon: "text-amber-500", action: "bg-amber-500 text-black" },
accent: {
bar: "bg-accent/15 border-accent/30",
icon: "text-accent",
action: "bg-accent text-accent-fg",
},
warning: {
bar: "bg-amber-500/15 border-amber-500/30",
icon: "text-amber-500",
action: "bg-amber-500 text-black",
},
};
// Shared top-of-app notification bar. VersionBanner and DemoBanner are thin wrappers over this;
+11 -3
View File
@@ -6,7 +6,10 @@ import { linkify } from "../lib/linkify";
// --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) ---
function displayName(code: string, lang: string, type: "region" | "language"): string {
try {
return new Intl.DisplayNames([lang], { type }).of(type === "region" ? code.toUpperCase() : code) ?? code;
return (
new Intl.DisplayNames([lang], { type }).of(type === "region" ? code.toUpperCase() : code) ??
code
);
} catch {
return code;
}
@@ -47,7 +50,9 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
return (
<>
{ch.description ? (
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{linkify(ch.description)}</p>
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">
{linkify(ch.description)}
</p>
) : (
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
)}
@@ -75,7 +80,10 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
<span className="inline-flex items-center gap-2">
{/^[A-Za-z]{2}$/.test(ch.country) && (
// Real SVG flag (flag-icons) — Windows/Chrome don't render flag emoji.
<span className={`fi fi-${ch.country.toLowerCase()} rounded-sm shrink-0`} aria-hidden />
<span
className={`fi fi-${ch.country.toLowerCase()} rounded-sm shrink-0`}
aria-hidden
/>
)}
{displayName(ch.country, i18n.language, "region")}
</span>
+7 -9
View File
@@ -91,7 +91,7 @@ export default function ChannelDiscovery({
const q = search.trim().toLowerCase();
const rows = (query.data ?? []).filter(
(c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)
(c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q),
);
const columns: Column<DiscoveredChannel>[] = [
@@ -107,9 +107,7 @@ export default function ChannelDiscovery({
render: (c) => (
<Tooltip
hint={
canWrite
? t("channels.discovery.subscribeHint")
: t("channels.discovery.needWriteHint")
canWrite ? t("channels.discovery.subscribeHint") : t("channels.discovery.needWriteHint")
}
>
<button
@@ -189,11 +187,11 @@ export default function ChannelDiscovery({
// copy+sort the whole list every render for a result nothing reads.
const isCards = layout === "cards";
const sortedRows = isCards ? sortRows(rows, columns, sort) : rows;
const { paged: pagedRows, pagerProps, resetPage } = useCardPager(
sortedRows,
LS.channelDiscoveryCardSize,
isCards,
);
const {
paged: pagedRows,
pagerProps,
resetPage,
} = useCardPager(sortedRows, LS.channelDiscoveryCardSize, isCards);
// Changing what you're looking at starts over at the first page. NOT keyed on the row count:
// subscribing drops a row from this tab, and being thrown to page 1 for it would punish the very
// action you just took — a list that shrank past your page is handled by the hook's clamp.
@@ -49,7 +49,9 @@ export default function ChannelLayoutGrid<T>({
const meta = columns.filter((c) => !c.cardPrimary && !slotted.has(c.key));
const renderCard = (row: T) => (
<div className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}>
<div
className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1 font-medium">
{primary.map((col) => (
+209 -189
View File
@@ -2,7 +2,16 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigationActions } from "./NavigationProvider";
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react";
import {
ArrowLeft,
Ban,
Check,
ExternalLink,
Loader2,
Plus,
RefreshCw,
SlidersHorizontal,
} from "lucide-react";
import Avatar from "./Avatar";
import Feed from "./Feed";
import ChannelAboutContent from "./ChannelAbout";
@@ -15,7 +24,6 @@ import { api, type FeedFilters, type Me } from "../lib/api";
import type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatViews } from "../lib/format";
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
// auto-ingests recent uploads in the background ("explore") so they're browsable immediately,
@@ -90,7 +98,7 @@ export default function ChannelPage({
})
.finally(() => setExploring(false));
},
[channelId, qc]
[channelId, qc],
);
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped for the demo
@@ -167,14 +175,21 @@ export default function ChannelPage({
const name = ch?.title ?? initialName ?? channelId;
const ytUrl = channelYouTubeUrl(channelId, ch?.handle);
const joined = ch?.published_at
? new Date(ch.published_at).toLocaleDateString(i18n.language, { year: "numeric", month: "short" })
? new Date(ch.published_at).toLocaleDateString(i18n.language, {
year: "numeric",
month: "short",
})
: null;
// Handle + stats on one compact meta line under the name (saves the separate stats row).
const metaParts = [
ch?.handle ? `@${ch.handle.replace(/^@/, "")}` : null,
ch?.subscriber_count != null ? t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) }) : null,
ch?.subscriber_count != null
? t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) })
: null,
ch?.video_count != null ? t("channel.videoCount", { count: ch.video_count }) : null,
ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null,
ch?.total_view_count != null
? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) })
: null,
joined ? t("channel.joined", { date: joined }) : null,
].filter(Boolean) as string[];
const tabClass = (active: boolean) =>
@@ -188,211 +203,216 @@ export default function ChannelPage({
return (
<>
<PageToolbar>
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
<div className="relative px-4 sm:px-6 pt-3">
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? (
// Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
// low default size, so request a crisp wide version (=w1707) and object-cover the
// desktop "safe area" — the centre 2560×423 (~6:1) band.
<div
className="w-full overflow-hidden rounded-2xl bg-surface"
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
>
<img
// key forces a fresh <img> per attempt; the ?r= cache-buster on a retry bypasses the
// browser's negative cache (googleusercontent accepts an extra query param).
key={bannerAttempt}
src={`${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`}
alt=""
className="w-full h-full object-cover object-center"
onError={() => {
if (bannerAttempt <= MAX_BANNER_RETRIES) {
window.setTimeout(() => setBannerAttempt((a) => a + 1), 500 * (bannerAttempt + 1));
}
}}
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
<div className="relative px-4 sm:px-6 pt-3">
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? (
// Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
// low default size, so request a crisp wide version (=w1707) and object-cover the
// desktop "safe area" — the centre 2560×423 (~6:1) band.
<div
className="w-full overflow-hidden rounded-2xl bg-surface"
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
>
<img
// key forces a fresh <img> per attempt; the ?r= cache-buster on a retry bypasses the
// browser's negative cache (googleusercontent accepts an extra query param).
key={bannerAttempt}
src={`${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`}
alt=""
className="w-full h-full object-cover object-center"
onError={() => {
if (bannerAttempt <= MAX_BANNER_RETRIES) {
window.setTimeout(
() => setBannerAttempt((a) => a + 1),
500 * (bannerAttempt + 1),
);
}
}}
/>
</div>
) : (
// No banner (or a dead one): a neutral bar the SAME height as a real banner, so the
// overlapping avatar + the Back button have the same room and don't collide (a short bar
// let the -mt-8 avatar ride up into the Back button).
<div
className="w-full rounded-2xl bg-surface"
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
/>
</div>
) : (
// No banner (or a dead one): a neutral bar the SAME height as a real banner, so the
// overlapping avatar + the Back button have the same room and don't collide (a short bar
// let the -mt-8 avatar ride up into the Back button).
<div
className="w-full rounded-2xl bg-surface"
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
/>
)}
<button
data-testid="channel-back"
onClick={closeChannel}
className="absolute top-5 left-7 sm:left-9 z-base inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-sm"
>
<ArrowLeft className="w-4 h-4" />
{t("channel.back")}
</button>
</div>
<div className="px-4 sm:px-6">
{/* Identity + actions. relative+z-base so the avatar that overlaps up into the banner
paints ABOVE it (the banner's positioned container would otherwise cover it). */}
<div className="relative z-base flex items-end gap-4 -mt-8">
<Avatar
src={ch?.thumbnail_url ?? null}
fallback={name}
className="w-20 h-20 rounded-full border-4 border-bg shrink-0 bg-bg"
/>
<div className="flex-1 min-w-0 pb-1">
<div className="flex items-center gap-2 flex-wrap">
<h1 data-testid="channel-title" className="text-xl font-semibold truncate">{name}</h1>
{ch?.blocked ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
{t("channel.blockedBadge")}
</span>
) : (
ch?.explored &&
!ch?.subscribed && (
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400">
{t("channel.exploringBadge")}
</span>
)
)}
</div>
<div className="text-sm text-muted mt-0.5 flex flex-wrap gap-x-1.5 gap-y-0.5">
{metaParts.map((part, i) => (
<span key={i}>
{i > 0 && <span className="mr-1.5">·</span>}
{part}
</span>
))}
</div>
</div>
)}
<button
data-testid="channel-back"
onClick={closeChannel}
className="absolute top-5 left-7 sm:left-9 z-base inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-sm"
>
<ArrowLeft className="w-4 h-4" />
{t("channel.back")}
</button>
</div>
{exploring && (
<div className="flex items-center gap-2 text-xs text-muted mt-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{t("channel.loadingVideos")}
<div className="px-4 sm:px-6">
{/* Identity + actions. relative+z-base so the avatar that overlaps up into the banner
paints ABOVE it (the banner's positioned container would otherwise cover it). */}
<div className="relative z-base flex items-end gap-4 -mt-8">
<Avatar
src={ch?.thumbnail_url ?? null}
fallback={name}
className="w-20 h-20 rounded-full border-4 border-bg shrink-0 bg-bg"
/>
<div className="flex-1 min-w-0 pb-1">
<div className="flex items-center gap-2 flex-wrap">
<h1 data-testid="channel-title" className="text-xl font-semibold truncate">
{name}
</h1>
{ch?.blocked ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
{t("channel.blockedBadge")}
</span>
) : (
ch?.explored &&
!ch?.subscribed && (
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400">
{t("channel.exploringBadge")}
</span>
)
)}
</div>
<div className="text-sm text-muted mt-0.5 flex flex-wrap gap-x-1.5 gap-y-0.5">
{metaParts.map((part, i) => (
<span key={i}>
{i > 0 && <span className="mr-1.5">·</span>}
{part}
</span>
))}
</div>
</div>
</div>
)}
{/* Tabs — the channel actions ride along here rather than off in the header's top-right
{exploring && (
<div className="flex items-center gap-2 text-xs text-muted mt-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{t("channel.loadingVideos")}
</div>
)}
{/* Tabs — the channel actions ride along here rather than off in the header's top-right
corner: there are only ever two tabs, so there's room, and it keeps the things you click
within a few pixels of each other instead of across the page. */}
<div className="flex items-center gap-4 mt-3 border-b border-border">
<button onClick={() => setTab("videos")} className={tabClass(tab === "videos")}>
{t("channel.tabVideos")}
</button>
<button onClick={() => setTab("about")} className={tabClass(tab === "about")}>
{t("channel.tabAbout")}
</button>
<div className="ml-2 flex items-center gap-2 pb-1.5">
<a
href={ytUrl}
target="_blank"
rel="noreferrer"
title={t("channels.row.openOnYouTube")}
aria-label={t("channels.row.openOnYouTube")}
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg border border-border"
>
<ExternalLink className="w-4 h-4" />
</a>
{ch?.subscribed && (
<button
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"
<div className="flex items-center gap-4 mt-3 border-b border-border">
<button onClick={() => setTab("videos")} className={tabClass(tab === "videos")}>
{t("channel.tabVideos")}
</button>
<button onClick={() => setTab("about")} className={tabClass(tab === "about")}>
{t("channel.tabAbout")}
</button>
<div className="ml-2 flex items-center gap-2 pb-1.5">
<a
href={ytUrl}
target="_blank"
rel="noreferrer"
title={t("channels.row.openOnYouTube")}
aria-label={t("channels.row.openOnYouTube")}
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg border border-border"
>
<SlidersHorizontal className="w-4 h-4" />
</button>
)}
{!me.is_demo && (
<button
onClick={() => block.mutate()}
disabled={block.isPending}
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
aria-label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border disabled:opacity-50 transition ${
ch?.blocked
? "border-danger text-danger"
: "border-border text-muted hover:text-danger hover:border-danger"
}`}
>
<Ban className="w-4 h-4" />
</button>
)}
{!me.is_demo &&
!ch?.blocked &&
(ch?.subscribed ? (
<ExternalLink className="w-4 h-4" />
</a>
{ch?.subscribed && (
<button
onClick={onUnsubscribe}
disabled={unsubscribe.isPending}
title={t("channel.unsubscribe")}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg disabled:opacity-50"
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"
>
<Check className="w-4 h-4" />
{t("channel.subscribedState")}
<SlidersHorizontal className="w-4 h-4" />
</button>
) : (
)}
{!me.is_demo && (
<button
onClick={() => subscribe.mutate()}
disabled={subscribe.isPending}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50"
onClick={() => block.mutate()}
disabled={block.isPending}
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
aria-label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border disabled:opacity-50 transition ${
ch?.blocked
? "border-danger text-danger"
: "border-border text-muted hover:text-danger hover:border-danger"
}`}
>
<Plus className="w-4 h-4" />
{t("channel.subscribe")}
<Ban className="w-4 h-4" />
</button>
))}
)}
{!me.is_demo &&
!ch?.blocked &&
(ch?.subscribed ? (
<button
onClick={onUnsubscribe}
disabled={unsubscribe.isPending}
title={t("channel.unsubscribe")}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg disabled:opacity-50"
>
<Check className="w-4 h-4" />
{t("channel.subscribedState")}
</button>
) : (
<button
onClick={() => subscribe.mutate()}
disabled={subscribe.isPending}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50"
>
<Plus className="w-4 h-4" />
{t("channel.subscribe")}
</button>
))}
</div>
</div>
</div>
</div>
{/* The channelScoped Feed portals its toolbar into this node — the bottom of the fixed chrome,
{/* The channelScoped Feed portals its toolbar into this node — the bottom of the fixed chrome,
just under the tabs. */}
<div ref={setChromeSlot} />
<div ref={setChromeSlot} />
</PageToolbar>
{/* Tab content — the feed's toolbar portals up into the fixed chrome via this slot override;
only the grid (and About) render here, in the scroller. */}
<PageToolbarSlot.Provider value={chromeSlot}>
{tab === "videos" ? (
<>
<Feed
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={me.can_read}
isDemo={me.is_demo}
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
channelScoped
/>
{!ch?.subscribed && nextToken && (
<div className="flex justify-center pb-6">
<button
onClick={() => runExplore(nextToken)}
disabled={exploring}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border hover:bg-surface disabled:opacity-50"
>
{exploring ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
{t("channel.loadMore")}
</button>
</div>
)}
</>
) : (
<div className="px-4 sm:px-6 py-4 max-w-3xl">
{ch ? (
<ChannelAboutContent ch={ch} />
) : (
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
)}
</div>
)}
{tab === "videos" ? (
<>
<Feed
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={me.can_read}
isDemo={me.is_demo}
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
channelScoped
/>
{!ch?.subscribed && nextToken && (
<div className="flex justify-center pb-6">
<button
onClick={() => runExplore(nextToken)}
disabled={exploring}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border hover:bg-surface disabled:opacity-50"
>
{exploring ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
{t("channel.loadMore")}
</button>
</div>
)}
</>
) : (
<div className="px-4 sm:px-6 py-4 max-w-3xl">
{ch ? (
<ChannelAboutContent ch={ch} />
) : (
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
)}
</div>
)}
</PageToolbarSlot.Provider>
</>
);
+273 -210
View File
@@ -82,8 +82,14 @@ export default function Channels() {
focusToken: focusChannelToken,
} = useChannels();
const layout = useChannelLayout();
const { setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel: onFocusChannel } =
useChannelsActions();
const {
setSearch,
setStatusFilter,
setView,
setLayout,
setSort,
focusChannel: onFocusChannel,
} = useChannelsActions();
const { openChannel: onViewChannel, setPage } = useNavigationActions();
const feedFilters = useFeedFilters();
const { setFilters: setFeedFilters } = useFeedFiltersActions();
@@ -144,7 +150,7 @@ export default function Channels() {
const bumpPriority = (id: string, current: number, delta: number) => {
const next = current + delta;
qc.setQueryData<ManagedChannel[]>(["channels"], (old) =>
(old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch))
(old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)),
);
api
.updateChannel(id, { priority: next })
@@ -159,23 +165,22 @@ export default function Channels() {
ch.id === id
? {
...ch,
tag_ids: hasTag
? ch.tag_ids.filter((x) => x !== tagId)
: [...ch.tag_ids, tagId],
tag_ids: hasTag ? ch.tag_ids.filter((x) => x !== tagId) : [...ch.tag_ids, tagId],
}
: ch
)
: ch,
),
);
const call = hasTag
? api.detachChannelTag(id, tagId)
: api.attachChannelTag(id, tagId);
const call = hasTag ? api.detachChannelTag(id, tagId) : api.attachChannelTag(id, tagId);
call.catch(() => qc.invalidateQueries({ queryKey: ["channels"] }));
};
const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(),
onSuccess: (r: { subscriptions?: number }) => {
invalidate();
notify({ level: "success", message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }) });
notify({
level: "success",
message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }),
});
},
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.syncFailed"), onOpenWizard),
});
@@ -186,7 +191,8 @@ export default function Channels() {
qc.invalidateQueries({ queryKey: ["my-status"] });
notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) });
},
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard),
onError: (e) =>
notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard),
});
const resetBackfill = useMutation({
mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id),
@@ -207,7 +213,8 @@ export default function Channels() {
message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
});
},
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard),
onError: (e) =>
notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard),
});
// Channel-name search (from the shared top SearchBar, via props) + tag-chip filtering, applied
@@ -237,7 +244,7 @@ export default function Channels() {
? c.backfill_done
: statusFilter === "hidden"
? c.hidden
: true
: true,
);
const q = search.trim().toLowerCase();
const visibleChannels = channels.filter((c) => {
@@ -338,7 +345,9 @@ export default function Channels() {
nowrap: true,
sortable: true,
sortValue: (c) => c.stored_videos,
render: (c) => <span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>,
render: (c) => (
<span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>
),
},
subsColumn<ManagedChannel>(t),
{
@@ -356,7 +365,11 @@ export default function Channels() {
nowrap: true,
sortable: true,
sortValue: (c) => c.total_duration_seconds,
render: (c) => <span className="text-muted tabular-nums">{formatTotalHours(c.total_duration_seconds)}</span>,
render: (c) => (
<span className="text-muted tabular-nums">
{formatTotalHours(c.total_duration_seconds)}
</span>
),
},
{
key: "types",
@@ -381,7 +394,12 @@ export default function Channels() {
{ value: "fully", label: t("channels.filters.fullySynced") },
{ value: "needsFull", label: t("channels.filters.needsFull") },
],
test: (c, v) => (v === "fully" ? c.recent_synced && c.backfill_done : v === "needsFull" ? !c.backfill_done : true),
test: (c, v) =>
v === "fully"
? c.recent_synced && c.backfill_done
: v === "needsFull"
? !c.backfill_done
: true,
},
render: (c) => <SyncCell c={c} />,
},
@@ -431,7 +449,11 @@ export default function Channels() {
<div className="flex flex-wrap items-center justify-end gap-x-5 gap-y-1 text-sm text-muted">
{s && (
<>
<Stat label={t("channels.stats.channels")} value={s.channels_total} hint={t("channels.stats.channelsHint")} />
<Stat
label={t("channels.stats.channels")}
value={s.channels_total}
hint={t("channels.stats.channelsHint")}
/>
<Stat
label={t("channels.stats.recentSynced")}
value={`${s.channels_recent_synced}/${s.channels_total}`}
@@ -449,7 +471,11 @@ export default function Channels() {
hint={t("channels.stats.leftHint", { count: s.deep_pending_count })}
/>
)}
<Stat label={t("channels.stats.myVideos")} value={s.my_videos.toLocaleString()} hint={t("channels.stats.myVideosHint")} />
<Stat
label={t("channels.stats.myVideos")}
value={s.my_videos.toLocaleString()}
hint={t("channels.stats.myVideosHint")}
/>
<Stat
label={t("channels.stats.quotaLeft")}
value={s.quota_remaining_today.toLocaleString()}
@@ -522,11 +548,11 @@ export default function Channels() {
// too would copy+sort the whole ~300-row array on every render for a result nothing reads.
const isCards = layout === "cards";
const sortedChannels = isCards ? sortRows(visibleChannels, columns, sort) : visibleChannels;
const { paged: pagedCards, pagerProps, resetPage } = useCardPager(
sortedChannels,
LS.channelCardSize,
isCards,
);
const {
paged: pagedCards,
pagerProps,
resetPage,
} = useCardPager(sortedChannels, LS.channelCardSize, isCards);
// Changing what you're looking at starts over at the first page, the way DataTable resets its own
// page on a filter/sort change. Deliberately NOT keyed on the row count: the ["channels"] query
// refetches on window focus and after every sync, so a catalogue that merely gained a channel would
@@ -564,169 +590,161 @@ export default function Channels() {
return (
<>
{/* The tab bar is always fixed chrome (it portals into the shell's fixed band, so it never
{/* The tab bar is always fixed chrome (it portals into the shell's fixed band, so it never
scrolls and doesn't jump between tabs). On Subscriptions the stats/description/tags ride
with it, and the status-chip + pager row lands just below (DataTable's controlsInBand). */}
<PageToolbar>
{tabs}
{view === "subscribed" && (
<div className="px-4 pt-2 pb-2 max-w-[96rem] mx-auto">
{/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name
<PageToolbar>
{tabs}
{view === "subscribed" && (
<div className="px-4 pt-2 pb-2 max-w-[96rem] mx-auto">
{/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name
search lives in the shared top SearchBar, tag create/delete in the manager. */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex flex-wrap items-center gap-1.5">
<Tooltip hint={t("channels.tags.yourTagsHint")}>
<span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4">
{t("channels.tags.yourTags")}
</span>
</Tooltip>
{userTags.map((tg) => {
const active = tagFilter.includes(tg.id);
return (
<button
key={tg.id}
onClick={() =>
setTagFilter((f) => (active ? f.filter((x) => x !== tg.id) : [...f, tg.id]))
}
aria-pressed={active}
className={`text-xs px-2 py-1 rounded-full border transition ${
active
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:text-fg hover:border-accent"
}`}
>
{tg.name}
</button>
);
})}
{tagFilter.length > 0 && (
<button
onClick={() => setTagFilter([])}
className="text-xs px-2 py-1 text-muted hover:text-accent transition"
>
{t("datatable.clear")}
</button>
)}
<button
onClick={() => setTagManagerOpen(true)}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
>
<Pencil className="w-3 h-3" />
{t("channels.tags.manage")}
</button>
</div>
{actionButtons}
</div>
</div>
)}
{/* In table mode the status chips + pager ride in DataTable's own in-band controls row. The card
layout has no such row, so surface the same status chips (left) and its pager (right) here. */}
{view === "subscribed" && layout === "cards" && (
<div className="px-4 pb-2 max-w-[96rem] mx-auto flex items-center justify-between gap-3 flex-wrap">
{statusChips}
{cardPager}
</div>
)}
</PageToolbar>
{tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)}
{view === "discovery" ? (
<ChannelDiscovery
canWrite={canWrite}
search={search}
onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel}
/>
) : view === "blocked" ? (
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
<div className="px-4 pb-4 pt-3 max-w-[96rem] mx-auto">
{(blockedQuery.data?.length ?? 0) === 0 ? (
<p className="text-sm text-muted py-8">{t("channels.blocked.empty")}</p>
) : (
<>
<div className="flex items-center gap-2 mb-3">
<Ban className="w-4 h-4 text-muted" />
<span className="text-xs uppercase tracking-wide text-muted">
{t("channels.blocked.title")}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{blockedQuery.data!.map((c) => (
<span
key={c.id}
className="inline-flex items-center gap-1.5 text-xs pl-2.5 pr-1 py-1 rounded-full bg-card border border-border"
>
{c.title ?? c.id}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex flex-wrap items-center gap-1.5">
<Tooltip hint={t("channels.tags.yourTagsHint")}>
<span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4">
{t("channels.tags.yourTags")}
</span>
</Tooltip>
{userTags.map((tg) => {
const active = tagFilter.includes(tg.id);
return (
<button
key={tg.id}
onClick={() =>
setTagFilter((f) => (active ? f.filter((x) => x !== tg.id) : [...f, tg.id]))
}
aria-pressed={active}
className={`text-xs px-2 py-1 rounded-full border transition ${
active
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:text-fg hover:border-accent"
}`}
>
{tg.name}
</button>
);
})}
{tagFilter.length > 0 && (
<button
onClick={() => unblock.mutate(c.id)}
title={t("channel.unblock")}
aria-label={t("channel.unblock")}
className="rounded-full p-0.5 text-muted hover:text-danger transition"
onClick={() => setTagFilter([])}
className="text-xs px-2 py-1 text-muted hover:text-accent transition"
>
<X className="w-3 h-3" />
{t("datatable.clear")}
</button>
</span>
))}
)}
<button
onClick={() => setTagManagerOpen(true)}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
>
<Pencil className="w-3 h-3" />
{t("channels.tags.manage")}
</button>
</div>
{actionButtons}
</div>
<p className="text-[11px] text-muted mt-2">{t("channels.blocked.hint")}</p>
</>
</div>
)}
</div>
) : (
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
{/* In table mode the status chips + pager ride in DataTable's own in-band controls row. The card
layout has no such row, so surface the same status chips (left) and its pager (right) here. */}
{view === "subscribed" && layout === "cards" && (
<div className="px-4 pb-2 max-w-[96rem] mx-auto flex items-center justify-between gap-3 flex-wrap">
{statusChips}
{cardPager}
</div>
)}
</PageToolbar>
{tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)}
{view === "discovery" ? (
<ChannelDiscovery
canWrite={canWrite}
search={search}
onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel}
/>
) : view === "blocked" ? (
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
<div className="px-4 pb-4 pt-3 max-w-[96rem] mx-auto">
{(blockedQuery.data?.length ?? 0) === 0 ? (
<p className="text-sm text-muted py-8">{t("channels.blocked.empty")}</p>
) : (
<>
<div className="flex items-center gap-2 mb-3">
<Ban className="w-4 h-4 text-muted" />
<span className="text-xs uppercase tracking-wide text-muted">
{t("channels.blocked.title")}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{blockedQuery.data!.map((c) => (
<span
key={c.id}
className="inline-flex items-center gap-1.5 text-xs pl-2.5 pr-1 py-1 rounded-full bg-card border border-border"
>
{c.title ?? c.id}
<button
onClick={() => unblock.mutate(c.id)}
title={t("channel.unblock")}
aria-label={t("channel.unblock")}
className="rounded-full p-0.5 text-muted hover:text-danger transition"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<p className="text-[11px] text-muted mt-2">{t("channels.blocked.hint")}</p>
</>
)}
</div>
) : (
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
`position: sticky` engages on the first scroll (the band's controls row above gives the
visual gap). */
<div className="px-4 pb-4 max-w-[96rem] mx-auto">
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row
<div className="px-4 pb-4 max-w-[96rem] mx-auto">
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row
(status chips + pager) rides in the fixed band right above it (controlsInBand). The
card layout swaps the table for a virtualized grid over the same rows (its pager and
status chips ride in the fixed band above instead). */}
{channelsQuery.isLoading ? (
<div className="text-muted py-8">{t("channels.loading")}</div>
) : layout === "table" ? (
<DataTable
rows={visibleChannels}
columns={columns}
rowKey={(c) => c.id}
persistKey={accountKey(LS.channelsTable) ?? undefined}
controlsPosition="both"
controlsLeading={statusChips}
controlsInBand
controlsBandClassName="px-4 max-w-[96rem] mx-auto"
sort={sort}
onSortChange={setSort}
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
emptyText={t("channels.empty")}
/>
) : (
<ChannelLayoutGrid
rows={pagedCards}
columns={columns}
rowKey={(c) => c.id}
leadKey="priority"
aboutKey="about"
actionsKey="actions"
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
emptyText={t("channels.empty")}
/>
)}
</div>
)}
{channelsQuery.isLoading ? (
<div className="text-muted py-8">{t("channels.loading")}</div>
) : layout === "table" ? (
<DataTable
rows={visibleChannels}
columns={columns}
rowKey={(c) => c.id}
persistKey={accountKey(LS.channelsTable) ?? undefined}
controlsPosition="both"
controlsLeading={statusChips}
controlsInBand
controlsBandClassName="px-4 max-w-[96rem] mx-auto"
sort={sort}
onSortChange={setSort}
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
emptyText={t("channels.empty")}
/>
) : (
<ChannelLayoutGrid
rows={pagedCards}
columns={columns}
rowKey={(c) => c.id}
leadKey="priority"
aboutKey="about"
actionsKey="actions"
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
emptyText={t("channels.empty")}
/>
)}
</div>
)}
</>
);
}
function Stat({
label,
value,
hint,
}: {
label: string;
value: string | number;
hint?: string;
}) {
function Stat({ label, value, hint }: { label: string; value: string | number; hint?: string }) {
return (
<Tooltip hint={hint ?? ""}>
<span className={hint ? "cursor-help" : ""}>
@@ -751,16 +769,30 @@ function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: str
);
}
function PriorityCell({ c, onPriority }: { c: ManagedChannel; onPriority: (delta: number) => void }) {
function PriorityCell({
c,
onPriority,
}: {
c: ManagedChannel;
onPriority: (delta: number) => void;
}) {
const { t } = useTranslation();
return (
<Tooltip hint={t("channels.row.priorityHint")}>
<div className="inline-flex flex-col items-center cursor-help">
<button onClick={() => onPriority(1)} className="text-muted hover:text-accent" aria-label={t("channels.row.raisePriority")}>
<button
onClick={() => onPriority(1)}
className="text-muted hover:text-accent"
aria-label={t("channels.row.raisePriority")}
>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<span className="text-xs text-muted tabular-nums">{c.priority}</span>
<button onClick={() => onPriority(-1)} className="text-muted hover:text-accent" aria-label={t("channels.row.lowerPriority")}>
<button
onClick={() => onPriority(-1)}
className="text-muted hover:text-accent"
aria-label={t("channels.row.lowerPriority")}
>
<ArrowDown className="w-3.5 h-3.5" />
</button>
</div>
@@ -768,30 +800,51 @@ function PriorityCell({ c, onPriority }: { c: ManagedChannel; onPriority: (delta
);
}
// Status only — the backfill *action* lives in the Actions column. When both recent and
// full history are in, collapse to a single "fully synced" chip; otherwise show what's
// present plus the missing/queued state.
function SyncCell({ c }: { c: ManagedChannel }) {
const { t } = useTranslation();
if (c.recent_synced && c.backfill_done) {
return <SyncBadge ok label={t("channels.row.fullySynced")} hint={t("channels.row.fullySyncedHint")} />;
return (
<SyncBadge
ok
label={t("channels.row.fullySynced")}
hint={t("channels.row.fullySyncedHint")}
/>
);
}
return (
<div className="flex flex-wrap items-center gap-1">
<SyncBadge
ok={c.recent_synced}
label={t("channels.row.recent")}
hint={c.recent_synced ? t("channels.row.recentSyncedHint") : t("channels.row.recentNotSyncedHint")}
hint={
c.recent_synced
? t("channels.row.recentSyncedHint")
: t("channels.row.recentNotSyncedHint")
}
/>
{c.backfill_done ? (
<SyncBadge ok label={t("channels.row.full")} hint={t("channels.row.fullHint")} />
) : c.deep_requested ? (
<SyncBadge ok={false} label={t("channels.row.fullHistoryQueued")} hint={t("channels.row.queuedRequestedHint")} />
<SyncBadge
ok={false}
label={t("channels.row.fullHistoryQueued")}
hint={t("channels.row.queuedRequestedHint")}
/>
) : c.deep_in_queue ? (
<SyncBadge ok={false} label={t("channels.row.fullHistoryComing")} hint={t("channels.row.queuedByOtherHint")} />
<SyncBadge
ok={false}
label={t("channels.row.fullHistoryComing")}
hint={t("channels.row.queuedByOtherHint")}
/>
) : (
<SyncBadge ok={false} label={t("channels.row.full")} hint={t("channels.row.fullNotFetchedHint")} />
<SyncBadge
ok={false}
label={t("channels.row.full")}
hint={t("channels.row.fullNotFetchedHint")}
/>
)}
</div>
);
@@ -814,7 +867,9 @@ function TagsCell({
// `absolute` inside the cell, because in the CARD layout every VirtualGrid row carries a
// `transform` — which makes it a stacking context and traps a z-indexed child, so the menu drew
// UNDER the next card. Same hazard, same blessed fix as the players in E3.
const [coords, setCoords] = useState<{ left: number; top?: number; bottom?: number } | null>(null);
const [coords, setCoords] = useState<{ left: number; top?: number; bottom?: number } | null>(
null,
);
const fade = useScrollFade();
const ref = useRef<HTMLDivElement | null>(null);
const popRef = useRef<HTMLDivElement | null>(null);
@@ -839,9 +894,7 @@ function TagsCell({
const w = popRef.current?.offsetWidth ?? 0;
const left =
w && r.left + w > window.innerWidth - 8 ? Math.max(8, window.innerWidth - 8 - w) : r.left;
return flip
? { left, bottom: window.innerHeight - r.top + 4 }
: { left, top: r.bottom + 4 };
return flip ? { left, bottom: window.innerHeight - r.top + 4 } : { left, top: r.bottom + 4 };
}, []);
const place = useCallback(() => {
@@ -939,33 +992,39 @@ function TagsCell({
</button>
{open && coords && (
<Overlay>
<div
ref={setPopRef}
style={{ ...fade.style, position: "fixed", left: coords.left, top: coords.top, bottom: coords.bottom }}
className="glass-menu z-popover w-44 max-h-[264px] overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
{userTags.map((tg) => {
const on = c.tag_ids.includes(tg.id);
return (
<button
key={tg.id}
onClick={() => onToggleTag(tg.id)}
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
on ? "text-fg" : "text-muted hover:bg-card"
}`}
>
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
on ? "bg-accent border-accent text-accent-fg" : "border-border"
<div
ref={setPopRef}
style={{
...fade.style,
position: "fixed",
left: coords.left,
top: coords.top,
bottom: coords.bottom,
}}
className="glass-menu z-popover w-44 max-h-[264px] overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
{userTags.map((tg) => {
const on = c.tag_ids.includes(tg.id);
return (
<button
key={tg.id}
onClick={() => onToggleTag(tg.id)}
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
on ? "text-fg" : "text-muted hover:bg-card"
}`}
>
{on && <Check className="w-2.5 h-2.5" />}
</span>
{tg.name}
</button>
);
})}
</div>
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
on ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{on && <Check className="w-2.5 h-2.5" />}
</span>
{tg.name}
</button>
);
})}
</div>
</Overlay>
)}
</div>
@@ -1025,7 +1084,11 @@ function ActionsCell({
</button>
</Tooltip>
<Tooltip hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}>
<button onClick={onHide} className="text-muted hover:text-fg shrink-0" aria-label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}>
<button
onClick={onHide}
className="text-muted hover:text-fg shrink-0"
aria-label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}
>
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</Tooltip>
+9 -1
View File
@@ -209,7 +209,15 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
[search, statusFilter, view, sort, filtersResetToken, focusName, focusToken],
);
const actions = useMemo<ChannelsActions>(
() => ({ setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel, goToFullHistory }),
() => ({
setSearch,
setStatusFilter,
setView,
setLayout,
setSort,
focusChannel,
goToFullHistory,
}),
[setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel, goToFullHistory],
);
+19 -5
View File
@@ -2,7 +2,14 @@ import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, ChevronUp, X } from "lucide-react";
import { closeChat, initDock, notifyIncoming, toggleMinimize, useDockChats, type DockChat } from "../lib/chatDock";
import {
closeChat,
initDock,
notifyIncoming,
toggleMinimize,
useDockChats,
type DockChat,
} from "../lib/chatDock";
import { onMessage, onUnread } from "../lib/messagesSocket";
import * as e2ee from "../lib/e2ee";
import Avatar from "./Avatar";
@@ -37,7 +44,7 @@ export default function ChatDock({ meId }: { meId: number }) {
notifyIncoming({ id: from.id, name: from.name, avatar_url: from.avatar_url });
}
}),
[qc, meId]
[qc, meId],
);
// Live badge sync: another tab/device read a thread → refresh this tab's unread count + list.
@@ -47,7 +54,7 @@ export default function ChatDock({ meId }: { meId: number }) {
qc.invalidateQueries({ queryKey: ["message-unread"] });
qc.invalidateQueries({ queryKey: ["conversations"] });
}),
[qc]
[qc],
);
if (chats.length === 0) return null;
@@ -68,7 +75,10 @@ function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
<div className="pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] glass rounded-2xl shadow-2xl flex flex-col overflow-hidden">
{/* One-shot attention flash on a new message; keyed by the counter so it replays each time. */}
{chat.flash > 0 && (
<div key={chat.flash} className="chat-flash pointer-events-none absolute inset-0 rounded-2xl z-base" />
<div
key={chat.flash}
className="chat-flash pointer-events-none absolute inset-0 rounded-2xl z-base"
/>
)}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-card/50">
<button
@@ -76,7 +86,11 @@ function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
className="flex items-center gap-2 min-w-0 flex-1 text-left"
title={chat.minimized ? t("messages.expand") : t("messages.minimize")}
>
<Avatar src={chat.avatar} fallback={chat.name} className="w-7 h-7 rounded-full shrink-0 text-xs" />
<Avatar
src={chat.avatar}
fallback={chat.name}
className="w-7 h-7 rounded-full shrink-0 text-xs"
/>
<span className="font-semibold text-sm truncate">{chat.name}</span>
</button>
<button
+5 -3
View File
@@ -36,7 +36,7 @@ export default function ChatThread({
const q = useLiveQuery<{ partner: MessageUser; items: Message[] }>(
["thread", partnerId],
() => api.thread(partnerId),
{ intervalMs: POLL_MS, enabled: !needsKey }
{ intervalMs: POLL_MS, enabled: !needsKey },
);
const items = q.data?.items ?? [];
@@ -144,7 +144,7 @@ export default function ChatThread({
) : (
items.map((m) => {
const mine = m.sender_id === meId;
const text = m.kind === "system" ? m.body ?? "" : plain[m.id] ?? "…";
const text = m.kind === "system" ? (m.body ?? "") : (plain[m.id] ?? "…");
return (
<div key={m.id} className={`flex ${mine ? "justify-end" : "justify-start"}`}>
<div
@@ -165,7 +165,9 @@ export default function ChatThread({
</div>
{isSystem ? (
<div className="p-2 border-t border-border text-center text-xs text-muted">{t("messages.systemReadOnly")}</div>
<div className="p-2 border-t border-border text-center text-xs text-muted">
{t("messages.systemReadOnly")}
</div>
) : (
<div className="flex items-end gap-2 p-2 border-t border-border">
<textarea
@@ -56,7 +56,11 @@ export default function ColumnSortControl<T>({
aria-label={active.dir === "asc" ? ascLabel : descLabel}
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
>
{active.dir === "asc" ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
{active.dir === "asc" ? (
<ArrowUp className="w-4 h-4" />
) : (
<ArrowDown className="w-4 h-4" />
)}
</button>
)}
</div>
+133 -115
View File
@@ -16,7 +16,19 @@ import { LS } from "../lib/storage";
// applied together via one Save/Discard bar (consistent with the Settings page); nothing hits
// the server until Save. Group order is fixed where known so logically related settings (e.g.
// Email/SMTP) stay together.
const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch", "downloads", "audit", "plex"];
const GROUP_ORDER = [
"access",
"email",
"youtube",
"google",
"quota",
"backfill",
"shorts",
"batch",
"downloads",
"audit",
"plex",
];
export default function ConfigPanel() {
const { t } = useTranslation();
@@ -69,6 +81,7 @@ export default function ConfigPanel() {
try {
for (const key of dirtyKeys) {
const it = byKey[key];
if (!it) continue; // dirtyKeys are all config-item keys; skip if somehow not found
const raw = draft[key] ?? "";
if (it.secret) {
if (secretReset[key] && !raw.trim()) await api.resetConfig(key);
@@ -121,16 +134,17 @@ export default function ConfigPanel() {
notify({ level: "error", message: t("config.plexTestFailed") });
},
});
const plexLibs = (draft["plex_libraries"] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const plexLibs = (draft["plex_libraries"] ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const togglePlexLib = (key: string) => {
// An empty list means "all libraries" — every box renders checked. Unchecking one from that
// state must select all-except-this, so expand "all" to the explicit section set first;
// otherwise the toggle would ADD the key and leave it as the ONLY selected library.
const allKeys = plexResult?.sections?.map((s) => s.key) ?? [];
const current = plexLibs.length === 0 ? allKeys : plexLibs;
const next = current.includes(key)
? current.filter((k) => k !== key)
: [...current, key];
const next = current.includes(key) ? current.filter((k) => k !== key) : [...current, key];
setDraft((d) => ({ ...d, plex_libraries: next.join(",") }));
};
@@ -139,7 +153,7 @@ export default function ConfigPanel() {
}
const groupKeys = Object.keys(data.groups).sort(
(a, b) => (GROUP_ORDER.indexOf(a) + 1 || 99) - (GROUP_ORDER.indexOf(b) + 1 || 99)
(a, b) => (GROUP_ORDER.indexOf(a) + 1 || 99) - (GROUP_ORDER.indexOf(b) + 1 || 99),
);
// Clamp the persisted tab to a real group (the registry can change between sessions). The
// tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is
@@ -157,118 +171,124 @@ export default function ConfigPanel() {
<PageToolbar>
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<p className="text-xs text-muted mb-3 leading-relaxed">{t("config.intro")}</p>
<Tabs tabs={groupTabs} active={activeGroup} onChange={setTab} />
<Tabs tabs={groupTabs} active={activeGroup ?? ""} onChange={setTab} />
</div>
</PageToolbar>
<div className="px-4 pb-24 pt-3 max-w-3xl w-full mx-auto">
{activeGroup && (
<div className="glass rounded-2xl p-4 mb-4">
<div className="divide-y divide-border/60">
{data.groups[activeGroup].map((item) => (
<Field
key={item.key}
item={item}
value={draft[item.key] ?? ""}
secretsManageable={data.secrets_manageable}
pendingSecretReset={!!secretReset[item.key]}
onChange={(v) => setDraft((d) => ({ ...d, [item.key]: v }))}
onResetField={() => {
if (item.secret) setSecretReset((s) => ({ ...s, [item.key]: !s[item.key] }));
else setDraft((d) => ({ ...d, [item.key]: "" }));
}}
/>
))}
</div>
{activeGroup === "email" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.testEmailHint")}>
<button
onClick={() => testEmail.mutate()}
disabled={testEmail.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Send className={`w-4 h-4 ${testEmail.isPending ? "animate-pulse" : ""}`} />
{t("config.testEmail")}
</button>
</Tooltip>
{activeGroup && (
<div className="glass rounded-2xl p-4 mb-4">
<div className="divide-y divide-border/60">
{(data.groups[activeGroup] ?? []).map((item) => (
<Field
key={item.key}
item={item}
value={draft[item.key] ?? ""}
secretsManageable={data.secrets_manageable}
pendingSecretReset={!!secretReset[item.key]}
onChange={(v) => setDraft((d) => ({ ...d, [item.key]: v }))}
onResetField={() => {
if (item.secret) setSecretReset((s) => ({ ...s, [item.key]: !s[item.key] }));
else setDraft((d) => ({ ...d, [item.key]: "" }));
}}
/>
))}
</div>
)}
{activeGroup === "plex" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.plexTestHint")}>
<button
onClick={() => testPlex.mutate()}
disabled={testPlex.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Plug className={`w-4 h-4 ${testPlex.isPending ? "animate-pulse" : ""}`} />
{t("config.plexTest")}
</button>
</Tooltip>
{dirty && <p className="text-[11px] text-muted mt-2">{t("config.plexTestDirty")}</p>}
{plexResult && (
<div className="mt-3">
<p className="text-xs text-muted mb-2">
{t("config.plexConnected", {
name: plexResult.server_name,
version: plexResult.version ?? "",
})}
</p>
{plexResult.media_check?.checked &&
(plexResult.media_check.ok ? (
<p className="text-[11px] text-emerald-500 mb-2">{t("config.plexMediaOk")}</p>
) : (
<p className="text-[11px] text-red-400 mb-2">
{t("config.plexMediaMissing", { path: plexResult.media_check.local_path ?? "" })}
</p>
))}
<p className="text-[11px] text-muted mb-1.5">{t("config.plexPickLibraries")}</p>
<div className="flex flex-col gap-1.5">
{plexResult.sections.map((s) => (
<label
key={s.key}
className="inline-flex items-center gap-2 text-sm cursor-pointer"
>
<input
type="checkbox"
checked={plexLibs.length === 0 || plexLibs.includes(s.key)}
onChange={() => togglePlexLib(s.key)}
className="accent-accent"
/>
<span>{s.title}</span>
<span className="text-[11px] text-muted">
{s.type === "movie" ? t("config.plexMovies") : t("config.plexShows")}
{s.count != null ? ` · ${s.count}` : ""}
</span>
</label>
))}
{activeGroup === "email" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.testEmailHint")}>
<button
onClick={() => testEmail.mutate()}
disabled={testEmail.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Send className={`w-4 h-4 ${testEmail.isPending ? "animate-pulse" : ""}`} />
{t("config.testEmail")}
</button>
</Tooltip>
</div>
)}
{activeGroup === "plex" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.plexTestHint")}>
<button
onClick={() => testPlex.mutate()}
disabled={testPlex.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Plug className={`w-4 h-4 ${testPlex.isPending ? "animate-pulse" : ""}`} />
{t("config.plexTest")}
</button>
</Tooltip>
{dirty && (
<p className="text-[11px] text-muted mt-2">{t("config.plexTestDirty")}</p>
)}
{plexResult && (
<div className="mt-3">
<p className="text-xs text-muted mb-2">
{t("config.plexConnected", {
name: plexResult.server_name,
version: plexResult.version ?? "",
})}
</p>
{plexResult.media_check?.checked &&
(plexResult.media_check.ok ? (
<p className="text-[11px] text-emerald-500 mb-2">
{t("config.plexMediaOk")}
</p>
) : (
<p className="text-[11px] text-red-400 mb-2">
{t("config.plexMediaMissing", {
path: plexResult.media_check.local_path ?? "",
})}
</p>
))}
<p className="text-[11px] text-muted mb-1.5">{t("config.plexPickLibraries")}</p>
<div className="flex flex-col gap-1.5">
{plexResult.sections.map((s) => (
<label
key={s.key}
className="inline-flex items-center gap-2 text-sm cursor-pointer"
>
<input
type="checkbox"
checked={plexLibs.length === 0 || plexLibs.includes(s.key)}
onChange={() => togglePlexLib(s.key)}
className="accent-accent"
/>
<span>{s.title}</span>
<span className="text-[11px] text-muted">
{s.type === "movie" ? t("config.plexMovies") : t("config.plexShows")}
{s.count != null ? ` · ${s.count}` : ""}
</span>
</label>
))}
</div>
<p className="text-[11px] text-muted mt-2">{t("config.plexPickHint")}</p>
</div>
<p className="text-[11px] text-muted mt-2">{t("config.plexPickHint")}</p>
</div>
)}
</div>
)}
</div>
)}
)}
</div>
)}
</div>
)}
<DraftSaveBar
variant="floating"
dirty={dirty}
state={saveState}
onSave={save}
onDiscard={discard}
labels={{
saved: t("config.saved"),
failed: t("config.saveFailed"),
unsaved: t("config.unsaved"),
discard: t("config.discard"),
save: t("config.save"),
saving: t("config.saving"),
}}
/>
</div>
<DraftSaveBar
variant="floating"
dirty={dirty}
state={saveState}
onSave={save}
onDiscard={discard}
labels={{
saved: t("config.saved"),
failed: t("config.saveFailed"),
unsaved: t("config.unsaved"),
discard: t("config.discard"),
save: t("config.save"),
saving: t("config.saving"),
}}
/>
</div>
</>
);
}
@@ -349,9 +369,7 @@ function Field({
<button
onClick={onResetField}
className={`inline-flex items-center gap-1 text-xs transition ${
item.secret && pendingSecretReset
? "text-accent"
: "text-muted hover:text-fg"
item.secret && pendingSecretReset ? "text-accent" : "text-muted hover:text-fg"
}`}
>
<RotateCcw className="w-3.5 h-3.5" />
@@ -362,4 +380,4 @@ function Field({
</div>
</div>
);
}
}
+6 -12
View File
@@ -10,7 +10,7 @@ import Modal from "./Modal";
// confirms, because the second dialog's Cancel/Escape/backdrop is then indistinguishable from
// a deliberate answer (the playlist delete used to read a dismissal as "delete here only" and
// went ahead and deleted).
export interface ConfirmOptions {
interface ConfirmOptions {
title?: string;
message: ReactNode;
confirmLabel?: string;
@@ -18,7 +18,7 @@ export interface ConfirmOptions {
danger?: boolean;
}
export interface Choice {
interface Choice {
id: string;
label: string;
danger?: boolean;
@@ -46,13 +46,11 @@ export function useConfirm(): ConfirmFn {
title: opts.title,
message: opts.message,
cancelLabel: opts.cancelLabel,
choices: [
{ id: "confirm", label: opts.confirmLabel ?? "", danger: opts.danger },
],
choices: [{ id: "confirm", label: opts.confirmLabel ?? "", danger: opts.danger }],
});
return picked === "confirm";
},
[ask]
[ask],
);
}
@@ -71,7 +69,7 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
const ask = useCallback<ChoiceFn>(
(opts) => new Promise<string | null>((resolve) => setState({ opts, resolve })),
[]
[],
);
function close(id: string | null) {
@@ -100,11 +98,7 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
side they wrap raggedly and a wrapped button reads as a second, lesser group — and
these are peers. Ordered least to most destructive, top to bottom. */}
<div
className={
multi
? "flex flex-col gap-2 mt-5"
: "flex justify-end gap-2 mt-5 flex-wrap"
}
className={multi ? "flex flex-col gap-2 mt-5" : "flex justify-end gap-2 mt-5 flex-wrap"}
>
<button
autoFocus={anyDanger}
+5 -9
View File
@@ -3,13 +3,7 @@ import { useTranslation } from "react-i18next";
import { sortRows, cycleSort, type SortState } from "../lib/columnSort";
import Pager, { hasPagerContent } from "./Pager";
import { PageToolbar } from "./PageShell";
import {
ColumnHead,
alignClass,
filterRows,
type Column,
type FilterMap,
} from "./tableHeader";
import { ColumnHead, alignClass, filterRows, type Column, type FilterMap } from "./tableHeader";
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
@@ -95,7 +89,7 @@ export default function DataTable<T>({
// the table keeps its own, seeded from and saved to persistKey.
const controlledSort = onSortChange !== undefined;
const [internalSort, setInternalSort] = useState<SortState>(initial.sort);
const sort = controlledSort ? sortProp ?? null : internalSort;
const sort = controlledSort ? (sortProp ?? null) : internalSort;
const [filters, setFilters] = useState<FilterMap>(initial.filters);
const [page, setPage] = useState(initial.page);
const [size, setSize] = useState(initial.size ?? pageSize);
@@ -253,7 +247,9 @@ export default function DataTable<T>({
</div>
{paged.length === 0 && (
<div className="text-muted py-8 text-center text-sm">{emptyText ?? t("datatable.empty")}</div>
<div className="text-muted py-8 text-center text-sm">
{emptyText ?? t("datatable.empty")}
</div>
)}
{controlsPosition === "bottom" && controls}
+1 -1
View File
@@ -55,7 +55,7 @@ export default function DownloadButton({
title={label}
className={clsx(
className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
(downloaded || inQueue) && "text-accent"
(downloaded || inQueue) && "text-accent",
)}
>
<Download className={clsx("w-4 h-4", inQueue && "animate-pulse")} />
+312 -163
View File
@@ -50,7 +50,15 @@ const STATUS_CLS: Record<string, string> = {
// "editing" (the phase-2 editor) reports a real % from ffmpeg's out_time, so it gets a bar.
const DOWNLOAD_PHASES = ["video", "audio", "editing"];
const PHASE_KEYS = [
"video", "audio", "editing", "merging", "audio_extract", "thumbnail", "sponsorblock", "metadata", "processing",
"video",
"audio",
"editing",
"merging",
"audio_extract",
"thumbnail",
"sponsorblock",
"metadata",
"processing",
];
function StatusPill({ job }: { job: DownloadJob }) {
@@ -58,7 +66,9 @@ function StatusPill({ job }: { job: DownloadJob }) {
// While downloading, show the concrete phase (video / audio / merging) so the user isn't
// confused by the bar resetting between the separate video and audio streams.
if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) {
return <span className="text-xs font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>;
return (
<span className="text-xs font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
);
}
const key = job.expired ? "expired" : job.status;
return (
@@ -108,7 +118,7 @@ function IconBtn({
title={title}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted transition",
danger ? "hover:text-red-400" : "hover:text-fg"
danger ? "hover:text-red-400" : "hover:text-fg",
)}
>
{children}
@@ -181,13 +191,7 @@ function ChannelLine({ job }: { job: DownloadJob }) {
return <div className="text-xs text-muted truncate mt-0.5">{name}</div>;
}
function DownloadRow({
job,
children,
}: {
job: DownloadJob;
children?: React.ReactNode;
}) {
function DownloadRow({ job, children }: { job: DownloadJob; children?: React.ReactNode }) {
const { t } = useTranslation();
const running = job.status === "running";
return (
@@ -205,12 +209,18 @@ function DownloadRow({
<ChannelLine job={job} />
<div className="flex items-center gap-2 mt-1 text-xs">
<StatusPill job={job} />
{job.asset?.size_bytes ? <span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span> : null}
{job.asset?.duration_s ? <span className="text-muted">· {formatDuration(job.asset.duration_s)}</span> : null}
{job.asset?.size_bytes ? (
<span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span>
) : null}
{job.asset?.duration_s ? (
<span className="text-muted">· {formatDuration(job.asset.duration_s)}</span>
) : null}
{job.error ? <span className="text-red-400 truncate">· {job.error}</span> : null}
</div>
{job.source_url && <SourceRef url={job.source_url} />}
{job.extra_links?.map((url) => <SourceRef key={url} url={url} />)}
{job.extra_links?.map((url) => (
<SourceRef key={url} url={url} />
))}
{running && (
<div className="mt-1.5">
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? (
@@ -221,7 +231,10 @@ function DownloadRow({
) : (
<>
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
<div className="h-full bg-accent transition-all" style={{ width: `${job.progress}%` }} />
<div
className="h-full bg-accent transition-all"
style={{ width: `${job.progress}%` }}
/>
</div>
<div className="text-[11px] text-muted mt-0.5 flex gap-2">
<span>{job.progress}%</span>
@@ -247,7 +260,9 @@ function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void
const qc = useQueryClient();
const [name, setName] = useState(job.display_name || job.asset?.title || "");
const [channel, setChannel] = useState(job.display_uploader || job.asset?.uploader || "");
const [channelUrl, setChannelUrl] = useState(job.display_uploader_url || job.asset?.uploader_url || "");
const [channelUrl, setChannelUrl] = useState(
job.display_uploader_url || job.asset?.uploader_url || "",
);
// Editable extra-link rows; keep one blank row so there's always somewhere to type.
const [links, setLinks] = useState<string[]>(() => [...(job.extra_links ?? []), ""]);
@@ -274,11 +289,20 @@ function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void
<div className="space-y-3">
<label className="block">
<span className="block text-sm font-medium mb-1">{t("downloads.edit.name")}</span>
<input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} autoFocus />
<input
value={name}
onChange={(e) => setName(e.target.value)}
className={inputCls}
autoFocus
/>
</label>
<label className="block">
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channel")}</span>
<input value={channel} onChange={(e) => setChannel(e.target.value)} className={inputCls} />
<input
value={channel}
onChange={(e) => setChannel(e.target.value)}
className={inputCls}
/>
</label>
<label className="block">
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channelUrl")}</span>
@@ -300,7 +324,11 @@ function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void
placeholder="https://…"
className={inputCls}
/>
<IconBtn onClick={() => removeLink(i)} title={t("downloads.edit.removeLink")} danger>
<IconBtn
onClick={() => removeLink(i)}
title={t("downloads.edit.removeLink")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</div>
@@ -338,7 +366,8 @@ function UsageBar() {
const usage = useLiveQuery(["download-usage"], api.downloadUsage, { intervalMs: 2000 });
const u = usage.data;
if (!u) return null;
const pct = u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100);
const pct =
u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100);
return (
<div className="rounded-xl glass-card p-3 mb-4">
<div className="flex justify-between text-sm mb-1.5">
@@ -346,12 +375,17 @@ function UsageBar() {
<span className="text-muted">
{formatBytes(u.footprint_bytes)}
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes)}`} ·{" "}
{u.unlimited ? t("downloads.usage.unlimited") : t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })}
{u.unlimited
? t("downloads.usage.unlimited")
: t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })}
</span>
</div>
{!u.unlimited && (
<div className="h-2 rounded-full bg-surface overflow-hidden">
<div className={clsx("h-full transition-all", pct > 90 ? "bg-red-400" : "bg-accent")} style={{ width: `${pct}%` }} />
<div
className={clsx("h-full transition-all", pct > 90 ? "bg-red-400" : "bg-accent")}
style={{ width: `${pct}%` }}
/>
</div>
)}
</div>
@@ -360,13 +394,38 @@ function UsageBar() {
// --- Admin system tab -----------------------------------------------------------------------
function QuotaModal({ userId, email, onClose }: { userId: number; email: string; onClose: () => void }) {
function QuotaModal({
userId,
email,
onClose,
}: {
userId: number;
email: string;
onClose: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const q = useQuery({ queryKey: ["dl-quota", userId], queryFn: () => api.adminGetDownloadQuota(userId) });
const [form, setForm] = useState<{ gb: number; jobs: number; conc: number; unlimited: boolean } | null>(null);
const q = useQuery({
queryKey: ["dl-quota", userId],
queryFn: () => api.adminGetDownloadQuota(userId),
});
const [form, setForm] = useState<{
gb: number;
jobs: number;
conc: number;
unlimited: boolean;
} | null>(null);
const cur = q.data;
const state = form ?? (cur ? { gb: Math.round((cur.max_bytes / GiB) * 10) / 10, jobs: cur.max_jobs, conc: cur.max_concurrent, unlimited: cur.unlimited } : null);
const state =
form ??
(cur
? {
gb: Math.round((cur.max_bytes / GiB) * 10) / 10,
jobs: cur.max_jobs,
conc: cur.max_concurrent,
unlimited: cur.unlimited,
}
: null);
const done = () => {
qc.invalidateQueries({ queryKey: ["dl-quota", userId] });
qc.invalidateQueries({ queryKey: ["admin-storage"] });
@@ -375,14 +434,17 @@ function QuotaModal({ userId, email, onClose }: { userId: number; email: string;
const save = useMutation({
mutationFn: () =>
api.adminSetDownloadQuota(userId, {
max_bytes: Math.round((state!.gb) * GiB),
max_bytes: Math.round(state!.gb * GiB),
max_jobs: state!.jobs,
max_concurrent: state!.conc,
unlimited: state!.unlimited,
}),
onSuccess: done,
});
const reset = useMutation({ mutationFn: () => api.adminResetDownloadQuota(userId), onSuccess: done });
const reset = useMutation({
mutationFn: () => api.adminResetDownloadQuota(userId),
onSuccess: done,
});
if (!state) return null;
const upd = (p: Partial<typeof state>) => setForm({ ...state, ...p });
return (
@@ -391,26 +453,53 @@ function QuotaModal({ userId, email, onClose }: { userId: number; email: string;
<div className="grid grid-cols-3 gap-3">
<label className="text-sm">
{t("downloads.admin.maxBytes")}
<input type="number" value={state.gb} onChange={(e) => upd({ gb: Number(e.target.value) })} className={inputCls} disabled={state.unlimited} />
<input
type="number"
value={state.gb}
onChange={(e) => upd({ gb: Number(e.target.value) })}
className={inputCls}
disabled={state.unlimited}
/>
</label>
<label className="text-sm">
{t("downloads.admin.maxJobs")}
<input type="number" value={state.jobs} onChange={(e) => upd({ jobs: Number(e.target.value) })} className={inputCls} />
<input
type="number"
value={state.jobs}
onChange={(e) => upd({ jobs: Number(e.target.value) })}
className={inputCls}
/>
</label>
<label className="text-sm">
{t("downloads.admin.maxConcurrent")}
<input type="number" value={state.conc} onChange={(e) => upd({ conc: Number(e.target.value) })} className={inputCls} />
<input
type="number"
value={state.conc}
onChange={(e) => upd({ conc: Number(e.target.value) })}
className={inputCls}
/>
</label>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={state.unlimited} onChange={(e) => upd({ unlimited: e.target.checked })} />
<input
type="checkbox"
checked={state.unlimited}
onChange={(e) => upd({ unlimited: e.target.checked })}
/>
{t("downloads.admin.unlimited")}
</label>
<div className="flex justify-between pt-1">
<button onClick={() => reset.mutate()} className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition">
<button
onClick={() => reset.mutate()}
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
>
{t("downloads.admin.reset")}
</button>
<button onClick={() => save.mutate()} disabled={save.isPending} className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition">
<button
onClick={() => save.mutate()}
disabled={save.isPending}
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
>
{t("downloads.admin.save")}
</button>
</div>
@@ -430,7 +519,10 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
const filtered = useMemo(() => {
const s = q.trim().toLowerCase();
const base = s
? list.filter((u) => u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s))
? list.filter(
(u) =>
u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s),
)
: list;
return base.slice(0, 8);
}, [q, list]);
@@ -482,7 +574,10 @@ function AdminSystem() {
{[
[t("downloads.admin.readyFiles"), String(s.ready_files)],
[t("downloads.admin.totalSize"), formatBytes(s.total_bytes)],
[t("downloads.admin.cap"), s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap")],
[
t("downloads.admin.cap"),
s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap"),
],
].map(([label, val]) => (
<div key={label} className="rounded-xl glass-card p-3">
<div className="text-xs text-muted">{label}</div>
@@ -501,7 +596,10 @@ function AdminSystem() {
</div>
<div className="space-y-1">
{s.per_user.map((u) => (
<div key={u.user_id} className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-card/40">
<div
key={u.user_id}
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-card/40"
>
<span className="flex-1 truncate">{u.email}</span>
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
<button
@@ -523,17 +621,26 @@ function AdminSystem() {
<div className="text-sm font-medium mb-2">{t("downloads.tabs.system")}</div>
<div className="space-y-1.5">
{(jobs.data ?? []).map((j) => (
<div key={j.id} className="flex items-center gap-3 text-sm px-3 py-1.5 rounded-lg bg-card/40">
<div
key={j.id}
className="flex items-center gap-3 text-sm px-3 py-1.5 rounded-lg bg-card/40"
>
<span className="flex-1 truncate">{jobTitle(j)}</span>
<span className="text-muted truncate w-40">{j.user_email}</span>
<StatusPill job={j} />
<span className="text-muted w-16 text-right">{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""}</span>
<span className="text-muted w-16 text-right">
{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""}
</span>
</div>
))}
{jobs.data && jobs.data.length === 0 && <div className="text-sm text-muted py-6 text-center">{t("downloads.empty.admin")}</div>}
{jobs.data && jobs.data.length === 0 && (
<div className="text-sm text-muted py-6 text-center">{t("downloads.empty.admin")}</div>
)}
</div>
{quotaFor && <QuotaModal userId={quotaFor.id} email={quotaFor.email} onClose={() => setQuotaFor(null)} />}
{quotaFor && (
<QuotaModal userId={quotaFor.id} email={quotaFor.email} onClose={() => setQuotaFor(null)} />
)}
</div>
);
}
@@ -554,7 +661,11 @@ export default function DownloadCenter() {
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
const jobsQ = useLiveQuery(["downloads"], api.downloads, { intervalMs: 2000 });
const sharedQ = useQuery({ queryKey: ["downloads-shared"], queryFn: api.downloadsShared, enabled: tab === "shared" });
const sharedQ = useQuery({
queryKey: ["downloads-shared"],
queryFn: api.downloadsShared,
enabled: tab === "shared",
});
const jobs = jobsQ.data ?? [];
const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status));
const library = jobs.filter((j) => j.status === "done");
@@ -562,10 +673,13 @@ export default function DownloadCenter() {
const invalidate = () => invalidateDownloads(qc);
const act = useMutation({
mutationFn: ({ id, op }: { id: number; op: "pause" | "resume" | "cancel" | "delete" }) =>
op === "pause" ? api.pauseDownload(id)
: op === "resume" ? api.resumeDownload(id)
: op === "cancel" ? api.cancelDownload(id)
: api.deleteDownload(id),
op === "pause"
? api.pauseDownload(id)
: op === "resume"
? api.resumeDownload(id)
: op === "cancel"
? api.cancelDownload(id)
: api.deleteDownload(id),
onSuccess: invalidate,
});
@@ -607,147 +721,182 @@ export default function DownloadCenter() {
{/* Page title + subtitle + tabs are fixed chrome; the tab content scrolls under them. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-4xl w-full mx-auto">
<div className="flex items-center justify-between gap-3 mb-1">
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
<button
onClick={() => setManage(true)}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
>
<Settings2 className="w-4 h-4" /> {t("downloads.page.manage")}
</button>
</div>
<p className="text-sm text-muted mb-4">{t("downloads.page.subtitle")}</p>
<div className="flex items-center justify-between gap-3 mb-1">
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
<button
onClick={() => setManage(true)}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
>
<Settings2 className="w-4 h-4" /> {t("downloads.page.manage")}
</button>
</div>
<p className="text-sm text-muted mb-4">{t("downloads.page.subtitle")}</p>
<Tabs tabs={tabs} active={tab} onChange={setTab} />
<Tabs tabs={tabs} active={tab} onChange={setTab} />
</div>
</PageToolbar>
<div className="px-4 pb-4 pt-3 max-w-4xl w-full mx-auto">
{tab === "queue" && (
<>
<div className="flex gap-2 mb-4">
<input
value={addUrl}
onChange={(e) => setAddUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())}
placeholder={t("downloads.page.addUrl")}
className={inputCls}
/>
<button
onClick={() => addUrl.trim() && setAddSource(addUrl.trim())}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 transition shrink-0"
>
<Plus className="w-4 h-4" /> {t("downloads.page.add")}
</button>
</div>
<div className="space-y-2">
{queue.map((job) => (
<DownloadRow key={job.id} job={job}>
{(job.status === "queued" || job.status === "running") && (
<IconBtn onClick={() => act.mutate({ id: job.id, op: "pause" })} title={t("downloads.actions.pause")}>
<Pause className="w-4 h-4" />
{tab === "queue" && (
<>
<div className="flex gap-2 mb-4">
<input
value={addUrl}
onChange={(e) => setAddUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())}
placeholder={t("downloads.page.addUrl")}
className={inputCls}
/>
<button
onClick={() => addUrl.trim() && setAddSource(addUrl.trim())}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 transition shrink-0"
>
<Plus className="w-4 h-4" /> {t("downloads.page.add")}
</button>
</div>
<div className="space-y-2">
{queue.map((job) => (
<DownloadRow key={job.id} job={job}>
{(job.status === "queued" || job.status === "running") && (
<IconBtn
onClick={() => act.mutate({ id: job.id, op: "pause" })}
title={t("downloads.actions.pause")}
>
<Pause className="w-4 h-4" />
</IconBtn>
)}
{(job.status === "paused" || job.status === "error") && (
<IconBtn
onClick={() => act.mutate({ id: job.id, op: "resume" })}
title={t(
job.status === "error"
? "downloads.actions.retry"
: "downloads.actions.resume",
)}
>
<Play className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.cancelTitle"),
t("downloads.confirm.cancelBody"),
() => act.mutate({ id: job.id, op: "cancel" }),
)
}
title={t("downloads.actions.cancel")}
danger
>
<Ban className="w-4 h-4" />
</IconBtn>
)}
{(job.status === "paused" || job.status === "error") && (
<IconBtn onClick={() => act.mutate({ id: job.id, op: "resume" })} title={t(job.status === "error" ? "downloads.actions.retry" : "downloads.actions.resume")}>
<Play className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() => confirmThen(t("downloads.confirm.cancelTitle"), t("downloads.confirm.cancelBody"), () => act.mutate({ id: job.id, op: "cancel" }))}
title={t("downloads.actions.cancel")}
danger
>
<Ban className="w-4 h-4" />
</IconBtn>
</DownloadRow>
))}
{queue.length === 0 && <div className="text-sm text-muted py-10 text-center">{t("downloads.empty.queue")}</div>}
</div>
</>
)}
</DownloadRow>
))}
{queue.length === 0 && (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.queue")}
</div>
)}
</div>
</>
)}
{tab === "done" && (
<>
<UsageBar />
{tab === "done" && (
<>
<UsageBar />
<div className="space-y-2">
{library.map((job) => (
<DownloadRow key={job.id} job={job}>
{saveBtn(job)}
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() => setMetaJob(job)}
title={t("downloads.actions.editDetails")}
>
<Pencil className="w-4 h-4" />
</IconBtn>
<IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}>
<Share2 className="w-4 h-4" />
</IconBtn>
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.deleteTitle"),
t("downloads.confirm.deleteBody"),
() => act.mutate({ id: job.id, op: "delete" }),
)
}
title={t("downloads.actions.delete")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</DownloadRow>
))}
{library.length === 0 && (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.done")}
</div>
)}
</div>
</>
)}
{tab === "shared" && (
<div className="space-y-2">
{library.map((job) => (
{(sharedQ.data ?? []).map((job) => (
<DownloadRow key={job.id} job={job}>
{saveBtn(job)}
{job.can_download && saveBtn(job)}
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
</IconBtn>
)}
<IconBtn onClick={() => setMetaJob(job)} title={t("downloads.actions.editDetails")}>
<Pencil className="w-4 h-4" />
</IconBtn>
<IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}>
<Share2 className="w-4 h-4" />
</IconBtn>
<IconBtn
onClick={() => confirmThen(t("downloads.confirm.deleteTitle"), t("downloads.confirm.deleteBody"), () => act.mutate({ id: job.id, op: "delete" }))}
title={t("downloads.actions.delete")}
onClick={() =>
confirmThen(
t("downloads.confirm.removeSharedTitle"),
t("downloads.confirm.removeSharedBody"),
() => removeShared.mutate(job.id),
)
}
title={t("downloads.actions.removeShared")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</DownloadRow>
))}
{library.length === 0 && <div className="text-sm text-muted py-10 text-center">{t("downloads.empty.done")}</div>}
{sharedQ.data && sharedQ.data.length === 0 && (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.shared")}
</div>
)}
</div>
</>
)}
{tab === "shared" && (
<div className="space-y-2">
{(sharedQ.data ?? []).map((job) => (
<DownloadRow key={job.id} job={job}>
{job.can_download && saveBtn(job)}
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.removeSharedTitle"),
t("downloads.confirm.removeSharedBody"),
() => removeShared.mutate(job.id)
)
}
title={t("downloads.actions.removeShared")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</DownloadRow>
))}
{sharedQ.data && sharedQ.data.length === 0 && (
<div className="text-sm text-muted py-10 text-center">{t("downloads.empty.shared")}</div>
)}
</div>
)}
{tab === "system" && me.role === "admin" && <AdminSystem />}
<Suspense fallback={null}>
{addSource && (
<DownloadDialog
source={addSource}
onClose={() => {
setAddSource(null);
setAddUrl("");
}}
/>
)}
{manage && <ProfileEditor onClose={() => setManage(false)} />}
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
</Suspense>
</div>
{tab === "system" && me.role === "admin" && <AdminSystem />}
<Suspense fallback={null}>
{addSource && (
<DownloadDialog
source={addSource}
onClose={() => {
setAddSource(null);
setAddUrl("");
}}
/>
)}
{manage && <ProfileEditor onClose={() => setManage(false)} />}
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
</Suspense>
</div>
</>
);
}
+3 -1
View File
@@ -32,7 +32,9 @@ export default class ErrorBoundary extends Component<Props, State> {
return (
<div className="min-h-screen grid place-items-center text-muted p-6 text-center">
<div>
<div className="text-lg font-semibold text-fg mb-1">{i18n.t("errors.boundary.title")}</div>
<div className="text-lg font-semibold text-fg mb-1">
{i18n.t("errors.boundary.title")}
</div>
<div className="text-sm mb-3">{i18n.t("errors.boundary.subtitle")}</div>
<button
onClick={() => location.reload()}
+120 -95
View File
@@ -1,6 +1,11 @@
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import {
keepPreviousData,
useInfiniteQuery,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
ArrowDown,
ArrowUp,
@@ -123,13 +128,13 @@ export default function Feed({
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
// The open player: which video and where to start (null = resume from saved position).
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
null
null,
);
const qc = useQueryClient();
const openVideo = useCallback(
(video: Video, startAt: number | null = null) => setActiveVideo({ video, startAt }),
[]
[],
);
const ytActive = !!ytSearch;
@@ -158,7 +163,8 @@ export default function Feed({
// revisited search from re-spending.
const ytQuery = useInfiniteQuery({
queryKey: ["yt-search", ytSearch, ytCount],
queryFn: ({ pageParam }) => api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount),
queryFn: ({ pageParam }) =>
api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.next_cursor ?? undefined,
enabled: ytActive,
@@ -282,10 +288,10 @@ export default function Feed({
const next = { ...o };
delete next[id];
return next;
})
}),
);
},
[qc]
[qc],
);
const onResetState = useCallback(
@@ -301,10 +307,10 @@ export default function Feed({
const next = { ...o };
delete next[id];
return next;
})
}),
);
},
[qc]
[qc],
);
const onToggleSave = useCallback(
@@ -317,15 +323,21 @@ export default function Feed({
})
.catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved })));
},
[qc]
[qc],
);
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
loadedRef.current = loaded;
const withOverrides = (list: Video[]): Video[] =>
list
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v));
.map((v) => {
const status = overrides[v.id]; // read once so the narrowing sticks
return status ? { ...v, status } : v;
})
.map((v) => {
const saved = savedOverrides[v.id];
return saved !== undefined ? { ...v, saved } : v;
});
const merged: Video[] = withOverrides(loaded);
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
// The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the
@@ -410,7 +422,9 @@ export default function Feed({
const ids = ytItems.map((v) => v.id);
api
.clearSearch(ids)
.then((r) => notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }))
.then((r) =>
notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }),
)
.catch(() => {});
setFilters({ ...filters, librarySource: "organic", sort: "newest" });
onExitYtSearch();
@@ -485,40 +499,40 @@ export default function Feed({
// wizard; real users get the onboarding prompt. No toolbar (sort/type are meaningless).
if (items.length === 0 && !canRead && filters.scope === "my")
return (
<div className="p-8 grid place-items-center text-center">
<div className="max-w-sm">
<h2 className="text-lg font-semibold">
{isDemo ? t("feed.demoEmptyTitle") : t("feed.emptyTitle")}
</h2>
<p className="text-sm text-muted mt-2 mb-4">
{isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")}
</p>
{isDemo ? (
<div className="p-8 grid place-items-center text-center">
<div className="max-w-sm">
<h2 className="text-lg font-semibold">
{isDemo ? t("feed.demoEmptyTitle") : t("feed.emptyTitle")}
</h2>
<p className="text-sm text-muted mt-2 mb-4">
{isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")}
</p>
{isDemo ? (
<button
onClick={() => setFilters({ ...filters, scope: "all" })}
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
{t("feed.browseLibrary")}
</button>
) : (
<>
<button
onClick={() => setFilters({ ...filters, scope: "all" })}
onClick={onOpenWizard}
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
{t("feed.browseLibrary")}
{t("feed.setUp")}
</button>
) : (
<>
<button
onClick={onOpenWizard}
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
{t("feed.setUp")}
</button>
<button
onClick={() => setFilters({ ...filters, scope: "all" })}
className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
>
{t("feed.browseShared")}
</button>
</>
)}
</div>
<button
onClick={() => setFilters({ ...filters, scope: "all" })}
className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
>
{t("feed.browseShared")}
</button>
</>
)}
</div>
);
</div>
);
const { key: sortKey, dir: sortDir } = parseSort(filters.sort);
@@ -597,61 +611,72 @@ export default function Feed({
</span>
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-muted">{t("feed.sortLabel")}</span>
<select
value={sortKey}
onChange={(e) => {
const key = e.target.value as SortKey;
const dir = DIRECTIONLESS.includes(key)
? "desc"
: SORT_DEFAULT_DIR[key as Exclude<SortKey, "shuffle" | "relevance">];
setFilters({
...filters,
sort: buildSort(key, dir),
seed: key === "shuffle" ? rollSeed() : undefined,
});
}}
aria-label={t("feed.sortLabel")}
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
>
{/* Relevance only ranks meaningfully when there's a search term — offer it then. */}
{(filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys).map((k) => (
<option key={k} value={k}>
{t("feed.sortKey." + k)}
</option>
))}
</select>
{!DIRECTIONLESS.includes(sortKey) && (
<button
onClick={() =>
setFilters({ ...filters, sort: buildSort(sortKey, sortDir === "asc" ? "desc" : "asc") })
}
title={sortDir === "asc" ? t("feed.dirAsc") : t("feed.dirDesc")}
aria-label={sortDir === "asc" ? t("feed.dirAsc") : t("feed.dirDesc")}
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
<select
value={sortKey}
onChange={(e) => {
const key = e.target.value as SortKey;
const dir = DIRECTIONLESS.includes(key)
? "desc"
: SORT_DEFAULT_DIR[key as Exclude<SortKey, "shuffle" | "relevance">];
setFilters({
...filters,
sort: buildSort(key, dir),
seed: key === "shuffle" ? rollSeed() : undefined,
});
}}
aria-label={t("feed.sortLabel")}
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
>
{sortDir === "asc" ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
</button>
)}
{sortKey === "shuffle" && (
<button
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
title={t("sidebar.reshuffle")}
aria-label={t("sidebar.reshuffle")}
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
>
<RefreshCw className="w-4 h-4" />
</button>
)}
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
<ViewSwitcher
value={view}
options={FEED_VIEWS.map((id) => ({ id, label: t("feed.view." + id), icon: VIEW_ICON[id] }))}
onChange={setView}
label={t("feed.viewLabel")}
// This toolbar is packed — show/content chips, source, count, sort — so the mode's name
// only earns its place once the window is wide.
labelClassName="hidden xl:inline"
/>
{/* Relevance only ranks meaningfully when there's a search term — offer it then. */}
{(filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys).map((k) => (
<option key={k} value={k}>
{t("feed.sortKey." + k)}
</option>
))}
</select>
{!DIRECTIONLESS.includes(sortKey) && (
<button
onClick={() =>
setFilters({
...filters,
sort: buildSort(sortKey, sortDir === "asc" ? "desc" : "asc"),
})
}
title={sortDir === "asc" ? t("feed.dirAsc") : t("feed.dirDesc")}
aria-label={sortDir === "asc" ? t("feed.dirAsc") : t("feed.dirDesc")}
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
>
{sortDir === "asc" ? (
<ArrowUp className="w-4 h-4" />
) : (
<ArrowDown className="w-4 h-4" />
)}
</button>
)}
{sortKey === "shuffle" && (
<button
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
title={t("sidebar.reshuffle")}
aria-label={t("sidebar.reshuffle")}
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
>
<RefreshCw className="w-4 h-4" />
</button>
)}
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
<ViewSwitcher
value={view}
options={FEED_VIEWS.map((id) => ({
id,
label: t("feed.view." + id),
icon: VIEW_ICON[id],
}))}
onChange={setView}
label={t("feed.viewLabel")}
// This toolbar is packed — show/content chips, source, count, sort — so the mode's name
// only earns its place once the window is wide.
labelClassName="hidden xl:inline"
/>
</div>
</div>
</div>
+3 -1
View File
@@ -36,7 +36,9 @@ export function useFeedViewActions(): FeedViewActions {
}
export function FeedViewProvider({ children }: { children: ReactNode }) {
const [view, setViewState] = useState<FeedView>(() => parseFeedView(readAccount(LS.feedView, null)));
const [view, setViewState] = useState<FeedView>(() =>
parseFeedView(readAccount(LS.feedView, null)),
);
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
const { data: me } = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false });
+77 -14
View File
@@ -28,11 +28,51 @@ type Slider = {
const SLIDERS: Slider[] = [
{ k: "--glass-blur", label: "Blur radius", min: 0, max: 40, step: 1, unit: "px", def: 10 },
{ k: "--glass-saturate", label: "Saturation", min: 1, max: 2.5, step: 0.05, unit: "", def: 1.6 },
{ k: "--glass-dark-bright", label: "Dark brightness", min: 1, max: 2, step: 0.05, unit: "", def: 1.2 },
{ k: "--glass-surface-alpha", label: "Panel opacity", min: 40, max: 100, step: 1, unit: "%", def: 50 },
{ k: "--glass-card-alpha", label: "Card opacity", min: 40, max: 100, step: 1, unit: "%", def: 60 },
{ k: "--glass-menu-alpha", label: "Menu opacity", min: 40, max: 100, step: 1, unit: "%", def: 66 },
{ k: "--glass-border-alpha", label: "Border opacity", min: 20, max: 100, step: 1, unit: "%", def: 80 },
{
k: "--glass-dark-bright",
label: "Dark brightness",
min: 1,
max: 2,
step: 0.05,
unit: "",
def: 1.2,
},
{
k: "--glass-surface-alpha",
label: "Panel opacity",
min: 40,
max: 100,
step: 1,
unit: "%",
def: 50,
},
{
k: "--glass-card-alpha",
label: "Card opacity",
min: 40,
max: 100,
step: 1,
unit: "%",
def: 60,
},
{
k: "--glass-menu-alpha",
label: "Menu opacity",
min: 40,
max: 100,
step: 1,
unit: "%",
def: 66,
},
{
k: "--glass-border-alpha",
label: "Border opacity",
min: 20,
max: 100,
step: 1,
unit: "%",
def: 80,
},
{ k: "--glass-edge", label: "Edge-light rim", min: 0, max: 60, step: 1, unit: "%", def: 25 },
{ k: "--glass-inset", label: "Top highlight", min: 0, max: 40, step: 1, unit: "%", def: 15 },
{ k: "--glass-scrim", label: "Under-text scrim", min: 0, max: 85, step: 1, unit: "%", def: 30 },
@@ -71,7 +111,12 @@ const PRESETS: { id: string; name: string; hint: string; over: Record<string, nu
id: "readable",
name: "Max readable",
hint: "Solid + strong scrim",
over: { "--glass-edge": 42, "--glass-scrim": 45, "--glass-surface-alpha": 96, "--glass-card-alpha": 96 },
over: {
"--glass-edge": 42,
"--glass-scrim": 45,
"--glass-surface-alpha": 96,
"--glass-card-alpha": 96,
},
},
];
@@ -222,7 +267,10 @@ export default function GlassTuner() {
return (
<Overlay>
<div className="fixed right-0 top-24 z-tuner flex items-start" style={{ pointerEvents: "none" }}>
<div
className="fixed right-0 top-24 z-tuner flex items-start"
style={{ pointerEvents: "none" }}
>
{!open && (
<button
onClick={() => persistOpen(true)}
@@ -253,7 +301,9 @@ export default function GlassTuner() {
<div className="overflow-y-auto px-3 py-3 flex flex-col gap-4 text-sm">
{/* Treatment presets */}
<div className="flex flex-col gap-1.5">
<div className="text-[10px] uppercase tracking-wider text-muted font-semibold">Dark treatment</div>
<div className="text-[10px] uppercase tracking-wider text-muted font-semibold">
Dark treatment
</div>
<div className="grid grid-cols-2 gap-1.5">
{PRESETS.map((p) => (
<button
@@ -276,7 +326,9 @@ export default function GlassTuner() {
key={t}
onClick={() => setTab(t)}
className={`flex-1 rounded-lg px-2 py-1 text-xs capitalize ${
tab === t ? "bg-accent text-[color:var(--accent-fg)] font-semibold" : "glass-card text-muted"
tab === t
? "bg-accent text-[color:var(--accent-fg)] font-semibold"
: "glass-card text-muted"
}`}
>
{t}
@@ -287,7 +339,12 @@ export default function GlassTuner() {
{tab === "glass" && (
<div className="flex flex-col gap-2.5">
{SLIDERS.map((s) => (
<Ctl key={s.k} s={s} value={vars[s.k]} onChange={(v) => setVar(s.k, v)} />
<Ctl
key={s.k}
s={s}
value={vars[s.k] ?? s.def}
onChange={(v) => setVar(s.k, v)}
/>
))}
</div>
)}
@@ -295,7 +352,8 @@ export default function GlassTuner() {
{tab === "palette" && (
<div className="flex flex-col gap-2">
<p className="text-[10px] text-muted leading-relaxed">
Overrides the live scheme (inline on &lt;html&gt;). Reset clears these back to the scheme.
Overrides the live scheme (inline on &lt;html&gt;). Reset clears these back to
the scheme.
</p>
{PALETTE.map((p) => (
<label key={p.k} className="flex items-center gap-2 text-xs text-fg">
@@ -314,7 +372,9 @@ export default function GlassTuner() {
{/* Export */}
<div className="flex flex-col gap-1.5 pt-1 border-t border-border/50">
<div className="text-[10px] uppercase tracking-wider text-muted font-semibold">Export bake in</div>
<div className="text-[10px] uppercase tracking-wider text-muted font-semibold">
Export bake in
</div>
<button
onClick={syncToTheme}
title="Load the values of the theme you're currently viewing (light or dark) into the sliders"
@@ -329,7 +389,10 @@ export default function GlassTuner() {
>
{copied ? "Copied ✓" : "Copy CSS"}
</button>
<button onClick={reset} className="glass-card glass-hover rounded-lg px-3 py-1.5 text-xs text-fg">
<button
onClick={reset}
className="glass-card glass-hover rounded-lg px-3 py-1.5 text-xs text-fg"
>
Reset
</button>
</div>
@@ -340,7 +403,7 @@ export default function GlassTuner() {
</div>
</div>
)}
</div>
</div>
</Overlay>
);
}
+15 -4
View File
@@ -11,7 +11,11 @@ import * as e2ee from "../lib/e2ee";
export default function KeyGate({ meId, compact = false }: { meId: number; compact?: boolean }) {
const { t } = useTranslation();
const qc = useQueryClient();
const keyQ = useQuery({ queryKey: ["message-key"], queryFn: api.messageMyKey, staleTime: 60_000 });
const keyQ = useQuery({
queryKey: ["message-key"],
queryFn: api.messageMyKey,
staleTime: 60_000,
});
const configured = keyQ.data?.configured ?? false;
const [pass, setPass] = useState("");
@@ -39,15 +43,22 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
}
}
if (keyQ.isLoading) return <div className="p-6 text-center text-muted text-sm">{t("common.loading")}</div>;
if (keyQ.isLoading)
return <div className="p-6 text-center text-muted text-sm">{t("common.loading")}</div>;
return (
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
<div className="flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-accent shrink-0" />
<div className="font-semibold text-sm">{configured ? t("messages.unlockTitle") : t("messages.setupTitle")}</div>
<div className="font-semibold text-sm">
{configured ? t("messages.unlockTitle") : t("messages.setupTitle")}
</div>
</div>
{!compact && <p className="text-sm text-muted">{configured ? t("messages.unlockBody") : t("messages.setupBody")}</p>}
{!compact && (
<p className="text-sm text-muted">
{configured ? t("messages.unlockBody") : t("messages.setupBody")}
</p>
)}
<input
type="password"
value={pass}
+6 -6
View File
@@ -64,10 +64,7 @@ export default function LanguageSwitcher({
}, [variant, open]);
const menu = (
<div
ref={panelRef}
className="glass-menu w-40 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
<div ref={panelRef} className="glass-menu w-40 rounded-xl p-1.5 animate-[popIn_0.16s_ease]">
{LANGUAGES.map((l) => (
<button
key={l.code}
@@ -103,10 +100,13 @@ export default function LanguageSwitcher({
</button>
{open &&
createPortal(
<div className="z-rail" style={{ position: "fixed", left: pos.left, bottom: pos.bottom }}>
<div
className="z-rail"
style={{ position: "fixed", left: pos.left, bottom: pos.bottom }}
>
{menu}
</div>,
document.body
document.body,
)}
</>
);
+80 -18
View File
@@ -13,7 +13,10 @@ import Avatar from "./Avatar";
import KeyGate from "./KeyGate";
import ChatThread from "./ChatThread";
type View = "list" | "new" | { partnerId: number; name?: string; avatar?: string | null; system?: boolean };
type View =
| "list"
| "new"
| { partnerId: number; name?: string; avatar?: string | null; system?: boolean };
export default function Messages({ meId }: { meId: number }) {
// Sub-views live in browser history, so Back returns to the conversation list before leaving
@@ -35,12 +38,24 @@ export default function Messages({ meId }: { meId: number }) {
);
}
if (view === "new") {
return <Directory onPick={(u) => open({ partnerId: u.id, name: u.name, avatar: u.avatar_url })} onBack={back} />;
return (
<Directory
onPick={(u) => open({ partnerId: u.id, name: u.name, avatar: u.avatar_url })}
onBack={back}
/>
);
}
return (
<ConversationList
meId={meId}
onOpen={(c) => open({ partnerId: c.partner.id, name: c.partner.name, avatar: c.partner.avatar_url, system: c.partner.id === SYSTEM_ID })}
onOpen={(c) =>
open({
partnerId: c.partner.id,
name: c.partner.name,
avatar: c.partner.avatar_url,
system: c.partner.id === SYSTEM_ID,
})
}
onNew={() => open("new")}
/>
);
@@ -58,7 +73,9 @@ function ConversationList({
const { t } = useTranslation();
const keyState = useKeyState();
const ready = keyState === "ready";
const q = useLiveQuery<{ items: Conversation[] }>(["conversations"], api.conversations, { intervalMs: POLL_MS });
const q = useLiveQuery<{ items: Conversation[] }>(["conversations"], api.conversations, {
intervalMs: POLL_MS,
});
const items = q.data?.items ?? [];
const [previews, setPreviews] = useState<Record<number, string>>({});
@@ -99,7 +116,9 @@ function ConversationList({
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.conversations")}</div>
<div className="text-xs uppercase tracking-wide text-muted">
{t("messages.conversations")}
</div>
{ready && (
<button
onClick={onNew}
@@ -120,16 +139,36 @@ function ConversationList({
{items.map((c) => {
const isSystem = c.partner.id === SYSTEM_ID;
return (
<div key={c.partner.id} className="group flex items-center gap-1 rounded-xl hover:bg-card transition">
<button onClick={() => onOpen(c)} className="flex items-center gap-3 p-2.5 text-left min-w-0 flex-1">
<Avatar src={c.partner.avatar_url} fallback={c.partner.name} className="w-10 h-10 rounded-full shrink-0 text-sm" />
<div
key={c.partner.id}
className="group flex items-center gap-1 rounded-xl hover:bg-card transition"
>
<button
onClick={() => onOpen(c)}
className="flex items-center gap-3 p-2.5 text-left min-w-0 flex-1"
>
<Avatar
src={c.partner.avatar_url}
fallback={c.partner.name}
className="w-10 h-10 rounded-full shrink-0 text-sm"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={`truncate ${c.unread > 0 ? "font-semibold" : "font-medium"}`}>{c.partner.name}</span>
<span className="ml-auto text-[11px] text-muted shrink-0">{relativeTime(c.last_message.created_at)}</span>
<span
className={`truncate ${c.unread > 0 ? "font-semibold" : "font-medium"}`}
>
{c.partner.name}
</span>
<span className="ml-auto text-[11px] text-muted shrink-0">
{relativeTime(c.last_message.created_at)}
</span>
</div>
<div className="flex items-center gap-2">
<span className={`truncate text-sm ${c.unread > 0 ? "text-fg" : "text-muted"}`}>{previewText(c)}</span>
<span
className={`truncate text-sm ${c.unread > 0 ? "text-fg" : "text-muted"}`}
>
{previewText(c)}
</span>
{c.unread > 0 && (
<span className="ml-auto shrink-0 min-w-5 h-5 px-1.5 grid place-items-center rounded-full bg-accent text-accent-fg text-[11px] font-semibold">
{c.unread}
@@ -161,11 +200,16 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
const { t } = useTranslation();
const [filter, setFilter] = useState("");
const q = useQuery({ queryKey: ["message-users"], queryFn: api.messageUsers });
const users = (q.data ?? []).filter((u) => u.name.toLowerCase().includes(filter.trim().toLowerCase()));
const users = (q.data ?? []).filter((u) =>
u.name.toLowerCase().includes(filter.trim().toLowerCase()),
);
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<button onClick={onBack} className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition">
<button
onClick={onBack}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<ArrowLeft className="w-4 h-4" />
</button>
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.newTitle")}</div>
@@ -179,7 +223,9 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
{q.isLoading ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
) : users.length === 0 ? (
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.noPeople")}</div>
<div className="glass rounded-2xl p-10 text-center text-muted">
{t("messages.noPeople")}
</div>
) : (
<div className="flex flex-col gap-1">
{users.map((u) => (
@@ -188,7 +234,11 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
onClick={() => onPick(u)}
className="flex items-center gap-3 p-2.5 rounded-xl text-left hover:bg-card transition"
>
<Avatar src={u.avatar_url} fallback={u.name} className="w-9 h-9 rounded-full shrink-0 text-sm" />
<Avatar
src={u.avatar_url}
fallback={u.name}
className="w-9 h-9 rounded-full shrink-0 text-sm"
/>
<span className="truncate font-medium">{u.name}</span>
</button>
))}
@@ -219,12 +269,24 @@ 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 onClick={onBack} className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition">
<button
onClick={onBack}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<ArrowLeft className="w-4 h-4" />
</button>
<Avatar src={seedAvatar} fallback={name} className="w-9 h-9 rounded-full shrink-0 text-sm" />
<Avatar
src={seedAvatar}
fallback={name}
className="w-9 h-9 rounded-full shrink-0 text-sm"
/>
<span className="font-semibold truncate">{name}</span>
{!isSystem && <ShieldCheck className="w-4 h-4 text-muted shrink-0" aria-label={t("messages.encryptedHint")} />}
{!isSystem && (
<ShieldCheck
className="w-4 h-4 text-muted shrink-0"
aria-label={t("messages.encryptedHint")}
/>
)}
<div className="flex-1" />
{!isSystem && ready && (
<button
+1 -1
View File
@@ -78,6 +78,6 @@ export default function Modal({
<div className="px-5 pb-5">{children}</div>
</div>
</div>,
document.body
document.body,
);
}
+176 -176
View File
@@ -138,21 +138,18 @@ export default function NavSidebar({
// Durable, server-backed unread count for the inbox badge. Polled live (pauses when the
// tab is hidden); coexists with the client-side transient bell at the bottom of the rail.
const unreadQuery = useLiveQuery(
["notif-unread"],
api.notificationUnreadCount,
{ intervalMs: 30000 }
);
const unreadQuery = useLiveQuery(["notif-unread"], api.notificationUnreadCount, {
intervalMs: 30000,
});
// One indicator for both layers: durable server notifications + the client-side transient
// events (the former separate bell is now folded into the inbox page).
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
const unread = (unreadQuery.data?.count ?? 0) + clientUnread;
// Direct messages are their own module with their own unread badge (demo can't message).
const msgUnreadQuery = useLiveQuery(
["message-unread"],
api.messageUnreadCount,
{ intervalMs: 30000, enabled: !me.is_demo }
);
const msgUnreadQuery = useLiveQuery(["message-unread"], api.messageUnreadCount, {
intervalMs: 30000,
enabled: !me.is_demo,
});
const msgUnread = msgUnreadQuery.data?.count ?? 0;
// Download center badge = items still working (queued/running/paused). Reuses the same
// lightweight per-user index the feed cards poll, so no extra request shape.
@@ -184,11 +181,14 @@ export default function NavSidebar({
const sys = new Set<Page>(SYSTEM_PAGES);
const order = moduleOrder(me);
// User-facing content modules vs. admin/system modules, separated by a divider in the rail.
const userItems: NavItem[] = order.filter((p) => !sys.has(p)).map((p) => ({ page: p, ...META(p) }));
const systemItems: NavItem[] = order.filter((p) => sys.has(p)).map((p) => ({ page: p, ...META(p) }));
const userItems: NavItem[] = order
.filter((p) => !sys.has(p))
.map((p) => ({ page: p, ...META(p) }));
const systemItems: NavItem[] = order
.filter((p) => sys.has(p))
.map((p) => ({ page: p, ...META(p) }));
const rowBase =
"w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
const rowBase = "w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
const name = me.display_name ?? me.email.split("@")[0];
// Role is surfaced as a plain title tooltip on the account row (no visual badge). Demo isn't a
// real role (it's a flag on a `user` row), so surface it first.
@@ -256,183 +256,183 @@ export default function NavSidebar({
}`}
aria-label={t("nav.primary")}
>
<div className={`flex items-center mb-3 ${slim ? "justify-center" : "justify-between"}`}>
{!slim && (
<div className={`flex items-center mb-3 ${slim ? "justify-center" : "justify-between"}`}>
{!slim && (
<button
onClick={() => setPage("feed")}
className="text-lg font-bold tracking-tight select-none cursor-pointer rounded-md -mx-1 px-1 hover:bg-card hover:opacity-90 transition"
title={t("header.feed")}
>
Sift<span className="text-accent">lode</span>
</button>
)}
<button
onClick={() => setPage("feed")}
className="text-lg font-bold tracking-tight select-none cursor-pointer rounded-md -mx-1 px-1 hover:bg-card hover:opacity-90 transition"
title={t("header.feed")}
onClick={onToggleCollapse}
title={pinned ? t("nav.unpin") : t("nav.pin")}
aria-label={pinned ? t("nav.unpin") : t("nav.pin")}
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
Sift<span className="text-accent">lode</span>
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
</button>
)}
<button
onClick={onToggleCollapse}
title={pinned ? t("nav.unpin") : t("nav.pin")}
aria-label={pinned ? t("nav.unpin") : t("nav.pin")}
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
</button>
</div>
</div>
<div
ref={listFade.ref}
className="flex-1 min-h-0 overflow-y-auto no-scrollbar flex flex-col gap-1"
style={listFade.style}
>
{userItems.map(renderItem)}
{systemItems.length > 0 &&
(slim ? (
// Collapsed rail: a short, thicker centred rule is the clearest "new section" cue
// when there's no room for a label.
<div className="my-2 mx-auto w-7 border-t-2 border-border" />
) : (
<div className="mt-4 mb-1 px-2.5 flex items-center gap-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted/70">
{t("nav.adminSection")}
</span>
<span className="flex-1 border-t border-border" />
</div>
))}
{systemItems.map(renderItem)}
</div>
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
<div
className={`flex items-center justify-center gap-2 pb-2 mb-1 border-b border-border ${
slim ? "flex-col" : "flex-row"
}`}
ref={listFade.ref}
className="flex-1 min-h-0 overflow-y-auto no-scrollbar flex flex-col gap-1"
style={listFade.style}
>
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
<button
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
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>
{userItems.map(renderItem)}
{systemItems.length > 0 &&
(slim ? (
// Collapsed rail: a short, thicker centred rule is the clearest "new section" cue
// when there's no room for a label.
<div className="my-2 mx-auto w-7 border-t-2 border-border" />
) : (
<div className="mt-4 mb-1 px-2.5 flex items-center gap-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted/70">
{t("nav.adminSection")}
</span>
<span className="flex-1 border-t border-border" />
</div>
))}
{systemItems.map(renderItem)}
</div>
<button
data-testid="nav-settings"
onClick={() => setPage("settings")}
title={slim ? t("header.account.settings") : undefined}
aria-current={page === "settings" ? "page" : undefined}
className={`${rowBase} ${slim ? "justify-center px-0" : ""} ${
page === "settings"
? "bg-accent text-accent-fg"
: "text-muted hover:text-fg hover:bg-card"
}`}
>
<Settings className="w-[18px] h-[18px] shrink-0" />
{!slim && <span className="truncate">{t("header.account.settings")}</span>}
</button>
<div className="relative">
<button
ref={acctBtnRef}
onClick={toggleAccount}
title={slim ? `${name} · ${roleLabel}` : undefined}
className={`${rowBase} ${slim ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`}
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
<div
className={`flex items-center justify-center gap-2 pb-2 mb-1 border-b border-border ${
slim ? "flex-col" : "flex-row"
}`}
>
<span className="relative shrink-0">
<AvatarImg
src={me.avatar_url}
fallback={name}
className="w-[22px] h-[22px] rounded-full text-[10px]"
/>
</span>
{!slim && (
<span title={roleLabel} className="truncate flex-1 text-left">
{name}
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
<button
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
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>
</div>
<button
data-testid="nav-settings"
onClick={() => setPage("settings")}
title={slim ? t("header.account.settings") : undefined}
aria-current={page === "settings" ? "page" : undefined}
className={`${rowBase} ${slim ? "justify-center px-0" : ""} ${
page === "settings"
? "bg-accent text-accent-fg"
: "text-muted hover:text-fg hover:bg-card"
}`}
>
<Settings className="w-[18px] h-[18px] shrink-0" />
{!slim && <span className="truncate">{t("header.account.settings")}</span>}
</button>
<div className="relative">
<button
ref={acctBtnRef}
onClick={toggleAccount}
title={slim ? `${name} · ${roleLabel}` : undefined}
className={`${rowBase} ${slim ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`}
>
<span className="relative shrink-0">
<AvatarImg
src={me.avatar_url}
fallback={name}
className="w-[22px] h-[22px] rounded-full text-[10px]"
/>
</span>
)}
</button>
{!slim && (
<span title={roleLabel} className="truncate flex-1 text-left">
{name}
</span>
)}
</button>
{acctOpen &&
createPortal(
<div
ref={acctPanelRef}
style={{ position: "fixed", left: acctPos.left, bottom: acctPos.bottom }}
className="glass w-60 rounded-xl p-3 z-overlay animate-[popIn_0.16s_ease]"
>
<div className="flex items-center gap-3 pb-3 border-b border-border">
<AvatarImg
src={me.avatar_url}
fallback={name}
className="w-10 h-10 rounded-full text-sm shrink-0"
/>
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{name}</div>
<div className="text-xs text-muted truncate">{me.email}</div>
{acctOpen &&
createPortal(
<div
ref={acctPanelRef}
style={{ position: "fixed", left: acctPos.left, bottom: acctPos.bottom }}
className="glass w-60 rounded-xl p-3 z-overlay animate-[popIn_0.16s_ease]"
>
<div className="flex items-center gap-3 pb-3 border-b border-border">
<AvatarImg
src={me.avatar_url}
fallback={name}
className="w-10 h-10 rounded-full text-sm shrink-0"
/>
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{name}</div>
<div className="text-xs text-muted truncate">{me.email}</div>
</div>
</div>
</div>
{me.role === "admin" && (
<div className="flex items-center gap-1.5 mt-2 text-[11px] font-medium text-accent">
<Shield className="w-3.5 h-3.5" />
{t("header.account.admin")}
</div>
)}
{otherAccounts.length > 0 && (
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-0.5">
{otherAccounts.map((a) => (
<button
key={a.id}
onClick={() => switchTo(a.id)}
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
>
<AvatarImg
src={a.avatar_url}
fallback={a.display_name ?? a.email}
className="w-6 h-6 rounded-full text-[10px] shrink-0"
/>
<span className="min-w-0 text-left">
<span className="block text-[13px] text-fg truncate">
{a.display_name ?? a.email.split("@")[0]}
{me.role === "admin" && (
<div className="flex items-center gap-1.5 mt-2 text-[11px] font-medium text-accent">
<Shield className="w-3.5 h-3.5" />
{t("header.account.admin")}
</div>
)}
{otherAccounts.length > 0 && (
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-0.5">
{otherAccounts.map((a) => (
<button
key={a.id}
onClick={() => switchTo(a.id)}
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
>
<AvatarImg
src={a.avatar_url}
fallback={a.display_name ?? a.email}
className="w-6 h-6 rounded-full text-[10px] shrink-0"
/>
<span className="min-w-0 text-left">
<span className="block text-[13px] text-fg truncate">
{a.display_name ?? a.email.split("@")[0]}
</span>
<span className="block text-[11px] text-muted truncate">{a.email}</span>
</span>
<span className="block text-[11px] text-muted truncate">{a.email}</span>
</span>
</button>
))}
</button>
))}
</div>
)}
<div className="mt-2 pt-2 border-t border-border flex flex-col">
<button
onClick={() => {
// Adding an account switches THIS tab to it: drop the tab's pin so that on
// return from Google it adopts the freshly-added account (the new default)
// and pins that. Other tabs keep their own pinned account.
clearActiveAccount();
window.location.href = "/auth/login";
}}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<UserPlus className="w-4 h-4" />
{t("header.account.addAccount")}
</button>
<button
onClick={logout}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<LogOut className="w-4 h-4" />
{t("header.account.signOut")}
</button>
</div>
)}
<div className="mt-2 pt-2 border-t border-border flex flex-col">
<button
onClick={() => {
// Adding an account switches THIS tab to it: drop the tab's pin so that on
// return from Google it adopts the freshly-added account (the new default)
// and pins that. Other tabs keep their own pinned account.
clearActiveAccount();
window.location.href = "/auth/login";
}}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<UserPlus className="w-4 h-4" />
{t("header.account.addAccount")}
</button>
<button
onClick={logout}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<LogOut className="w-4 h-4" />
{t("header.account.signOut")}
</button>
</div>
</div>,
document.body
)}
</div>,
document.body,
)}
</div>
</div>
</div>
</nav>
</div>
);
@@ -133,7 +133,8 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps it
// once the results resolve, so a reload only restores a confirmed scrape-sourced search.
const { _ytScrape: _drop, ...st } = window.history.state || {};
if (st._yt) window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
if (st._yt)
window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
else window.history.pushState({ ...st, sfPage: "feed", _yt: q }, ""); // new sub-view entry
}, []);
+98 -89
View File
@@ -1,7 +1,20 @@
import { useEffect, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, UserPlus, X } from "lucide-react";
import {
Activity,
Bell,
Check,
CheckCheck,
ExternalLink,
Eye,
RotateCcw,
Search,
Trash2,
Tv,
UserPlus,
X,
} from "lucide-react";
import { api, type AppNotification } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
@@ -44,7 +57,7 @@ export default function NotificationsPanel() {
const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
["notifications"],
() => api.notifications(),
{ intervalMs: POLL_MS }
{ intervalMs: POLL_MS },
);
const serverItems = q.data?.items ?? [];
const clientItems = useSyncExternalStore(subscribe, getNotifications, getNotifications);
@@ -107,90 +120,90 @@ export default function NotificationsPanel() {
<PageToolbar>
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<div className="glass rounded-2xl p-4 flex items-center gap-3">
<Bell className="w-5 h-5 text-accent shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-semibold">{t("inbox.title")}</div>
<div className="text-xs text-muted">{t("inbox.subtitle")}</div>
</div>
{hasAny && (
<div className="flex items-center gap-2">
<button
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"
>
<CheckCheck className="w-4 h-4" />
{t("inbox.markAllRead")}
</button>
<button
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"
>
<Trash2 className="w-4 h-4" />
{t("inbox.clearAll")}
</button>
<Bell className="w-5 h-5 text-accent shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-semibold">{t("inbox.title")}</div>
<div className="text-xs text-muted">{t("inbox.subtitle")}</div>
</div>
{hasAny && (
<div className="flex items-center gap-2">
<button
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"
>
<CheckCheck className="w-4 h-4" />
{t("inbox.markAllRead")}
</button>
<button
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"
>
<Trash2 className="w-4 h-4" />
{t("inbox.clearAll")}
</button>
</div>
)}
</div>
)}
</div>
</div>
</PageToolbar>
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto space-y-4">
{q.isLoading && !q.data && clientItems.length === 0 ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
) : !hasAny ? (
<div className="glass rounded-2xl p-10 text-center text-muted">
<Bell className="w-8 h-8 mx-auto mb-3 opacity-40" />
{t("inbox.empty")}
</div>
) : (
<div className="space-y-4">
{serverItems.length > 0 && (
<section>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("inbox.sectionSystem")}
</div>
<div className="flex flex-col gap-2">
{serverItems.map((n) => (
<NotificationRow
key={n.id}
n={n}
t={t}
onRead={() => readMut.mutate(n.id)}
onDismiss={() => dismissMut.mutate(n.id)}
onOpenScheduler={() => setPage("scheduler")}
/>
))}
</div>
</section>
)}
{clientItems.length > 0 && (
<section>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("inbox.sectionActivity")}
</div>
<div className="flex flex-col gap-2">
{clientItems.map((n) => (
<ClientActivityRow
key={n.id}
n={n}
t={t}
onFind={locate}
onRevert={revertState}
onFocusChannel={onFocusChannel}
onReviewAccess={() => {
focusAccessRequestsTab();
setPage("users");
}}
onRemove={() => removeClient(n.id)}
/>
))}
</div>
</section>
)}
</div>
)}
{q.isLoading && !q.data && clientItems.length === 0 ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
) : !hasAny ? (
<div className="glass rounded-2xl p-10 text-center text-muted">
<Bell className="w-8 h-8 mx-auto mb-3 opacity-40" />
{t("inbox.empty")}
</div>
) : (
<div className="space-y-4">
{serverItems.length > 0 && (
<section>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("inbox.sectionSystem")}
</div>
<div className="flex flex-col gap-2">
{serverItems.map((n) => (
<NotificationRow
key={n.id}
n={n}
t={t}
onRead={() => readMut.mutate(n.id)}
onDismiss={() => dismissMut.mutate(n.id)}
onOpenScheduler={() => setPage("scheduler")}
/>
))}
</div>
</section>
)}
{clientItems.length > 0 && (
<section>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("inbox.sectionActivity")}
</div>
<div className="flex flex-col gap-2">
{clientItems.map((n) => (
<ClientActivityRow
key={n.id}
n={n}
t={t}
onFind={locate}
onRevert={revertState}
onFocusChannel={onFocusChannel}
onReviewAccess={() => {
focusAccessRequestsTab();
setPage("users");
}}
onRemove={() => removeClient(n.id)}
/>
))}
</div>
</section>
)}
</div>
)}
</div>
</>
);
@@ -225,10 +238,9 @@ function NotificationRow({
} else if (isScheduler) {
// "<Job label> finished/failed", with the raw result summary as the (technical) body.
const job = t(`scheduler.jobs.${n.data!.job_id}`, n.data!.job_id);
title = t(
n.data!.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk",
{ job }
);
title = t(n.data!.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", {
job,
});
body = n.data!.summary || n.body;
}
return (
@@ -241,9 +253,7 @@ function NotificationRow({
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="font-medium text-sm">{title}</span>
<span className="text-[11px] text-muted shrink-0">
{relativeTime(n.created_at)}
</span>
<span className="text-[11px] text-muted shrink-0">{relativeTime(n.created_at)}</span>
</div>
{body && <div className="text-sm text-muted mt-0.5">{body}</div>}
{videos.length > 0 && (
@@ -403,7 +413,6 @@ function ClientActivityRow({
{t("common.accessRequestsReview")}
</button>
) : (
n.action &&
!n.dismissed && (
<button
+123 -118
View File
@@ -124,140 +124,145 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
return (
<Overlay>
<div className="fixed inset-0 z-overlay grid place-items-center p-4">
<div className="absolute inset-0 bg-black/50 animate-[overlayIn_0.2s_ease]" onClick={close} />
<div className="glass relative w-[min(94vw,460px)] rounded-2xl p-7 text-center animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]">
<button
<div className="fixed inset-0 z-overlay grid place-items-center p-4">
<div
className="absolute inset-0 bg-black/50 animate-[overlayIn_0.2s_ease]"
onClick={close}
className="absolute top-3 right-3 p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
aria-label={t("onboarding.close")}
>
<X className="w-4 h-4" />
</button>
/>
<div className="glass relative w-[min(94vw,460px)] rounded-2xl p-7 text-center animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]">
<button
onClick={close}
className="absolute top-3 right-3 p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
aria-label={t("onboarding.close")}
>
<X className="w-4 h-4" />
</button>
{step === "read" && (
<>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Youtube className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold">{t("onboarding.read.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
<Trans
i18nKey="onboarding.read.body"
components={[<span className="text-fg/90" />]}
/>
</p>
<ConsentHeadsUp />
<div className="mt-5 flex flex-col gap-2">
<button
onClick={() => beginGrant("read")}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
<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"
>
{t("onboarding.read.skip")}
</button>
</div>
</>
)}
{/* Read granted: show import progress before the optional write step. */}
{me.can_read && importing && (
<>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
<h2 className="text-lg font-semibold">{t("onboarding.importing.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-2">
{t("onboarding.importing.body")}
</p>
</>
)}
{step === "write" && !importing && (
<>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Check className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold">{t("onboarding.write.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
{importSubs.isError ? (
<>{t("onboarding.write.importFailed")}</>
) : (
<>
{t("onboarding.write.feedReady", {
channels: myStatus.data
? t("onboarding.write.feedReadyChannels", { count: myStatus.data.channels_total })
: "",
})}
</>
)}
{!importSubs.isError && (
{step === "read" && (
<>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Youtube className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold">{t("onboarding.read.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
<Trans
i18nKey="onboarding.write.rationale"
i18nKey="onboarding.read.body"
components={[<span className="text-fg/90" />]}
/>
</p>
<ConsentHeadsUp />
<div className="mt-5 flex flex-col gap-2">
<button
onClick={() => beginGrant("read")}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
<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"
>
{t("onboarding.read.skip")}
</button>
</div>
</>
)}
{/* Read granted: show import progress before the optional write step. */}
{me.can_read && importing && (
<>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
<h2 className="text-lg font-semibold">{t("onboarding.importing.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-2">
{t("onboarding.importing.body")}
</p>
</>
)}
{step === "write" && !importing && (
<>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Check className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold">{t("onboarding.write.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
{importSubs.isError ? (
<>{t("onboarding.write.importFailed")}</>
) : (
<>
{t("onboarding.write.feedReady", {
channels: myStatus.data
? t("onboarding.write.feedReadyChannels", {
count: myStatus.data.channels_total,
})
: "",
})}
</>
)}
{!importSubs.isError && (
<Trans
i18nKey="onboarding.write.rationale"
components={[<span className="text-fg/90" />]}
/>
)}
</p>
{importSubs.isError && (
<button
onClick={() => importSubs.mutate()}
className="mb-3 inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl text-sm bg-card border border-border hover:border-accent transition"
>
<RefreshCw className="w-4 h-4" /> {t("onboarding.write.retryImport")}
</button>
)}
</p>
{importSubs.isError && (
<button
onClick={() => importSubs.mutate()}
className="mb-3 inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl text-sm bg-card border border-border hover:border-accent transition"
>
<RefreshCw className="w-4 h-4" /> {t("onboarding.write.retryImport")}
</button>
)}
<ConsentHeadsUp />
<div className="mt-5 flex flex-col gap-2">
<button
onClick={() => beginGrant("write")}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
<ShieldCheck className="w-4 h-4" /> {t("onboarding.write.enableUnsubscribe")}
</button>
<ConsentHeadsUp />
<div className="mt-5 flex flex-col gap-2">
<button
onClick={() => beginGrant("write")}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
<ShieldCheck className="w-4 h-4" /> {t("onboarding.write.enableUnsubscribe")}
</button>
<button
onClick={() => {
endOnboarding(false);
onClose();
}}
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
>
{t("onboarding.write.keepReadOnly")}
</button>
</div>
</>
)}
{step === "done" && !importing && (
<>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Check className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold">{t("onboarding.done.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-5">
{t("onboarding.done.body")}
</p>
<button
onClick={() => {
endOnboarding(false);
onClose();
}}
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition w-full"
>
{t("onboarding.write.keepReadOnly")}
{t("onboarding.done.done")}
</button>
</div>
</>
)}
</>
)}
{step === "done" && !importing && (
<>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Check className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold">{t("onboarding.done.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-5">
{t("onboarding.done.body")}
</p>
<button
onClick={() => {
endOnboarding(false);
onClose();
}}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition w-full"
>
{t("onboarding.done.done")}
</button>
</>
)}
<div className="mt-6">
<Dots index={stepIndex} total={3} />
<div className="mt-6">
<Dots index={stepIndex} total={3} />
</div>
</div>
</div>
</div>
</Overlay>
);
}
+3 -1
View File
@@ -74,7 +74,9 @@ export default function PanelGroup({
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
className="text-muted hover:text-fg"
>
<ChevronDown className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`} />
<ChevronDown
className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`}
/>
</button>
)}
</div>
+371 -319
View File
@@ -3,12 +3,35 @@ import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { useNavigationActions } from "./NavigationProvider";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ChevronLeft, ChevronRight, ExternalLink, Repeat, Repeat1, Settings, Shuffle, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
import {
AlertTriangle,
ArrowLeft,
Check,
CheckCheck,
ChevronLeft,
ChevronRight,
ExternalLink,
Repeat,
Repeat1,
Settings,
Shuffle,
SkipBack,
SkipForward,
Volume2,
VolumeX,
X,
} from "lucide-react";
import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton";
import { api, type Video } from "../lib/api";
import { channelYouTubeUrl, formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
import {
channelYouTubeUrl,
formatDate,
formatDuration,
formatViews,
relativeTime,
} from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks";
import { useBackToClose } from "../lib/history";
import { useScrollFade } from "../lib/useScrollFade";
@@ -94,14 +117,14 @@ export default function PlayerModal({
const [playingId, setPlayingId] = useState<string>(
// A caller with an explicit index (playlist) starts there; otherwise locate the opened
// `video` in the queue (the feed passes its whole list + the clicked video, no index).
startIndex != null ? queue?.[startIndex]?.id ?? video.id : video.id
startIndex != null ? (queue?.[startIndex]?.id ?? video.id) : video.id,
);
// Locate the playing item in the frozen launch queue → drives prev/next + the N/M counter.
// Because the queue is frozen, the playing id always stays in it (unlike the old live queue,
// where a just-watched item could drop out and snap playback to queue[0]).
let index = queue ? queue.findIndex((v) => v.id === playingId) : 0;
if (index < 0) index = 0;
const active = queue && queue[index] ? queue[index] : video;
const active = queue?.[index] ?? video; // the queue item at index, else the opened video (never undefined)
const hasQueue = !!queue && queue.length > 1;
// startAt only applies to the item we opened with; advanced items resume their own pos.
const resumeAt =
@@ -136,7 +159,8 @@ export default function PlayerModal({
const togglePlay = () => {
const p = playerRef.current;
if (!p || typeof p.getPlayerState !== "function") return;
if (p.getPlayerState() === 1) p.pauseVideo?.(); // 1 === playing
if (p.getPlayerState() === 1)
p.pauseVideo?.(); // 1 === playing
else p.playVideo?.();
};
const toggleFullscreen = () => {
@@ -153,7 +177,7 @@ export default function PlayerModal({
const nudgeVolume = (delta: number) => {
const p = playerRef.current;
if (!p || typeof p.getVolume !== "function") return;
const cur = p.isMuted?.() ? 0 : p.getVolume?.() ?? 50;
const cur = p.isMuted?.() ? 0 : (p.getVolume?.() ?? 50);
const next = Math.max(0, Math.min(100, Math.round(cur + delta)));
if (next > 0) p.unMute?.();
p.setVolume?.(next);
@@ -218,11 +242,11 @@ export default function PlayerModal({
navRef.current = { queue, index };
const goPrev = () => {
const { queue: q, index: i } = navRef.current;
if (q && i > 0) setPlayingId(q[i - 1].id);
if (q && i > 0) setPlayingId(q[i - 1]!.id);
};
const goNext = () => {
const { queue: q, index: i } = navRef.current;
if (q && i < q.length - 1) setPlayingId(q[i + 1].id);
if (q && i < q.length - 1) setPlayingId(q[i + 1]!.id);
};
// Relative seek within the current video (plain Arrow keys), matching YouTube's ±5s.
const nudgeSeek = (delta: number) => {
@@ -240,7 +264,7 @@ export default function PlayerModal({
playerAutoAdvance?: AutoMode;
playerLoop?: LoopMode;
},
[qc]
[qc],
);
const [autoMode, setAutoMode] = useState<AutoMode>(savedPrefs.playerAutoAdvance ?? "off");
const [loopMode, setLoopMode] = useState<LoopMode>(savedPrefs.playerLoop ?? "off");
@@ -249,16 +273,16 @@ export default function PlayerModal({
const persistPref = (patch: Record<string, string>) => {
api.savePrefs(patch).catch(() => {});
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
);
};
const cycleAuto = () => {
const next = AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length];
const next = AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]!;
setAutoMode(next);
persistPref({ playerAutoAdvance: next });
};
const cycleLoop = () => {
const next = LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length];
const next = LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length]!;
setLoopMode(next);
persistPref({ playerLoop: next });
};
@@ -279,21 +303,21 @@ export default function PlayerModal({
if (!q || q.length === 0) return;
const wrap = l === "all";
if (a === "next") {
if (i < q.length - 1) setPlayingId(q[i + 1].id);
if (i < q.length - 1) setPlayingId(q[i + 1]!.id);
else if (wrap) {
if (q.length === 1) replayCurrent();
else setPlayingId(q[0].id);
else setPlayingId(q[0]!.id);
}
} else if (a === "prev") {
if (i > 0) setPlayingId(q[i - 1].id);
if (i > 0) setPlayingId(q[i - 1]!.id);
else if (wrap) {
if (q.length === 1) replayCurrent();
else setPlayingId(q[q.length - 1].id);
else setPlayingId(q[q.length - 1]!.id);
}
} else if (a === "random" && q.length > 1) {
let r = i;
while (r === i) r = Math.floor(Math.random() * q.length);
setPlayingId(q[r].id);
setPlayingId(q[r]!.id);
} else if (a === "random" && wrap) {
replayCurrent();
}
@@ -306,7 +330,7 @@ export default function PlayerModal({
// `bottom` anchors the popover just above the title so it grows upward (over the
// player) instead of downward off-screen / behind the OS taskbar.
const [descRect, setDescRect] = useState<{ left: number; bottom: number; width: number } | null>(
null
null,
);
const titleRef = useRef<HTMLSpanElement | null>(null);
const closeTimer = useRef<number | undefined>(undefined);
@@ -356,7 +380,8 @@ export default function PlayerModal({
}
const el = document.activeElement as HTMLElement | null;
const tag = (el?.tagName || "").toLowerCase();
const typing = tag === "input" || tag === "textarea" || tag === "select" || !!el?.isContentEditable;
const typing =
tag === "input" || tag === "textarea" || tag === "select" || !!el?.isContentEditable;
if (e.key === "f" || e.key === "F") {
if (typing) return;
e.preventDefault();
@@ -616,325 +641,352 @@ export default function PlayerModal({
<ChevronLeft className="h-8 w-8" />
</button>
)}
<div
ref={cardRef}
tabIndex={-1}
className="glass-card relative flex-1 min-w-0 max-h-full overflow-y-auto rounded-2xl shadow-2xl outline-none"
onClick={(e) => e.stopPropagation()}
>
<div
ref={stageRef}
className="player-stage relative aspect-video w-full bg-black rounded-t-2xl overflow-hidden"
ref={cardRef}
tabIndex={-1}
className="glass-card relative flex-1 min-w-0 max-h-full overflow-y-auto rounded-2xl shadow-2xl outline-none"
onClick={(e) => e.stopPropagation()}
>
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
<div
ref={stageRef}
className="player-stage relative aspect-video w-full bg-black rounded-t-2xl overflow-hidden"
>
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
through our overlay. */}
<div ref={mountRef} className={`w-full h-full ${playerError != null ? "invisible" : ""}`} />
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) and stops
<div
ref={mountRef}
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
/>
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) and stops
the iframe stealing keyboard focus. It deliberately leaves the top AND bottom edges
uncovered so YouTube's native controls — the top-right cluster (volume / CC / settings)
and the bottom bar (seek / More videos / fullscreen) — stay clickable. Hidden on error
so the "Open on YouTube" CTA is clickable. */}
{playerError == null && (
<div
onClick={() => {
focusModal();
togglePlay();
}}
title={t("player.shortcutsHint")}
aria-label={t("player.shortcutsHint")}
className={`absolute inset-x-0 top-[12%] bottom-[22%] z-base cursor-pointer ${
nativeControls ? "pointer-events-none" : ""
}`}
/>
)}
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
{playerError == null && nativeControls && (
<div className="pointer-events-none absolute top-3 left-3 z-menu flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-white/90 shadow-lg backdrop-blur-sm">
<Settings className="h-3.5 w-3.5" />
{t("player.nativeControls")}
</div>
)}
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
{volumeUi != null && (
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-menu flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg">
{volumeUi === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/25">
<div className="h-full rounded-full bg-white transition-all" style={{ width: `${volumeUi}%` }} />
</div>
<span className="w-7 text-right tabular-nums">{volumeUi}</span>
</div>
)}
{playerError != null && (
<div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center">
<div className="max-w-sm">
<AlertTriangle className="w-8 h-8 text-amber-500 mx-auto mb-3" />
<h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3>
<p className="text-sm text-muted mt-1.5 mb-4">
{playerError === 101 || playerError === 150
? t("player.embedDisabledBody")
: t("player.unavailableBody")}
</p>
<a
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
<ExternalLink className="w-4 h-4" />
{t("player.openOnYoutube")}
</a>
</div>
</div>
)}
</div>
{hasQueue && (
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
<button
onClick={goPrev}
disabled={index === 0}
title={`${t("player.previous")} · Shift+←`}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
>
<SkipBack className="w-4 h-4" /> {t("player.previous")}
</button>
<span className="text-muted tabular-nums">
{index + 1} / {queue!.length}
</span>
<button
onClick={goNext}
disabled={index === queue!.length - 1}
title={`${t("player.next")} · Shift+→`}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
>
{t("player.next")} <SkipForward className="w-4 h-4" />
</button>
<span className="w-px h-4 bg-border" />
{/* Persistent playback settings — cycle on click, saved to your account. */}
<button
onClick={cycleAuto}
title={t("player.autoAdvance.hint")}
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
autoMode !== "off" ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
}`}
>
{autoMode === "prev" ? (
<SkipBack className="h-3.5 w-3.5" />
) : autoMode === "random" ? (
<Shuffle className="h-3.5 w-3.5" />
) : (
<SkipForward className="h-3.5 w-3.5" />
)}
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</button>
<button
onClick={cycleLoop}
title={t("player.loop.hint")}
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
loopMode !== "off" ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
}`}
>
{loopMode === "one" ? <Repeat1 className="h-3.5 w-3.5" /> : <Repeat className="h-3.5 w-3.5" />}
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</button>
</div>
)}
<div className="p-4 sm:p-5">
{/* Title row — title (with hover description) on the left, Close on the right. */}
<div className="flex items-start gap-3">
<h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug">
{/* Hover target is the text itself (inline), not the whole row. */}
<span
ref={titleRef}
className="cursor-default"
onMouseEnter={scheduleOpenDesc}
onMouseLeave={scheduleCloseDesc}
>
{navigated ? liveData?.title ?? t("player.loading") : active.title}
</span>
</h2>
{navigated && (
<button
onClick={() => loadVideo(active.id, null)}
title={t("player.backToOriginal")}
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
>
<ArrowLeft className="w-4 h-4" />
{t("player.back")}
</button>
{playerError == null && (
<div
onClick={() => {
focusModal();
togglePlay();
}}
title={t("player.shortcutsHint")}
aria-label={t("player.shortcutsHint")}
className={`absolute inset-x-0 top-[12%] bottom-[22%] z-base cursor-pointer ${
nativeControls ? "pointer-events-none" : ""
}`}
/>
)}
{showDesc &&
descRect &&
createPortal(
<div
className="fixed z-popover bg-surface border border-border rounded-xl shadow-2xl p-4"
style={{
left: descRect.left,
bottom: descRect.bottom,
width: descRect.width,
}}
onMouseEnter={openDescNow}
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
{playerError == null && nativeControls && (
<div className="pointer-events-none absolute top-3 left-3 z-menu flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-white/90 shadow-lg backdrop-blur-sm">
<Settings className="h-3.5 w-3.5" />
{t("player.nativeControls")}
</div>
)}
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
{volumeUi != null && (
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-menu flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg">
{volumeUi === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/25">
<div
className="h-full rounded-full bg-white transition-all"
style={{ width: `${volumeUi}%` }}
/>
</div>
<span className="w-7 text-right tabular-nums">{volumeUi}</span>
</div>
)}
{playerError != null && (
<div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center">
<div className="max-w-sm">
<AlertTriangle className="w-8 h-8 text-amber-500 mx-auto mb-3" />
<h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3>
<p className="text-sm text-muted mt-1.5 mb-4">
{playerError === 101 || playerError === 150
? t("player.embedDisabledBody")
: t("player.unavailableBody")}
</p>
<a
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
<ExternalLink className="w-4 h-4" />
{t("player.openOnYoutube")}
</a>
</div>
</div>
)}
</div>
{hasQueue && (
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
<button
onClick={goPrev}
disabled={index === 0}
title={`${t("player.previous")} · Shift+←`}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
>
<SkipBack className="w-4 h-4" /> {t("player.previous")}
</button>
<span className="text-muted tabular-nums">
{index + 1} / {queue!.length}
</span>
<button
onClick={goNext}
disabled={index === queue!.length - 1}
title={`${t("player.next")} · Shift+→`}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
>
{t("player.next")} <SkipForward className="w-4 h-4" />
</button>
<span className="w-px h-4 bg-border" />
{/* Persistent playback settings — cycle on click, saved to your account. */}
<button
onClick={cycleAuto}
title={t("player.autoAdvance.hint")}
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
autoMode !== "off"
? "bg-accent/15 text-accent"
: "text-muted hover:bg-surface hover:text-fg"
}`}
>
{autoMode === "prev" ? (
<SkipBack className="h-3.5 w-3.5" />
) : autoMode === "random" ? (
<Shuffle className="h-3.5 w-3.5" />
) : (
<SkipForward className="h-3.5 w-3.5" />
)}
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</button>
<button
onClick={cycleLoop}
title={t("player.loop.hint")}
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
loopMode !== "off"
? "bg-accent/15 text-accent"
: "text-muted hover:bg-surface hover:text-fg"
}`}
>
{loopMode === "one" ? (
<Repeat1 className="h-3.5 w-3.5" />
) : (
<Repeat className="h-3.5 w-3.5" />
)}
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</button>
</div>
)}
<div className="p-4 sm:p-5">
{/* Title row — title (with hover description) on the left, Close on the right. */}
<div className="flex items-start gap-3">
<h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug">
{/* Hover target is the text itself (inline), not the whole row. */}
<span
ref={titleRef}
className="cursor-default"
onMouseEnter={scheduleOpenDesc}
onMouseLeave={scheduleCloseDesc}
>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("player.description")}
</div>
{detail.isLoading ? (
<div className="text-sm text-muted">{t("player.loading")}</div>
) : detail.data?.description ? (
<div
ref={descFade.ref}
style={descFade.style}
className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
>
{renderDescription(detail.data.description, {
currentId: currentVideoId,
onSeek: seekTo,
onLoadVideo: loadVideo,
t,
})}
</div>
) : (
<div className="text-sm text-muted">{t("player.noDescription")}</div>
)}
</div>,
document.body
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
</span>
</h2>
{navigated && (
<button
onClick={() => loadVideo(active.id, null)}
title={t("player.backToOriginal")}
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
>
<ArrowLeft className="w-4 h-4" />
{t("player.back")}
</button>
)}
<button
data-testid="player-close"
onClick={onClose}
title={t("player.closeEsc")}
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
>
{t("player.close")}
<X className="w-4 h-4" />
</button>
</div>
{showDesc &&
descRect &&
createPortal(
<div
className="fixed z-popover bg-surface border border-border rounded-xl shadow-2xl p-4"
style={{
left: descRect.left,
bottom: descRect.bottom,
width: descRect.width,
}}
onMouseEnter={openDescNow}
onMouseLeave={scheduleCloseDesc}
>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("player.description")}
</div>
{detail.isLoading ? (
<div className="text-sm text-muted">{t("player.loading")}</div>
) : detail.data?.description ? (
<div
ref={descFade.ref}
style={descFade.style}
className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
>
{renderDescription(detail.data.description, {
currentId: currentVideoId,
onSeek: seekTo,
onLoadVideo: loadVideo,
t,
})}
</div>
) : (
<div className="text-sm text-muted">{t("player.noDescription")}</div>
)}
</div>,
document.body,
)}
<button
data-testid="player-close"
onClick={onClose}
title={t("player.closeEsc")}
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
>
{t("player.close")}
<X className="w-4 h-4" />
</button>
</div>
{/* Channel + meta on one line, with the watched toggle pushed to the right.
{/* Channel + meta on one line, with the watched toggle pushed to the right.
When navigated to a linked video we only have its author (from the player),
so we show that as plain text and hide feed-video-specific bits. */}
<div className="flex items-center gap-3 mt-3">
{!navigated && (
<Avatar
src={active.channel_thumbnail}
fallback={active.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0"
/>
)}
{navigated ? (
detail.data?.channel_id ? (
<a
href={channelYouTubeUrl(detail.data.channel_id)}
target="_blank"
rel="noreferrer"
className="font-medium hover:text-accent shrink-0"
>
{liveData?.author ?? detail.data.channel_title ?? t("player.channel")}
</a>
<div className="flex items-center gap-3 mt-3">
{!navigated && (
<Avatar
src={active.channel_thumbnail}
fallback={active.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0"
/>
)}
{navigated ? (
detail.data?.channel_id ? (
<a
href={channelYouTubeUrl(detail.data.channel_id)}
target="_blank"
rel="noreferrer"
className="font-medium hover:text-accent shrink-0"
>
{liveData?.author ?? detail.data.channel_title ?? t("player.channel")}
</a>
) : (
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
)
) : (
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
)
) : (
<div className="flex items-center gap-1.5 shrink-0 min-w-0">
<button
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();
}}
className="font-medium hover:text-accent truncate text-left"
>
{active.channel_title}
</button>
<a
href={active.channel_url}
target="_blank"
rel="noreferrer"
title={t("channels.row.openOnYouTube")}
aria-label={t("channels.row.openOnYouTube")}
className="text-muted hover:text-accent shrink-0"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
)}
{!navigated ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
{active.view_count != null && (
<span>· {t("player.views", { count: active.view_count, formattedCount: formatViews(active.view_count) })}</span>
)}
<span>
· {relativeTime(active.published_at)}
{active.published_at && ` · ${fullDate(active.published_at)}`}
</span>
{active.duration_seconds != null && (
<span>· {formatDuration(active.duration_seconds)}</span>
)}
{active.live_status === "was_live" && (
<span className="bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
{t("player.stream")}
</span>
)}
</div>
) : (
// Linked video's stats come from the (already-fetched) video detail.
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
{detail.data?.view_count != null && (
<span>· {t("player.views", { count: detail.data.view_count, formattedCount: formatViews(detail.data.view_count) })}</span>
)}
{detail.data?.published_at && (
<div className="flex items-center gap-1.5 shrink-0 min-w-0">
<button
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();
}}
className="font-medium hover:text-accent truncate text-left"
>
{active.channel_title}
</button>
<a
href={active.channel_url}
target="_blank"
rel="noreferrer"
title={t("channels.row.openOnYouTube")}
aria-label={t("channels.row.openOnYouTube")}
className="text-muted hover:text-accent shrink-0"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
)}
{!navigated ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
{active.view_count != null && (
<span>
·{" "}
{t("player.views", {
count: active.view_count,
formattedCount: formatViews(active.view_count),
})}
</span>
)}
<span>
· {relativeTime(detail.data.published_at)} · {fullDate(detail.data.published_at)}
· {relativeTime(active.published_at)}
{active.published_at && ` · ${fullDate(active.published_at)}`}
</span>
)}
{detail.data?.duration_seconds != null && (
<span>· {formatDuration(detail.data.duration_seconds)}</span>
)}
</div>
)}
{active.duration_seconds != null && (
<span>· {formatDuration(active.duration_seconds)}</span>
)}
{active.live_status === "was_live" && (
<span className="bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
{t("player.stream")}
</span>
)}
</div>
) : (
// Linked video's stats come from the (already-fetched) video detail.
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
{detail.data?.view_count != null && (
<span>
·{" "}
{t("player.views", {
count: detail.data.view_count,
formattedCount: formatViews(detail.data.view_count),
})}
</span>
)}
{detail.data?.published_at && (
<span>
· {relativeTime(detail.data.published_at)} ·{" "}
{fullDate(detail.data.published_at)}
</span>
)}
{detail.data?.duration_seconds != null && (
<span>· {formatDuration(detail.data.duration_seconds)}</span>
)}
</div>
)}
{!navigated && (
<AddToPlaylist
videoId={active.id}
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/>
)}
{!navigated && (
<DownloadButton
videoId={active.id}
title={active.title}
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/>
)}
{!navigated && (
<button
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
className={
"shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
(watched
? "bg-accent text-accent-fg shadow-sm hover:opacity-90"
: "text-muted hover:text-fg hover:bg-surface border border-border")
}
>
{watched ? <CheckCheck className="w-4 h-4" /> : <Check className="w-4 h-4" />}
{watched ? t("player.watched") : t("player.markWatched")}
</button>
)}
{!navigated && (
<AddToPlaylist
videoId={active.id}
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/>
)}
{!navigated && (
<DownloadButton
videoId={active.id}
title={active.title}
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/>
)}
{!navigated && (
<button
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
className={
"shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
(watched
? "bg-accent text-accent-fg shadow-sm hover:opacity-90"
: "text-muted hover:text-fg hover:bg-surface border border-border")
}
>
{watched ? <CheckCheck className="w-4 h-4" /> : <Check className="w-4 h-4" />}
{watched ? t("player.watched") : t("player.markWatched")}
</button>
)}
</div>
</div>
</div>
</div>
{hasQueue && (
<button
onClick={(e) => {
@@ -951,6 +1003,6 @@ export default function PlayerModal({
)}
</div>
</div>,
document.body
document.body,
);
}
+38 -19
View File
@@ -45,7 +45,14 @@ import { PageToolbar } from "./PageShell";
import { useChoice, useConfirm } from "./ConfirmProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe";
import { ColumnHead, alignClass, filterRows, isActive, type Column, type FilterMap } from "./tableHeader";
import {
ColumnHead,
alignClass,
filterRows,
isActive,
type Column,
type FilterMap,
} from "./tableHeader";
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
@@ -158,7 +165,7 @@ export default function Playlists() {
api
.reorderPlaylist(
selectedId,
next.map((v) => v.id)
next.map((v) => v.id),
)
.then(() => {
// Refresh the sidebar (cover may change) and the detail (so the now-dirty state,
@@ -253,13 +260,13 @@ export default function Playlists() {
const columns = useMemo(
() => playlistColumns({ t, indexOf: storedIndex, onPlay, onRemove, canEdit }),
[t, storedIndex, onPlay, onRemove, canEdit]
[t, storedIndex, onPlay, onRemove, canEdit],
);
const filterActive = Object.values(filters).some(isActive);
const shown = useMemo(
() => applyView(filterRows(items, columns, filters), columns, viewSort, groupBy),
[items, columns, filters, viewSort, groupBy]
[items, columns, filters, viewSort, groupBy],
);
// Read by the drag announcements, which must report the CURRENT positions without making the
// announcement closures a render dependency of DndContext.
@@ -291,21 +298,32 @@ export default function Playlists() {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
// The grip was focusable and called itself "reorder", but only a pointer could move it.
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const posOf = (id: string | number) => shownRef.current.findIndex((v) => v.id === id) + 1;
const titleOf = (id: string | number) =>
shownRef.current.find((v) => v.id === id)?.title ?? "";
const titleOf = (id: string | number) => shownRef.current.find((v) => v.id === id)?.title ?? "";
const total = () => shownRef.current.length;
const screenReaderInstructions = { draggable: t("playlists.dndInstructions") };
const announcements = {
onDragStart: ({ active }: { active: { id: string | number } }) =>
t("playlists.dndPickedUp", { title: titleOf(active.id), pos: posOf(active.id), total: total() }),
t("playlists.dndPickedUp", {
title: titleOf(active.id),
pos: posOf(active.id),
total: total(),
}),
onDragOver: ({ active }: { active: { id: string | number } }) =>
t("playlists.dndMovedOver", { title: titleOf(active.id), pos: posOf(active.id), total: total() }),
t("playlists.dndMovedOver", {
title: titleOf(active.id),
pos: posOf(active.id),
total: total(),
}),
onDragEnd: ({ active }: { active: { id: string | number } }) =>
t("playlists.dndDropped", { title: titleOf(active.id), pos: posOf(active.id), total: total() }),
t("playlists.dndDropped", {
title: titleOf(active.id),
pos: posOf(active.id),
total: total(),
}),
onDragCancel: ({ active }: { active: { id: string | number } }) =>
t("playlists.dndCancelled", { title: titleOf(active.id), pos: posOf(active.id) }),
};
@@ -493,11 +511,12 @@ export default function Playlists() {
const closePlayer = useCallback(() => setPlayingId(null), []);
// Name the current view in the user's terms: "Title ↑", "grouped by channel", or both.
const sortLabel = viewSort && !isStoredSort(viewSort)
? `${columns.find((c) => c.key === viewSort.key)?.header ?? viewSort.key} ${
viewSort.dir === "asc" ? "↑" : "↓"
}`
: null;
const sortLabel =
viewSort && !isStoredSort(viewSort)
? `${columns.find((c) => c.key === viewSort.key)?.header ?? viewSort.key} ${
viewSort.dir === "asc" ? "↑" : "↓"
}`
: null;
const viewName = groupBy
? sortLabel
? t("playlists.viewGroupedAnd", { sort: sortLabel })
@@ -615,7 +634,9 @@ export default function Playlists() {
onClick={pushToYoutube}
disabled={pushing}
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
aria-label={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
aria-label={
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
}
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
detail.dirty
? "border-accent text-accent hover:bg-accent/10"
@@ -698,9 +719,7 @@ export default function Playlists() {
stored order ALREADY matches the sort produced a warning, no Save button and no
visible change, which reads as a malfunction. Now the line says which of the three
situations you are in; the disabled grip still explains itself on hover/focus. */}
{viewActive && (
<span className="text-[11px] text-muted/80">{viewStatus}</span>
)}
{viewActive && <span className="text-[11px] text-muted/80">{viewStatus}</span>}
{canEdit && (
<div className="ml-auto">
<UndoToolbar
@@ -1,11 +1,4 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
// Owns the Playlists module's shared local state — the rail's name filter and the selected playlist
+16 -6
View File
@@ -14,7 +14,11 @@ import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe";
import { playlistName } from "../lib/playlistName";
type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean };
type PlSort = {
key: "custom" | "name" | "count" | "duration";
dir: "asc" | "desc";
dirtyFirst: boolean;
};
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
// The Playlists module's left rail — lifted to App level (like the filter rails) so it floats in the
@@ -65,7 +69,7 @@ export default function PlaylistsRail({
return;
}
if (selectedId == null || !playlists.some((p) => p.id === selectedId)) {
setSelectedId(playlists[0].id);
setSelectedId(playlists[0]!.id); // playlists is non-empty (the length guard above returns)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playlists, selectedId, listQuery.data]);
@@ -92,9 +96,10 @@ export default function PlaylistsRail({
const q = search.trim().toLowerCase();
const visiblePlaylists = useMemo(
() => (q ? sortedPlaylists.filter((p) => plName(p).toLowerCase().includes(q)) : sortedPlaylists),
() =>
q ? sortedPlaylists.filter((p) => plName(p).toLowerCase().includes(q)) : sortedPlaylists,
// eslint-disable-next-line react-hooks/exhaustive-deps
[sortedPlaylists, q]
[sortedPlaylists, q],
);
useEffect(() => {
@@ -161,7 +166,11 @@ export default function PlaylistsRail({
title={plSort.dir === "asc" ? t("playlists.dirAsc") : t("playlists.dirDesc")}
className="p-1 rounded-md border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition"
>
{plSort.dir === "asc" ? <ArrowUp className="w-3.5 h-3.5" /> : <ArrowDown className="w-3.5 h-3.5" />}
{plSort.dir === "asc" ? (
<ArrowUp className="w-3.5 h-3.5" />
) : (
<ArrowDown className="w-3.5 h-3.5" />
)}
</button>
<button
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
@@ -235,7 +244,8 @@ export default function PlaylistsRail({
</div>
<div className="text-[11px] text-muted">
{t("playlists.itemCount", { count: pl.item_count })}
{pl.total_duration_seconds > 0 && ` · ${formatDuration(pl.total_duration_seconds)}`}
{pl.total_duration_seconds > 0 &&
` · ${formatDuration(pl.total_duration_seconds)}`}
</div>
</div>
</button>
+152 -38
View File
@@ -1,7 +1,20 @@
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import {
lazy,
Suspense,
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
} from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query";
import {
useInfiniteQuery,
useQuery,
useQueryClient,
type InfiniteData,
} from "@tanstack/react-query";
import {
ArrowLeft,
Check,
@@ -32,7 +45,12 @@ import {
import { formatRuntime } from "../lib/format";
import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history";
import { DetailCustomizeMenu, Filterable, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
import {
DetailCustomizeMenu,
Filterable,
useArtBackdrop,
useDetailPrefs,
} from "../lib/plexDetailUi";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import PlexCollectionEditor from "./PlexCollectionEditor";
import { PageToolbar } from "./PageShell";
@@ -95,7 +113,8 @@ export default function PlexBrowse() {
const el = scroller();
if (!el) return;
if (sub.view.kind === "grid" && scrollRef.current) el.scrollTop = scrollRef.current;
else if (sub.view.kind === "info" && infoScrollRef.current) el.scrollTop = infoScrollRef.current;
else if (sub.view.kind === "info" && infoScrollRef.current)
el.scrollTop = infoScrollRef.current;
}, [sub.view.kind]);
// Backspace steps back one drill-down level (grid ← show ← season, and out of info/playlist),
@@ -106,7 +125,8 @@ export default function PlexBrowse() {
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 (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable)
return;
if (["show", "season", "info", "playlist"].includes(sub.view.kind)) {
e.preventDefault();
sub.back();
@@ -163,7 +183,7 @@ export default function PlexBrowse() {
if (!sentinelEl) return;
const io = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage();
if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage();
},
{ rootMargin: "600px" },
);
@@ -227,7 +247,9 @@ export default function PlexBrowse() {
// The fallback is portaled for the same reason the player itself is: it is a fixed
// full-viewport overlay, and this tree renders inside the page scroller.
return (
<Suspense fallback={createPortal(<div className="fixed inset-0 z-overlay bg-black" />, document.body)}>
<Suspense
fallback={createPortal(<div className="fixed inset-0 z-overlay bg-black" />, document.body)}
>
<PlexPlayer itemId={sub.view.id} queue={sub.view.queue} onClose={sub.back} />
</Suspense>
);
@@ -288,7 +310,11 @@ export default function PlexBrowse() {
<PageToolbar>
<div className="px-4 pt-3 pb-1 max-w-[1600px] mx-auto">
<p className="text-xs text-muted">
{browseQ.isLoading ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}
{browseQ.isLoading
? " "
: dq
? t("plex.searchCount", { count: total })
: t("plex.count", { count: total })}
</p>
</div>
</PageToolbar>
@@ -314,7 +340,9 @@ export default function PlexBrowse() {
)}
<div className="min-w-0">
<div className="text-sm font-medium truncate max-w-[150px]">{p.name}</div>
<div className="text-[11px] text-muted">{t("plex.people.count", { count: p.count })}</div>
<div className="text-[11px] text-muted">
{t("plex.people.count", { count: p.count })}
</div>
</div>
<button
onClick={() => removeActor(p.name)}
@@ -389,7 +417,9 @@ export default function PlexBrowse() {
)}
<div ref={setSentinelEl} className="h-8" />
{isFetchingNextPage && <p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>}
{isFetchingNextPage && (
<p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>
)}
</div>
);
}
@@ -448,7 +478,11 @@ function PlexPosterCard({
<div className="flex flex-col items-center gap-1 text-white">
<Play className="w-10 h-10" fill="currentColor" />
<span className="text-xs font-medium">
{card.type === "show" ? t("plex.openShow") : inProgress ? t("plex.resume") : t("plex.play")}
{card.type === "show"
? t("plex.openShow")
: inProgress
? t("plex.resume")
: t("plex.play")}
</span>
</div>
</div>
@@ -462,7 +496,9 @@ function PlexPosterCard({
title={card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")}
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
>
<CheckCircle2 className={`w-4 h-4 ${card.status === "watched" ? "text-emerald-400" : "text-white"}`} />
<CheckCircle2
className={`w-4 h-4 ${card.status === "watched" ? "text-emerald-400" : "text-white"}`}
/>
</button>
)}
{/* Media info page (movies/episodes). */}
@@ -504,7 +540,9 @@ function PlexPosterCard({
{/* Type tag — tell a movie card from a show card at a glance (unified feed). */}
<span
className="absolute bottom-1.5 right-1.5 rounded bg-black/65 p-0.5 text-white/90"
title={t(card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie")}
title={t(
card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie",
)}
>
{card.type === "show" ? <Tv2 className="w-3 h-3" /> : <Film className="w-3 h-3" />}
</span>
@@ -519,8 +557,12 @@ function PlexPosterCard({
className="mt-1.5 rounded-lg px-2 py-1.5 cursor-pointer"
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
>
<div className="text-sm font-medium line-clamp-2 leading-tight hover:text-accent">{card.title}</div>
<div className="text-xs text-muted">{[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")}</div>
<div className="text-sm font-medium line-clamp-2 leading-tight hover:text-accent">
{card.title}
</div>
<div className="text-xs text-muted">
{[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")}
</div>
</div>
</div>
);
@@ -581,7 +623,8 @@ function PlexInfoView({
function epLabel(ep?: PlexCard | null): string | undefined {
if (!ep) return undefined;
if (ep.season_number != null && ep.episode_number != null) return `S${ep.season_number} · E${ep.episode_number}`;
if (ep.season_number != null && ep.episode_number != null)
return `S${ep.season_number} · E${ep.episode_number}`;
return ep.title;
}
@@ -675,7 +718,9 @@ function SeasonCard({
e.stopPropagation();
onToggleWatched();
}}
title={watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")}
title={
watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")
}
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
>
<CheckCircle2 className={`w-4 h-4 ${watched ? "text-emerald-400" : "text-white"}`} />
@@ -719,7 +764,13 @@ function SeasonCard({
);
}
function CastStrip({ cast, onFilter }: { cast: PlexCastMember[]; onFilter?: (patch: Partial<PlexFilters>) => void }) {
function CastStrip({
cast,
onFilter,
}: {
cast: PlexCastMember[];
onFilter?: (patch: Partial<PlexFilters>) => void;
}) {
const { t } = useTranslation();
return (
<section
@@ -731,14 +782,20 @@ function CastStrip({ cast, onFilter }: { cast: PlexCastMember[]; onFilter?: (pat
{cast.map((c, i) => {
const inner = (
<>
<div className={`mx-auto h-20 w-20 overflow-hidden rounded-full border bg-surface ${onFilter ? "border-border group-hover/cast:border-accent" : "border-border"}`}>
<div
className={`mx-auto h-20 w-20 overflow-hidden rounded-full border bg-surface ${onFilter ? "border-border group-hover/cast:border-accent" : "border-border"}`}
>
{c.thumb ? (
<img src={c.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center opacity-40">{c.name.charAt(0)}</div>
<div className="grid h-full w-full place-items-center opacity-40">
{c.name.charAt(0)}
</div>
)}
</div>
<div className={`mt-1.5 text-xs font-medium line-clamp-2 leading-tight ${onFilter ? "group-hover/cast:text-accent" : ""}`}>
<div
className={`mt-1.5 text-xs font-medium line-clamp-2 leading-tight ${onFilter ? "group-hover/cast:text-accent" : ""}`}
>
{c.name}
</div>
{c.role && <div className="text-[11px] text-muted line-clamp-1">{c.role}</div>}
@@ -774,7 +831,11 @@ function RelatedStrip({ related, onOpen }: { related: PlexCard[]; onOpen: (id: s
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
<div className="flex gap-3 overflow-x-auto pb-2">
{related.map((r) => (
<button key={r.id} onClick={() => onOpen(r.id)} className="group w-[130px] shrink-0 text-left">
<button
key={r.id}
onClick={() => onOpen(r.id)}
className="group w-[130px] shrink-0 text-left"
>
<div className="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border">
<img
src={r.thumb}
@@ -848,7 +909,9 @@ function EpisodeCard({
title={ep.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")}
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
>
<CheckCircle2 className={`w-4 h-4 ${ep.status === "watched" ? "text-emerald-400" : "text-white"}`} />
<CheckCircle2
className={`w-4 h-4 ${ep.status === "watched" ? "text-emerald-400" : "text-white"}`}
/>
</button>
)}
{ep.status === "watched" && (
@@ -989,19 +1052,37 @@ function PlexShowView({
<h1 className="text-2xl font-semibold leading-tight">{show.title}</h1>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted">
{show.year != null && (
<Filterable onClick={onFilter ? () => onFilter({ yearMin: show.year, yearMax: show.year }) : undefined}>
<Filterable
onClick={
onFilter
? () => onFilter({ yearMin: show.year, yearMax: show.year })
: undefined
}
>
{show.year}
</Filterable>
)}
{show.content_rating && (
<Filterable onClick={onFilter ? () => onFilter({ contentRatings: [show.content_rating!] }) : undefined}>
<Filterable
onClick={
onFilter
? () => onFilter({ contentRatings: [show.content_rating!] })
: undefined
}
>
· {show.content_rating}
</Filterable>
)}
{show.season_count != null && <span>· {t("plex.seasons", { count: show.season_count })}</span>}
{show.season_count != null && (
<span>· {t("plex.seasons", { count: show.season_count })}</span>
)}
{show.imdb_rating != null && (
<button
onClick={onFilter ? () => onFilter({ ratingMin: Math.floor(show.imdb_rating!) }) : undefined}
onClick={
onFilter
? () => onFilter({ ratingMin: Math.floor(show.imdb_rating!) })
: undefined
}
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
>
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
@@ -1023,7 +1104,9 @@ function PlexShowView({
</div>
)}
{show.summary && (
<p className="text-sm text-muted mt-3 leading-relaxed line-clamp-5">{show.summary}</p>
<p className="text-sm text-muted mt-3 leading-relaxed line-clamp-5">
{show.summary}
</p>
)}
<div className="mt-4 flex flex-wrap gap-2">
{show.resume && (
@@ -1036,23 +1119,35 @@ function PlexShowView({
/>
)}
{show.first && show.status !== "new" && (
<ActionBtn onClick={() => onPlay(show.first!.id, allKeys)} icon={RotateCcw} label={t("plex.series.playFromStart")} />
<ActionBtn
onClick={() => onPlay(show.first!.id, allKeys)}
icon={RotateCcw}
label={t("plex.series.playFromStart")}
/>
)}
<ActionBtn
onClick={() => markAll(!watched)}
disabled={busy}
icon={watched ? CheckCheck : Check}
label={watched ? t("plex.series.markShowUnwatched") : t("plex.series.markShowWatched")}
label={
watched ? t("plex.series.markShowUnwatched") : t("plex.series.markShowWatched")
}
/>
{allKeys.length > 0 && (
<ActionBtn
onClick={() => setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title })}
onClick={() =>
setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title })
}
icon={ListPlus}
label={t("plex.playlist.addShow")}
/>
)}
{isAdmin && showLib && (
<ActionBtn onClick={() => setCollOpen(true)} icon={Layers} label={t("plex.series.addShowCollection")} />
<ActionBtn
onClick={() => setCollOpen(true)}
icon={Layers}
label={t("plex.series.addShowCollection")}
/>
)}
</div>
</div>
@@ -1087,7 +1182,9 @@ function PlexShowView({
</section>
{show.cast.length > 0 && showCast && <CastStrip cast={show.cast} onFilter={onFilter} />}
{d.related.length > 0 && showRelated && <RelatedStrip related={d.related} onOpen={onOpenShow} />}
{d.related.length > 0 && showRelated && (
<RelatedStrip related={d.related} onOpen={onOpenShow} />
)}
</>
)}
@@ -1180,11 +1277,16 @@ function PlexSeasonView({
)}
</button>
<div className="min-w-0 flex-1">
<button onClick={onBack} className="text-sm text-muted hover:text-accent transition text-left">
<button
onClick={onBack}
className="text-sm text-muted hover:text-accent transition text-left"
>
{d.show.title}
</button>
<h1 className="text-2xl font-semibold leading-tight">{se.title}</h1>
<p className="text-sm text-muted mt-1">{t("plex.series.episodeCount", { count: se.episode_count })}</p>
<p className="text-sm text-muted mt-1">
{t("plex.series.episodeCount", { count: se.episode_count })}
</p>
<div className="mt-4 flex flex-wrap gap-2">
{se.resume && (
<ActionBtn
@@ -1196,18 +1298,30 @@ function PlexSeasonView({
/>
)}
{se.first && se.status !== "new" && (
<ActionBtn onClick={() => onPlay(se.first!.id, seasonKeys)} icon={RotateCcw} label={t("plex.series.playFromStart")} />
<ActionBtn
onClick={() => onPlay(se.first!.id, seasonKeys)}
icon={RotateCcw}
label={t("plex.series.playFromStart")}
/>
)}
<ActionBtn
onClick={() => markAll(!watched)}
disabled={busy}
icon={watched ? CheckCheck : Check}
label={watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")}
label={
watched
? t("plex.series.markSeasonUnwatched")
: t("plex.series.markSeasonWatched")
}
/>
{seasonKeys.length > 0 && (
<ActionBtn
onClick={() =>
setAddTarget({ kind: "group", ratingKeys: seasonKeys, title: `${d.show.title}${se.title}` })
setAddTarget({
kind: "group",
ratingKeys: seasonKeys,
title: `${d.show.title}${se.title}`,
})
}
icon={ListPlus}
label={t("plex.playlist.addSeason")}
@@ -44,7 +44,10 @@ export default function PlexCollectionEditor({
);
// Existing Plex collections you could take over (plain, non-smart, non-auto) but haven't marked yet.
const eligible = useMemo(
() => (listQ.data?.collections ?? []).filter((c) => !c.can_edit && !c.smart && c.source === "collection"),
() =>
(listQ.data?.collections ?? []).filter(
(c) => !c.can_edit && !c.smart && c.source === "collection",
),
[listQ.data],
);
const term = q.trim().toLowerCase();
@@ -102,7 +105,8 @@ export default function PlexCollectionEditor({
}
async function removeCollection(id: string, title: string) {
if (!(await confirm({ message: t("plex.collEditor.deleteConfirm", { title }), danger: true }))) return;
if (!(await confirm({ message: t("plex.collEditor.deleteConfirm", { title }), danger: true })))
return;
setBusy(id);
try {
await api.plexDeleteCollection(id);
@@ -210,7 +214,10 @@ export default function PlexCollectionEditor({
className="mt-2 max-h-40 space-y-1 overflow-y-auto no-scrollbar"
>
{eligible.map((c) => (
<div key={c.id} className="glass-card flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm">
<div
key={c.id}
className="glass-card flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm"
>
<span className="min-w-0 flex-1 truncate">{c.title}</span>
<span className="shrink-0 text-xs text-muted">
{t("plex.collEditor.count", { count: c.child_count ?? 0 })}
+293 -257
View File
@@ -4,7 +4,13 @@ import { useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { DetailCustomizeMenu, Filterable, PrefToggle, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
import {
DetailCustomizeMenu,
Filterable,
PrefToggle,
useArtBackdrop,
useDetailPrefs,
} from "../lib/plexDetailUi";
import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
@@ -60,7 +66,7 @@ export default function PlexInfo({
return next;
});
};
const isAdmin = (qc.getQueryData<{ role?: string }>(["me"])?.role) === "admin";
const isAdmin = qc.getQueryData<{ role?: string }>(["me"])?.role === "admin";
const [editorOpen, setEditorOpen] = useState(false);
const [playlistOpen, setPlaylistOpen] = useState(false);
@@ -93,215 +99,236 @@ export default function PlexInfo({
<div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
<div className={overlay ? "p-5" : "flex flex-col gap-4 p-3 sm:p-4"}>
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
<div
className={overlay ? "" : "glass rounded-2xl p-4 sm:p-6"}
>
{/* Header: close (overlay) / customize */}
<div className="mb-4 flex items-start gap-3">
<div className="min-w-0 flex-1">
<h1 className={overlay ? "text-lg font-bold text-white" : "text-2xl font-bold"}>
{detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title}
</h1>
{detail.kind === "episode" && (
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
S{detail.season_number}·E{detail.episode_number} {detail.title}
</p>
)}
</div>
<DetailCustomizeMenu
overlay={overlay}
artBg={artBg}
onToggleArtBg={toggleArtBg}
showCast={showCast}
onToggleCast={toggleCast}
hasCast={detail.cast.length > 0}
extra={presentSources.map((src) => (
<PrefToggle
key={src}
label={sourceLabel(src)}
on={!hiddenSources.includes(src)}
onClick={() => toggleSource(src)}
/>
))}
/>
{overlay && onClose && (
<button
onClick={onClose}
className="shrink-0 p-1.5 rounded-lg text-white/80 hover:bg-white/15"
aria-label={t("plex.player.back")}
>
<X className="w-5 h-5" />
</button>
)}
</div>
<div className="flex gap-4 sm:gap-6">
{/* Poster */}
<img
src={detail.thumb}
alt=""
className={`shrink-0 rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : "w-32 sm:w-44"} aspect-[2/3]`}
/>
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
{/* Meta line + IMDb rating/link (each value can set the matching filter). */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
{detail.year != null && (
<Filterable
className={mutedCls}
title={onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined}
onClick={onFilter ? () => onFilter({ yearMin: detail.year, yearMax: detail.year }) : undefined}
>
{detail.year}
</Filterable>
)}
{detail.content_rating && (
<Filterable
className={mutedCls}
onClick={onFilter ? () => onFilter({ contentRatings: [detail.content_rating!] }) : undefined}
>
{detail.content_rating}
</Filterable>
)}
{detail.duration_seconds ? <span className={mutedCls}>{formatRuntime(detail.duration_seconds)}</span> : null}
{detail.studio && (
<Filterable
className={mutedCls}
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
>
{detail.studio}
</Filterable>
)}
{detail.imdb_rating != null && (
<span className="inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500">
<button
onClick={onFilter ? () => onFilter({ ratingMin: Math.floor(detail.imdb_rating!) }) : undefined}
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
title={onFilter ? t("plex.info.filterRating", { n: Math.floor(detail.imdb_rating!) }) : undefined}
>
<Star className="w-3.5 h-3.5 fill-current" />
{detail.imdb_rating.toFixed(1)}
</button>
{detail.imdb_url && (
<a href={detail.imdb_url} target="_blank" rel="noopener noreferrer" title={t("plex.info.openImdb")}>
<ExternalLink className="w-3 h-3 opacity-70 hover:opacity-100" />
</a>
)}
</span>
<div className={overlay ? "" : "glass rounded-2xl p-4 sm:p-6"}>
{/* Header: close (overlay) / customize */}
<div className="mb-4 flex items-start gap-3">
<div className="min-w-0 flex-1">
<h1 className={overlay ? "text-lg font-bold text-white" : "text-2xl font-bold"}>
{detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title}
</h1>
{detail.kind === "episode" && (
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
S{detail.season_number}·E{detail.episode_number} {detail.title}
</p>
)}
</div>
{detail.tagline && (
<p className={`mt-2 text-sm italic ${overlay ? "text-white/60" : "text-muted"}`}>
{detail.tagline}
</p>
)}
{detail.genres.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{detail.genres.map((g) => (
<Filterable
key={g}
className={`rounded-full px-2 py-0.5 text-xs ${overlay ? "bg-white/10" : "bg-surface text-muted"}`}
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
>
{g}
</Filterable>
))}
</div>
)}
{detail.directors.length > 0 && (
<p className={`mt-2 text-sm ${mutedCls}`}>
{t("plex.info.director")}:{" "}
{detail.directors.map((d, i) => (
<span key={d}>
<Filterable
className="font-medium"
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
>
{d}
</Filterable>
{i < detail.directors.length - 1 ? ", " : ""}
</span>
))}
</p>
)}
{detail.summary && (
<p className={`mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}>
{detail.summary}
</p>
)}
{/* Page actions: Play/Resume + watch controls */}
{!overlay && (
<div className="mt-4 flex flex-wrap items-center gap-2">
{onPlay && (
<button
onClick={onPlay}
className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-white hover:opacity-90"
>
<Play className="w-5 h-5 fill-current" />
{inProgress ? t("plex.resume") : t("plex.play")}
</button>
)}
{detail.status === "watched" ? (
<button
onClick={() => setState("new")}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<RotateCcw className="w-4 h-4" />
{t("plex.info.markUnwatched")}
</button>
) : (
<button
onClick={() => setState("watched")}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<Check className="w-4 h-4" />
{t("plex.info.markWatched")}
</button>
)}
{inProgress && (
<button
onClick={() => setState("new")}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<RotateCcw className="w-4 h-4" />
{t("plex.info.clearResume")}
</button>
)}
{detail.kind === "movie" && (
<button
onClick={() => setPlaylistOpen(true)}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<ListPlus className="w-4 h-4" />
{t("plex.playlistAdd.manage")}
</button>
)}
{isAdmin && detail.kind === "movie" && library && (
<button
onClick={() => setEditorOpen(true)}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<FolderPlus className="w-4 h-4" />
{t("plex.collEditor.manage")}
</button>
)}
</div>
<DetailCustomizeMenu
overlay={overlay}
artBg={artBg}
onToggleArtBg={toggleArtBg}
showCast={showCast}
onToggleCast={toggleCast}
hasCast={detail.cast.length > 0}
extra={presentSources.map((src) => (
<PrefToggle
key={src}
label={sourceLabel(src)}
on={!hiddenSources.includes(src)}
onClick={() => toggleSource(src)}
/>
))}
/>
{overlay && onClose && (
<button
onClick={onClose}
className="shrink-0 p-1.5 rounded-lg text-white/80 hover:bg-white/15"
aria-label={t("plex.player.back")}
>
<X className="w-5 h-5" />
</button>
)}
</div>
</div>
<div className="flex gap-4 sm:gap-6">
{/* Poster */}
<img
src={detail.thumb}
alt=""
className={`shrink-0 rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : "w-32 sm:w-44"} aspect-[2/3]`}
/>
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
{/* Meta line + IMDb rating/link (each value can set the matching filter). */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
{detail.year != null && (
<Filterable
className={mutedCls}
title={onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined}
onClick={
onFilter
? () => onFilter({ yearMin: detail.year, yearMax: detail.year })
: undefined
}
>
{detail.year}
</Filterable>
)}
{detail.content_rating && (
<Filterable
className={mutedCls}
onClick={
onFilter
? () => onFilter({ contentRatings: [detail.content_rating!] })
: undefined
}
>
{detail.content_rating}
</Filterable>
)}
{detail.duration_seconds ? (
<span className={mutedCls}>{formatRuntime(detail.duration_seconds)}</span>
) : null}
{detail.studio && (
<Filterable
className={mutedCls}
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
>
{detail.studio}
</Filterable>
)}
{detail.imdb_rating != null && (
<span className="inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500">
<button
onClick={
onFilter
? () => onFilter({ ratingMin: Math.floor(detail.imdb_rating!) })
: undefined
}
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
title={
onFilter
? t("plex.info.filterRating", { n: Math.floor(detail.imdb_rating!) })
: undefined
}
>
<Star className="w-3.5 h-3.5 fill-current" />
{detail.imdb_rating.toFixed(1)}
</button>
{detail.imdb_url && (
<a
href={detail.imdb_url}
target="_blank"
rel="noopener noreferrer"
title={t("plex.info.openImdb")}
>
<ExternalLink className="w-3 h-3 opacity-70 hover:opacity-100" />
</a>
)}
</span>
)}
</div>
{detail.tagline && (
<p className={`mt-2 text-sm italic ${overlay ? "text-white/60" : "text-muted"}`}>
{detail.tagline}
</p>
)}
{detail.genres.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{detail.genres.map((g) => (
<Filterable
key={g}
className={`rounded-full px-2 py-0.5 text-xs ${overlay ? "bg-white/10" : "bg-surface text-muted"}`}
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
>
{g}
</Filterable>
))}
</div>
)}
{detail.directors.length > 0 && (
<p className={`mt-2 text-sm ${mutedCls}`}>
{t("plex.info.director")}:{" "}
{detail.directors.map((d, i) => (
<span key={d}>
<Filterable
className="font-medium"
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
>
{d}
</Filterable>
{i < detail.directors.length - 1 ? ", " : ""}
</span>
))}
</p>
)}
{detail.summary && (
<p
className={`mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
>
{detail.summary}
</p>
)}
{/* Page actions: Play/Resume + watch controls */}
{!overlay && (
<div className="mt-4 flex flex-wrap items-center gap-2">
{onPlay && (
<button
onClick={onPlay}
className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-white hover:opacity-90"
>
<Play className="w-5 h-5 fill-current" />
{inProgress ? t("plex.resume") : t("plex.play")}
</button>
)}
{detail.status === "watched" ? (
<button
onClick={() => setState("new")}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<RotateCcw className="w-4 h-4" />
{t("plex.info.markUnwatched")}
</button>
) : (
<button
onClick={() => setState("watched")}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<Check className="w-4 h-4" />
{t("plex.info.markWatched")}
</button>
)}
{inProgress && (
<button
onClick={() => setState("new")}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<RotateCcw className="w-4 h-4" />
{t("plex.info.clearResume")}
</button>
)}
{detail.kind === "movie" && (
<button
onClick={() => setPlaylistOpen(true)}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<ListPlus className="w-4 h-4" />
{t("plex.playlistAdd.manage")}
</button>
)}
{isAdmin && detail.kind === "movie" && library && (
<button
onClick={() => setEditorOpen(true)}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<FolderPlus className="w-4 h-4" />
{t("plex.collEditor.manage")}
</button>
)}
</div>
)}
</div>
</div>
</div>
{/* End hero panel. */}
{/* Cast row (toggleable), circular photos. */}
{cast.length > 0 && (
<div
className={overlay ? "mt-5" : "glass-card rounded-2xl p-4 sm:p-5"}
>
<div className={overlay ? "mt-5" : "glass-card rounded-2xl p-4 sm:p-5"}>
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
{t("plex.info.cast")}
</h2>
@@ -330,7 +357,9 @@ export default function PlexInfo({
{c.name}
</div>
{c.role && (
<div className={`truncate text-[11px] ${overlay ? "text-white/55" : "text-muted"}`}>
<div
className={`truncate text-[11px] ${overlay ? "text-white/55" : "text-muted"}`}
>
{c.role}
</div>
)}
@@ -360,61 +389,68 @@ export default function PlexInfo({
{detail.collections
?.filter((col) => !hiddenSources.includes(col.source))
.map((col) => (
<div
key={col.id}
className={overlay ? "mt-6" : "glass-card rounded-2xl p-4 sm:p-5"}
>
<div className="mb-2 flex items-center justify-between gap-3">
<h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>{col.title}</h2>
{onFilter && (
<button
onClick={() => onFilter({ collection: col.id, collectionTitle: col.title })}
className="shrink-0 text-xs text-muted hover:text-accent"
>
{t("plex.info.browseCollection")}
</button>
)}
<div key={col.id} className={overlay ? "mt-6" : "glass-card rounded-2xl p-4 sm:p-5"}>
<div className="mb-2 flex items-center justify-between gap-3">
<h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
{col.title}
</h2>
{onFilter && (
<button
onClick={() => onFilter({ collection: col.id, collectionTitle: col.title })}
className="shrink-0 text-xs text-muted hover:text-accent"
>
{t("plex.info.browseCollection")}
</button>
)}
</div>
<div className="flex gap-3 overflow-x-auto pb-2">
{col.items.map((m) => (
<button
key={m.id}
onClick={() => onPlayItem?.(m.id)}
disabled={!onPlayItem}
className="group/col w-28 shrink-0 text-left"
>
<div className="relative aspect-[2/3] overflow-hidden rounded-lg border border-border bg-surface">
<img
src={m.thumb}
alt=""
className="h-full w-full object-cover transition group-hover/col:scale-105"
/>
{m.status === "watched" && (
<span className="absolute right-1 top-1 rounded bg-black/70 px-1 text-[9px] text-white">
</span>
)}
{onPlayItem && (
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover/col:opacity-100">
<Play className="h-8 w-8 text-white" fill="currentColor" />
</div>
)}
{(m.position_seconds ?? 0) > 0 &&
m.status !== "watched" &&
m.duration_seconds ? (
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-black/40">
<div
className="h-full bg-accent"
style={{
width: `${Math.min(100, ((m.position_seconds ?? 0) / m.duration_seconds) * 100)}%`,
}}
/>
</div>
) : null}
</div>
<div
className={`mt-1 truncate text-xs font-medium ${overlay ? "text-white/90" : ""}`}
>
{m.title}
</div>
{m.year ? <div className="text-[11px] text-muted">{m.year}</div> : null}
</button>
))}
</div>
</div>
<div className="flex gap-3 overflow-x-auto pb-2">
{col.items.map((m) => (
<button
key={m.id}
onClick={() => onPlayItem?.(m.id)}
disabled={!onPlayItem}
className="group/col w-28 shrink-0 text-left"
>
<div className="relative aspect-[2/3] overflow-hidden rounded-lg border border-border bg-surface">
<img
src={m.thumb}
alt=""
className="h-full w-full object-cover transition group-hover/col:scale-105"
/>
{m.status === "watched" && (
<span className="absolute right-1 top-1 rounded bg-black/70 px-1 text-[9px] text-white"></span>
)}
{onPlayItem && (
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover/col:opacity-100">
<Play className="h-8 w-8 text-white" fill="currentColor" />
</div>
)}
{(m.position_seconds ?? 0) > 0 && m.status !== "watched" && m.duration_seconds ? (
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-black/40">
<div
className="h-full bg-accent"
style={{ width: `${Math.min(100, ((m.position_seconds ?? 0) / m.duration_seconds) * 100)}%` }}
/>
</div>
) : null}
</div>
<div className={`mt-1 truncate text-xs font-medium ${overlay ? "text-white/90" : ""}`}>
{m.title}
</div>
{m.year ? <div className="text-[11px] text-muted">{m.year}</div> : null}
</button>
))}
</div>
</div>
))}
))}
</div>
{editorOpen && library && (
<PlexCollectionEditor
+173 -57
View File
@@ -54,7 +54,7 @@ function audioOrdForLang(streams: AudioStream[], lang: string): number | null {
// absent (-1) or already the FIRST track (0, which the backend plays by default) → no override.
// (Match by index, NOT the `default` flag — some files mark a non-first track default, which made
// the restored language silently fall back to track 0.)
return i <= 0 ? null : streams[i].ord;
return i <= 0 ? null : streams[i]!.ord; // i > 0 here, so streams[i] exists
}
function subOrdForLang(subs: SubStream[], lang: string): number | null {
if (!lang) return null; // "" = subtitles off
@@ -126,8 +126,14 @@ function subShadowCss(px: number, color: string): string {
const o = px;
const b = Math.max(1, Math.round(px / 2));
const dirs: [number, number][] = [
[o, 0], [-o, 0], [0, o], [0, -o],
[o, o], [o, -o], [-o, o], [-o, -o],
[o, 0],
[-o, 0],
[0, o],
[0, -o],
[o, o],
[o, -o],
[-o, o],
[-o, -o],
];
const outline = dirs.map(([x, y]) => `${x}px ${y}px ${b}px ${color}`).join(",");
return `text-shadow:${outline},0 0 ${o * 2}px ${color};`;
@@ -147,7 +153,8 @@ function clockParts(now: Date, p: PlexPlayerPrefs): { time: string; date: string
const yyyy = now.getFullYear();
const mon = now.toLocaleString(loc, { month: "short" }).replace(/\./g, "").toUpperCase();
const dow = now.toLocaleString(loc, { weekday: "short" }).replace(/\./g, "");
const date = p.clockDateStyle === "hu" ? `${yyyy}-${mon}-${dd} ${dow}` : `${dd}-${mon}-${yyyy} ${dow}`;
const date =
p.clockDateStyle === "hu" ? `${yyyy}-${mon}-${dd} ${dow}` : `${dd}-${mon}-${yyyy} ${dow}`;
return { time, date };
}
// hls.js plays our non-zero-start `-copyts` HLS stream ~1.0s BEHIND the media clock (measured by
@@ -242,7 +249,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
}, [prefs.subOffset]);
const [menuOpen, setMenuOpen] = useState(false); // gear = tuning (tabbed)
const [tracksOpen, setTracksOpen] = useState(false); // separate quick menu = audio/subtitle tracks
const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">("sync");
const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">(
"sync",
);
const audioRef = useRef<number | null>(null);
const menuOpenRef = useRef(false);
const menuRef = useRef<HTMLDivElement>(null);
@@ -263,7 +272,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
tracksRef.current = node as HTMLDivElement | null;
tracksFade.ref(node);
},
[tracksFade.ref]
[tracksFade.ref],
);
// Keyboard-shortcut cheat sheet (toggled with "h") + a live wall clock for the top bar.
const [helpOpen, setHelpOpen] = useState(false);
@@ -313,7 +322,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// hls.js plays our non-zero-start copyts stream ~1s BEHIND the media clock (see HLS_SUB_LAG);
// fold that into the session start so the seekbar clock, seeking AND the subtitle shift are all
// content-accurate. Direct-play (start_s 0) uses the file's real clock — no compensation.
const trueStart = sess.mode === "hls" && sess.start_s > 0 ? sess.start_s - HLS_SUB_LAG : sess.start_s;
const trueStart =
sess.mode === "hls" && sess.start_s > 0 ? sess.start_s - HLS_SUB_LAG : sess.start_s;
sessionStartRef.current = trueStart;
setSessionK(trueStart);
// Belt-and-suspenders resume: the play() fired on MANIFEST_PARSED / loadedmetadata can be
@@ -466,7 +476,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// sees the resume position when the user actually stops — not a timeline call every 10 seconds.
const flush = (final = false) => {
if (absRef.current > 0 && durationRef.current > 0)
api.plexProgress(id, Math.floor(absRef.current), durationRef.current, final).catch(() => {});
api
.plexProgress(id, Math.floor(absRef.current), durationRef.current, final)
.catch(() => {});
};
const iv = window.setInterval(flush, 10000);
// Pausing is a settled position — push it to Plex now rather than waiting for unmount/unload.
@@ -544,8 +556,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
},
[applyVolume, patchPrefs],
);
const nudgeVolume = useCallback((d: number) => setVol(clamp01(prefs.volume + d), clamp01(prefs.volume + d) === 0), [prefs.volume, setVol]);
const toggleMute = useCallback(() => setVol(prefs.volume, !prefs.muted), [prefs.volume, prefs.muted, setVol]);
const nudgeVolume = useCallback(
(d: number) => setVol(clamp01(prefs.volume + d), clamp01(prefs.volume + d) === 0),
[prefs.volume, setVol],
);
const toggleMute = useCallback(
() => setVol(prefs.volume, !prefs.muted),
[prefs.volume, prefs.muted, setVol],
);
// Re-apply the persisted volume/mute to the <video> each time a session becomes ready (the element
// is fresh after a load/seek-restart, and starts at its default volume otherwise).
useEffect(() => {
@@ -572,29 +590,30 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
return () => document.removeEventListener("fullscreenchange", onFs);
}, []);
const go = useCallback(
(otherId: string | null | undefined) => {
if (otherId) {
// Stop the outgoing media BEFORE switching. If the next item fails to load (e.g. no local
// file), loadSession returns without replacing the <video> source, leaving the old episode
// still playing — it would then fire 'ended' against the NEW id and mark the wrong item
// watched (and scrobble it to Plex). Pausing here severs that stale-tail race.
try {
videoRef.current?.pause();
} catch {
/* ignore */
}
setAbs(0);
setId(otherId);
const go = useCallback((otherId: string | null | undefined) => {
if (otherId) {
// Stop the outgoing media BEFORE switching. If the next item fails to load (e.g. no local
// file), loadSession returns without replacing the <video> source, leaving the old episode
// still playing — it would then fire 'ended' against the NEW id and mark the wrong item
// watched (and scrobble it to Plex). Pausing here severs that stale-tail race.
try {
videoRef.current?.pause();
} catch {
/* ignore */
}
},
[],
);
setAbs(0);
setId(otherId);
}
}, []);
// Prev/next: from the playlist queue when playing one, else the item's own episode neighbours.
const qIdx = queue ? queue.indexOf(id) : -1;
const prevId = queue ? (qIdx > 0 ? queue[qIdx - 1] : null) : detail?.prev_id;
const nextId = queue ? (qIdx >= 0 && qIdx < queue.length - 1 ? queue[qIdx + 1] : null) : detail?.next_id;
const nextId = queue
? qIdx >= 0 && qIdx < queue.length - 1
? queue[qIdx + 1]
: null
: detail?.next_id;
// Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link.
const download = useCallback(() => {
@@ -613,7 +632,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
audioRef.current = ord;
setAudioOrd(ord);
// Remember the LANGUAGE (not the ord) so F5 / the next item restores this choice. Menu stays open.
patchPrefs({ audioLang: ord == null ? "" : (detail?.audio_streams.find((s) => s.ord === ord)?.language ?? "") });
patchPrefs({
audioLang:
ord == null ? "" : (detail?.audio_streams.find((s) => s.ord === ord)?.language ?? ""),
});
if (multiAudioRef.current && hlsRef.current) {
// Multi-rendition: switch the audio track CLIENT-SIDE — instant, same timeline, no restart.
try {
@@ -632,7 +654,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const changeSubtitle = useCallback(
(ord: number | null) => {
setSubOrd(ord);
patchPrefs({ subLang: ord == null ? "" : (detail?.subtitle_streams.find((s) => s.ord === ord)?.language ?? "") });
patchPrefs({
subLang:
ord == null ? "" : (detail?.subtitle_streams.find((s) => s.ord === ord)?.language ?? ""),
});
},
[detail, patchPrefs],
);
@@ -641,14 +666,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const streams = detail?.audio_streams ?? [];
if (streams.length < 2) return;
const idx = streams.findIndex((s) => s.ord === (audioRef.current ?? 0));
const next = streams[(idx + 1) % streams.length];
const next = streams[(idx + 1) % streams.length]!; // length >= 2 (guarded), so in-bounds
changeAudio(next.ord === 0 ? null : next.ord);
}, [detail, changeAudio]);
const cycleSubtitle = useCallback(() => {
const subs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
const order: (number | null)[] = [null, ...subs.map((s) => s.ord)];
const i = order.findIndex((o) => o === subOrd);
changeSubtitle(order[(i + 1) % order.length]);
changeSubtitle(order[(i + 1) % order.length]!); // order always has [null, ...] → non-empty
}, [detail, subOrd, changeSubtitle]);
// Audio A/V-sync offset: persist immediately (for the slider) but debounce the session restart so
// dragging doesn't respawn ffmpeg on every step.
@@ -677,7 +702,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const settingsTabs = [
{ key: "sync" as const, label: t("plex.player.sync") },
{ key: "playback" as const, label: t("plex.player.playback") },
...(textSubs.length > 0 ? [{ key: "subtitle" as const, label: t("plex.player.subStyle") }] : []),
...(textSubs.length > 0
? [{ key: "subtitle" as const, label: t("plex.player.subStyle") }]
: []),
{ key: "clock" as const, label: t("plex.player.clock") },
];
const activeTab = settingsTabs.some((x) => x.key === settingsTab) ? settingsTab : "sync";
@@ -829,7 +856,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
case "Backspace":
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
e.preventDefault();
if (skipProgressRef.current != null) cancelAutoSkipRef.current(); // cancel auto-skip, don't go back
if (skipProgressRef.current != null)
cancelAutoSkipRef.current(); // cancel auto-skip, don't go back
else handleBack();
break;
case "Escape":
@@ -845,7 +873,22 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [togglePlay, toggleFs, seekTo, go, nudgeVolume, toggleMute, cycleAudio, cycleSubtitle, detail, prevId, nextId, onClose, wake, handleBack]);
}, [
togglePlay,
toggleFs,
seekTo,
go,
nudgeVolume,
toggleMute,
cycleAudio,
cycleSubtitle,
detail,
prevId,
nextId,
onClose,
wake,
handleBack,
]);
const activeMarker: PlexMarker | undefined = detail?.markers.find(
(m) => abs >= m.start_s && abs < m.end_s - 1,
@@ -919,7 +962,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
skipTimerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeMarker?.type, activeMarker?.start_s, prefs.autoSkipIntro, prefs.autoSkipCredits, prefs.autoSkipDelay, doSkip]);
}, [
activeMarker?.type,
activeMarker?.start_s,
prefs.autoSkipIntro,
prefs.autoSkipCredits,
prefs.autoSkipDelay,
doSkip,
]);
// Seekbar hover: a timestamp tooltip that follows the cursor. Store the position as a FRACTION
// (0..1), not pixels: the bar sits inside the transform:scale(1.25) subtree, so a px `left` would be
@@ -974,7 +1024,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
cursor: uiVisible ? "default" : "none",
...(fs
? { width: "100vw", height: "100vh" }
: { width: "80vw", height: "80vh", transform: "scale(1.25)", transformOrigin: "top left" }),
: {
width: "80vw",
height: "80vh",
transform: "scale(1.25)",
transformOrigin: "top left",
}),
}}
>
{/* Per-user subtitle appearance (font size / colour / background). ::cue is global, but this
@@ -1031,7 +1086,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
}`}
>
<div className="flex items-center gap-3 text-white">
<button onClick={handleBack} className="p-1.5 rounded-lg hover:bg-white/15" title={t("plex.player.back")}>
<button
onClick={handleBack}
className="p-1.5 rounded-lg hover:bg-white/15"
title={t("plex.player.back")}
>
<ArrowLeft className="w-5 h-5" />
</button>
<div className="min-w-0">
@@ -1087,7 +1146,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
</>
)}
<span className="relative">
{activeMarker.type === "intro" ? t("plex.player.skipIntro") : t("plex.player.skipCredits")}
{activeMarker.type === "intro"
? t("plex.player.skipIntro")
: t("plex.player.skipCredits")}
</span>
</button>
)}
@@ -1137,16 +1198,25 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{fmt(hover.t)}
</div>
)}
<div className="absolute inset-y-0 left-0 bg-white/30 rounded-full" style={{ width: `${bufPct}%` }} />
<div
className="absolute inset-y-0 left-0 bg-white/30 rounded-full"
style={{ width: `${bufPct}%` }}
/>
{/* intro/credit marker regions */}
{detail?.markers.map((m, i) => (
<div
key={i}
className="absolute inset-y-0 bg-amber-400/50"
style={{ left: `${(m.start_s / duration) * 100}%`, width: `${((m.end_s - m.start_s) / duration) * 100}%` }}
style={{
left: `${(m.start_s / duration) * 100}%`,
width: `${((m.end_s - m.start_s) / duration) * 100}%`,
}}
/>
))}
<div className="absolute inset-y-0 left-0 bg-accent rounded-full" style={{ width: `${headPct}%` }} />
<div
className="absolute inset-y-0 left-0 bg-accent rounded-full"
style={{ width: `${headPct}%` }}
/>
<div
className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent transition-opacity ${
scrub != null ? "opacity-100" : "opacity-0 group-hover:opacity-100"
@@ -1178,7 +1248,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<div className="flex items-center gap-2">
<Ctrl label={t("plex.player.mute")} onClick={toggleMute}>
{muted || volume === 0 ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
{muted || volume === 0 ? (
<VolumeX className="w-5 h-5" />
) : (
<Volume2 className="w-5 h-5" />
)}
</Ctrl>
{/* Volume bar: plain accent fill on a neutral track (as before); hover shows the 0100
value under the cursor; click/drag sets it. */}
@@ -1230,7 +1304,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
pushes later while paused, since the remaining time then rides the advancing clock. */}
{duration > 0 && (
<span className="ml-1.5 text-white/45">
({clockParts(new Date(now.getTime() + Math.max(0, duration - abs) * 1000), prefs).time})
(
{
clockParts(new Date(now.getTime() + Math.max(0, duration - abs) * 1000), prefs)
.time
}
)
</span>
)}
</div>
@@ -1324,7 +1403,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
key={tab.key}
onClick={() => setSettingsTab(tab.key)}
className={`flex-1 whitespace-nowrap rounded px-2 py-1 text-[11px] ${
activeTab === tab.key ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
activeTab === tab.key
? "bg-white/15 text-accent"
: "text-white/70 hover:bg-white/10"
}`}
>
{tab.label}
@@ -1427,7 +1508,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
onChange={(v) => patchPrefs({ subPos: v })}
/>
<div className="flex items-center justify-between px-1 py-1.5">
<span className="text-[12px] text-white/80">{t("plex.player.subColor")}</span>
<span className="text-[12px] text-white/80">
{t("plex.player.subColor")}
</span>
<input
type="color"
value={prefs.subColor}
@@ -1436,13 +1519,19 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
/>
</div>
<div className="flex items-center justify-between px-1 py-1.5">
<span className="text-[12px] text-white/80">{t("plex.player.subBg")}</span>
<span className="text-[12px] text-white/80">
{t("plex.player.subBg")}
</span>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-[11px] text-white/70">
<input
type="checkbox"
checked={prefs.subBg === "transparent"}
onChange={(e) => patchPrefs({ subBg: e.target.checked ? "transparent" : "#000000" })}
onChange={(e) =>
patchPrefs({
subBg: e.target.checked ? "transparent" : "#000000",
})
}
className="accent-accent"
/>
{t("plex.player.subBgNone")}
@@ -1468,7 +1557,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
/>
{prefs.subShadow > 0 && (
<div className="flex items-center justify-between px-1 py-1.5">
<span className="text-[12px] text-white/80">{t("plex.player.subShadowColor")}</span>
<span className="text-[12px] text-white/80">
{t("plex.player.subShadowColor")}
</span>
<input
type="color"
value={prefs.subShadowColor}
@@ -1502,7 +1593,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
onChange={(v) => patchPrefs({ clockSize: v })}
/>
<div className="flex items-center justify-between px-1 py-1.5">
<span className="text-[12px] text-white/80">{t("plex.player.clockColor")}</span>
<span className="text-[12px] text-white/80">
{t("plex.player.clockColor")}
</span>
<input
type="color"
value={prefs.clockColor}
@@ -1522,10 +1615,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
key={st}
onClick={() => patchPrefs({ clockDateStyle: st })}
className={`flex-1 rounded px-2 py-1 text-[11px] ${
prefs.clockDateStyle === st ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
prefs.clockDateStyle === st
? "bg-white/15 text-accent"
: "text-white/70 hover:bg-white/10"
}`}
>
{clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date}
{
clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st })
.date
}
</button>
))}
</div>
@@ -1635,7 +1733,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
</div>
)}
</div>,
document.body
document.body,
);
}
@@ -1648,7 +1746,11 @@ function BackClose({ onBack }: { onBack: () => void }) {
// --- settings-panel building blocks ------------------------------------------------------------
function PanelHead({ children }: { children: ReactNode }) {
return <div className="px-1 pt-2 pb-1 text-[11px] uppercase tracking-wide text-white/50">{children}</div>;
return (
<div className="px-1 pt-2 pb-1 text-[11px] uppercase tracking-wide text-white/50">
{children}
</div>
);
}
function Slider({
label,
@@ -1697,11 +1799,24 @@ function Slider({
</div>
);
}
function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
function Toggle({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className="flex cursor-pointer items-center justify-between px-1 py-1.5">
<span className="text-[12px] text-white/80">{label}</span>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} className="h-4 w-4 accent-accent" />
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 accent-accent"
/>
</label>
);
}
@@ -1722,7 +1837,8 @@ function Ctrl({
align?: "start" | "center" | "end";
children: ReactNode;
}) {
const pos = align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
const pos =
align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
return (
<div className="relative flex group/ctrl">
<button
+21 -5
View File
@@ -15,7 +15,13 @@ export type PlexAddTarget =
// "Add to playlist" dialog — per-user (every user has their own playlists). Opened from the movie info
// page (single) and the show page (single episode / whole season / whole show). Lists my playlists with
// an in/out (or partial) state and a field to create a new one seeded with the target.
export default function PlexPlaylistAdd({ target, onClose }: { target: PlexAddTarget; onClose: () => void }) {
export default function PlexPlaylistAdd({
target,
onClose,
}: {
target: PlexAddTarget;
onClose: () => void;
}) {
const { t } = useTranslation();
const fade = useScrollFade();
const qc = useQueryClient();
@@ -32,7 +38,9 @@ export default function PlexPlaylistAdd({ target, onClose }: { target: PlexAddTa
? ["plex-playlists", "group", target.ratingKeys]
: ["plex-playlists", "contains", target.ratingKey],
queryFn: () =>
isGroup ? api.plexPlaylists(undefined, target.ratingKeys) : api.plexPlaylists(target.ratingKey),
isGroup
? api.plexPlaylists(undefined, target.ratingKeys)
: api.plexPlaylists(target.ratingKey),
});
const playlists = listQ.data?.playlists ?? [];
const refresh = () => qc.invalidateQueries({ queryKey: ["plex-playlists"] });
@@ -41,7 +49,7 @@ export default function PlexPlaylistAdd({ target, onClose }: { target: PlexAddTa
function inCount(p: PlexPlaylist): number {
const o = override.get(p.id);
if (o !== undefined) return o;
return isGroup ? p.group_in ?? 0 : p.has_item ? 1 : 0;
return isGroup ? (p.group_in ?? 0) : p.has_item ? 1 : 0;
}
async function toggle(p: PlexPlaylist) {
@@ -103,7 +111,11 @@ export default function PlexPlaylistAdd({ target, onClose }: { target: PlexAddTa
</button>
</div>
<div ref={fade.ref} style={fade.style} className="max-h-80 space-y-1 overflow-y-auto no-scrollbar">
<div
ref={fade.ref}
style={fade.style}
className="max-h-80 space-y-1 overflow-y-auto no-scrollbar"
>
{listQ.isLoading ? (
<p className="py-4 text-center text-sm text-muted">{t("plex.loading")}</p>
) : playlists.length === 0 ? (
@@ -127,7 +139,11 @@ export default function PlexPlaylistAdd({ target, onClose }: { target: PlexAddTa
all || partial ? "border-accent bg-accent/20" : "border-border"
}`}
>
{all ? <Check className="h-3.5 w-3.5" /> : partial ? <Minus className="h-3.5 w-3.5" /> : null}
{all ? (
<Check className="h-3.5 w-3.5" />
) : partial ? (
<Minus className="h-3.5 w-3.5" />
) : null}
</span>
<span className="min-w-0 flex-1 truncate">{p.title}</span>
{isGroup ? (
+101 -24
View File
@@ -44,7 +44,14 @@ type Layout = "accordion" | "tree";
type Season = { key: string; number: number | null; items: PlexCard[] };
type Block =
| { kind: "movie"; id: string; item: PlexCard }
| { kind: "show"; id: string; showKey: string; title: string; items: PlexCard[]; seasons: Season[] };
| {
kind: "show";
id: string;
showKey: string;
title: string;
items: PlexCard[];
seasons: Season[];
};
function buildSeasons(items: PlexCard[]): Season[] {
const map = new Map<string, Season>();
@@ -106,7 +113,9 @@ type Ctx = {
function Poster({ src, className }: { src: string; className: string }) {
return (
<div className={`relative shrink-0 overflow-hidden rounded border border-border bg-surface ${className}`}>
<div
className={`relative shrink-0 overflow-hidden rounded border border-border bg-surface ${className}`}
>
<img src={src} alt="" loading="lazy" className="h-full w-full object-cover" />
</div>
);
@@ -129,7 +138,11 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
id: ep.id,
disabled: ctx.busy,
});
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.6 : 1 };
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
};
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
return (
<div
@@ -138,12 +151,21 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
className={`flex items-center gap-2.5 rounded-lg ${dense ? "px-1.5 py-1" : "glass-card p-1.5"}`}
>
<DragHandle {...attributes} {...listeners} />
<span className="w-5 shrink-0 text-center text-[11px] text-muted tabular-nums">{ep.episode_number}</span>
<button onClick={() => ctx.onPlay(ep.id)} className="group flex min-w-0 flex-1 items-center gap-2.5 text-left">
<div className={`relative shrink-0 overflow-hidden rounded border border-border bg-surface ${dense ? "aspect-video w-14" : "aspect-video w-20"}`}>
<span className="w-5 shrink-0 text-center text-[11px] text-muted tabular-nums">
{ep.episode_number}
</span>
<button
onClick={() => ctx.onPlay(ep.id)}
className="group flex min-w-0 flex-1 items-center gap-2.5 text-left"
>
<div
className={`relative shrink-0 overflow-hidden rounded border border-border bg-surface ${dense ? "aspect-video w-14" : "aspect-video w-20"}`}
>
<img src={ep.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
{ep.status === "watched" && (
<span className="absolute right-0.5 top-0.5 rounded bg-black/70 px-1 text-[9px] text-white"></span>
<span className="absolute right-0.5 top-0.5 rounded bg-black/70 px-1 text-[9px] text-white">
</span>
)}
{inProgress && <div className="absolute inset-x-0 bottom-0 h-0.5 bg-accent" />}
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover:opacity-100">
@@ -169,12 +191,23 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
id: item.id,
disabled: ctx.busy,
});
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.6 : 1 };
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
};
const dense = ctx.layout === "tree";
return (
<div ref={setNodeRef} style={style} className="glass-card flex items-center gap-3 rounded-xl p-2">
<div
ref={setNodeRef}
style={style}
className="glass-card flex items-center gap-3 rounded-xl p-2"
>
<DragHandle {...attributes} {...listeners} />
<button onClick={() => ctx.onPlay(item.id)} className="group flex min-w-0 flex-1 items-center gap-3 text-left">
<button
onClick={() => ctx.onPlay(item.id)}
className="group flex min-w-0 flex-1 items-center gap-3 text-left"
>
<Poster src={item.thumb} className={dense ? "aspect-[2/3] w-10" : "aspect-[2/3] w-14"} />
<div className="min-w-0">
<div className="truncate text-sm font-medium">{item.title}</div>
@@ -202,11 +235,17 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
id: block.id,
disabled: ctx.busy,
});
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.6 : 1 };
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
};
const open = ctx.expandedShows.has(block.showKey);
const dense = ctx.layout === "tree";
const seasonLabel = (s: Season) =>
s.number != null ? t("plex.playlist.season", { n: s.number }) : t("plex.playlist.seasonUnknown");
s.number != null
? t("plex.playlist.season", { n: s.number })
: t("plex.playlist.seasonUnknown");
return (
<div ref={setNodeRef} style={style} className="glass-card overflow-hidden rounded-xl">
@@ -236,7 +275,9 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
)}
<button onClick={() => ctx.toggleShow(block.showKey)} className="min-w-0 flex-1 text-left">
<div className="truncate text-sm font-semibold">{block.title}</div>
<div className="text-[11px] text-muted">{t("plex.playlist.episodes", { count: block.items.length })}</div>
<div className="text-[11px] text-muted">
{t("plex.playlist.episodes", { count: block.items.length })}
</div>
</button>
<button
onClick={() => ctx.onRemove(block.items.map((i) => i.id))}
@@ -250,7 +291,10 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
{open && (
<div className={`border-t border-border/50 ${dense ? "px-2 pb-1.5 pt-1" : "px-2 pb-2"}`}>
<SortableContext items={block.items.map((i) => i.id)} strategy={verticalListSortingStrategy}>
<SortableContext
items={block.items.map((i) => i.id)}
strategy={verticalListSortingStrategy}
>
{block.seasons.map((s) => {
const sKey = `${block.showKey}:${s.key}`;
const sOpen = !dense || !ctx.collapsedSeasons.has(sKey);
@@ -263,7 +307,11 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
className="grid h-5 w-5 shrink-0 place-items-center rounded text-muted hover:text-fg"
aria-expanded={sOpen}
>
{sOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
{sOpen ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
)}
<span className="text-[11px] font-medium uppercase tracking-wide text-muted">
@@ -279,7 +327,11 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
</button>
</div>
{sOpen && (
<div className={dense ? "ml-2 space-y-0.5 border-l border-border/50 pl-1.5" : "space-y-1"}>
<div
className={
dense ? "ml-2 space-y-0.5 border-l border-border/50 pl-1.5" : "space-y-1"
}
>
{s.items.map((ep) => (
<EpisodeRow key={ep.id} ep={ep} ctx={ctx} dense={dense} />
))}
@@ -321,7 +373,10 @@ export default function PlexPlaylistView({
const [expandedShows, setExpandedShows] = useState<Set<string>>(new Set());
const [collapsedSeasons, setCollapsedSeasons] = useState<Set<string>>(new Set());
const q = useQuery({ queryKey: ["plex-playlist", playlistId], queryFn: () => api.plexPlaylist(playlistId) });
const q = useQuery({
queryKey: ["plex-playlist", playlistId],
queryFn: () => api.plexPlaylist(playlistId),
});
// Local optimistic copy so drag/remove are instant; re-synced whenever the query returns.
const [items, setItems] = useState<PlexCard[]>([]);
useEffect(() => {
@@ -338,7 +393,10 @@ export default function PlexPlaylistView({
function commit(next: PlexCard[]) {
setItems(next);
api
.plexReorderPlaylist(playlistId, next.map((i) => i.id))
.plexReorderPlaylist(
playlistId,
next.map((i) => i.id),
)
.then(() => qc.invalidateQueries({ queryKey: ["plex-playlists"] }));
}
@@ -348,7 +406,7 @@ export default function PlexPlaylistView({
const set = new Set(keys);
setItems((prev) => prev.filter((i) => !set.has(i.id)));
try {
if (keys.length === 1) await api.plexPlaylistRemoveItem(playlistId, keys[0]);
if (keys.length === 1) await api.plexPlaylistRemoveItem(playlistId, keys[0]!);
else await api.plexPlaylistRemoveBulk(playlistId, keys);
invalidate();
} finally {
@@ -364,7 +422,12 @@ export default function PlexPlaylistView({
invalidate();
}
async function del() {
if (!(await confirm({ message: t("plex.playlist.deleteConfirm", { title: q.data?.title }), danger: true })))
if (
!(await confirm({
message: t("plex.playlist.deleteConfirm", { title: q.data?.title }),
danger: true,
}))
)
return;
await api.plexDeletePlaylist(playlistId);
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
@@ -413,7 +476,13 @@ export default function PlexPlaylistView({
if (!b || b.kind !== "show" || !b.items.some((it) => it.id === oId)) return;
const from = b.items.findIndex((it) => it.id === aId);
const to = b.items.findIndex((it) => it.id === oId);
commit(flatten(blocks.map((bl) => (bl.id === b.id ? { ...bl, items: arrayMove(b.items, from, to) } : bl))));
commit(
flatten(
blocks.map((bl) =>
bl.id === b.id ? { ...bl, items: arrayMove(b.items, from, to) } : bl,
),
),
);
}
}
@@ -468,8 +537,16 @@ export default function PlexPlaylistView({
)}
{/* Layout preference: Accordion (A) or Tree (C). */}
<div className="flex items-center gap-1">
<LayoutBtn v="accordion" icon={<Rows3 className="h-4 w-4" />} label={t("plex.playlist.layoutAccordion")} />
<LayoutBtn v="tree" icon={<ListTree className="h-4 w-4" />} label={t("plex.playlist.layoutTree")} />
<LayoutBtn
v="accordion"
icon={<Rows3 className="h-4 w-4" />}
label={t("plex.playlist.layoutAccordion")}
/>
<LayoutBtn
v="tree"
icon={<ListTree className="h-4 w-4" />}
label={t("plex.playlist.layoutTree")}
/>
</div>
<button
onClick={() => {
@@ -483,7 +560,7 @@ export default function PlexPlaylistView({
</button>
{items.length > 0 && (
<button
onClick={() => onPlay(order[0], order)}
onClick={() => onPlay(order[0]!, order)}
className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-white hover:opacity-90"
>
<Play className="h-5 w-5 fill-current" />
+1 -8
View File
@@ -1,11 +1,4 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
+29 -13
View File
@@ -70,18 +70,19 @@ function MatchToggle({
);
}
export default function PlexSidebar({
layout,
setLayout,
collapsed,
onToggleCollapse,
}: Props) {
export default function PlexSidebar({ layout, setLayout, collapsed, onToggleCollapse }: Props) {
const { t } = useTranslation();
// Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and
// the header search box); this rail is the primary editor of them. Opening a playlist is a
// hand-off to PlexBrowse via the shared `playlistOpen` slot.
const { scope, show, sort, filters } = usePlex();
const { setScope, setShow, setSort, setFilters, setPlaylistOpen: onOpenPlaylist } = usePlexActions();
const {
setScope,
setShow,
setSort,
setFilters,
setPlaylistOpen: onOpenPlaylist,
} = usePlexActions();
const collFade = useScrollFade();
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
const [newPlaylist, setNewPlaylist] = useState("");
@@ -132,7 +133,8 @@ export default function PlexSidebar({
const durBucketKey = DURATION_BUCKETS.find(
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
)?.key;
const peopleActive = filters.directors.length + filters.actors.length + filters.studios.length > 0;
const peopleActive =
filters.directors.length + filters.actors.length + filters.studios.length > 0;
// Group bodies (no title — the PanelGroup card supplies it). Only available groups are pushed,
// so facet-gated / scope-gated sections drop out cleanly; PanelGroups orders them by the saved
@@ -226,7 +228,11 @@ export default function PlexSidebar({
</ChipRow>
<div className="mt-1.5 flex gap-1">
{(["asc", "desc"] as const).map((d) => (
<Chip key={d} active={(filters.sortDir ?? "asc") === d} onClick={() => patch({ sortDir: d })}>
<Chip
key={d}
active={(filters.sortDir ?? "asc") === d}
onClick={() => patch({ sortDir: d })}
>
{t(`plex.filter.dir.${d}`)}
</Chip>
))}
@@ -310,7 +316,9 @@ export default function PlexSidebar({
</button>
))}
{collections.length === 0 && (
<span className="px-2 py-1 text-xs text-muted">{t("plex.filter.collectionEmpty")}</span>
<span className="px-2 py-1 text-xs text-muted">
{t("plex.filter.collectionEmpty")}
</span>
)}
</div>
</div>
@@ -388,7 +396,9 @@ export default function PlexSidebar({
body: (
<ChipRow>
<Chip
active={!durBucketKey && filters.durationMin == null && filters.durationMax == null}
active={
!durBucketKey && filters.durationMin == null && filters.durationMax == null
}
onClick={() => patch({ durationMin: null, durationMax: null })}
>
{t("plex.filter.any")}
@@ -416,7 +426,11 @@ export default function PlexSidebar({
{t("plex.filter.any")}
</Chip>
{ADDED_OPTS.map((a) => (
<Chip key={a} active={filters.addedWithin === a} onClick={() => patch({ addedWithin: a })}>
<Chip
key={a}
active={filters.addedWithin === a}
onClick={() => patch({ addedWithin: a })}
>
{t(`plex.filter.addedOpt.${a}`)}
</Chip>
))}
@@ -434,7 +448,9 @@ export default function PlexSidebar({
<Chip
key={c.value}
active={filters.contentRatings.includes(c.value)}
onClick={() => patch({ contentRatings: toggleIn(filters.contentRatings, c.value) })}
onClick={() =>
patch({ contentRatings: toggleIn(filters.contentRatings, c.value) })
}
>
{c.value}
</Chip>
+90 -21
View File
@@ -82,7 +82,14 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
});
const onDelete = async (p: DownloadProfile) => {
if (await confirm({ title: t("downloads.profiles.delete"), message: t("downloads.profiles.deleteConfirm", { name: p.name }), confirmLabel: t("downloads.profiles.delete"), danger: true })) {
if (
await confirm({
title: t("downloads.profiles.delete"),
message: t("downloads.profiles.deleteConfirm", { name: p.name }),
confirmLabel: t("downloads.profiles.delete"),
danger: true,
})
) {
del.mutate(p.id);
}
};
@@ -95,23 +102,41 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
<Modal title={t("downloads.profiles.title")} onClose={onClose} maxWidth="max-w-xl">
{/* Existing formats */}
<div className="space-y-1.5 mb-4">
<div className="text-xs uppercase tracking-wide text-muted">{t("downloads.profiles.builtin")}</div>
<div className="text-xs uppercase tracking-wide text-muted">
{t("downloads.profiles.builtin")}
</div>
{builtins.map((p) => (
<div key={p.id} className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg glass-card">
<div
key={p.id}
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg glass-card"
>
<Lock className="w-3.5 h-3.5 text-muted shrink-0" />
<span className="flex-1 truncate">{p.name}</span>
</div>
))}
{mine.length > 0 && (
<div className="text-xs uppercase tracking-wide text-muted pt-2">{t("downloads.profiles.yours")}</div>
<div className="text-xs uppercase tracking-wide text-muted pt-2">
{t("downloads.profiles.yours")}
</div>
)}
{mine.map((p) => (
<div key={p.id} className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg glass-card">
<div
key={p.id}
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg glass-card"
>
<span className="flex-1 truncate">{p.name}</span>
<button onClick={() => startEdit(p)} title={t("downloads.actions.rename")} className="p-1 rounded hover:bg-surface text-muted hover:text-fg">
<button
onClick={() => startEdit(p)}
title={t("downloads.actions.rename")}
className="p-1 rounded hover:bg-surface text-muted hover:text-fg"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={() => onDelete(p)} title={t("downloads.profiles.delete")} className="p-1 rounded hover:bg-surface text-muted hover:text-red-400">
<button
onClick={() => onDelete(p)}
title={t("downloads.profiles.delete")}
className="p-1 rounded hover:bg-surface text-muted hover:text-red-400"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
@@ -129,11 +154,18 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
<div className="rounded-xl border border-border p-4 space-y-3">
<div>
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.name")}</label>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={t("downloads.profiles.namePlaceholder")} className={inputCls} />
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("downloads.profiles.namePlaceholder")}
className={inputCls}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.mode")}</label>
<label className="block text-sm font-medium mb-1">
{t("downloads.profiles.mode")}
</label>
<select value={spec.mode} onChange={set("mode")} className={inputCls}>
<option value="av">{t("downloads.profiles.modeAv")}</option>
<option value="v">{t("downloads.profiles.modeV")}</option>
@@ -142,10 +174,14 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
</div>
{spec.mode !== "a" ? (
<div>
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.quality")}</label>
<label className="block text-sm font-medium mb-1">
{t("downloads.profiles.quality")}
</label>
<select
value={spec.max_height ?? ""}
onChange={(e) => patch({ max_height: e.target.value === "" ? null : Number(e.target.value) })}
onChange={(e) =>
patch({ max_height: e.target.value === "" ? null : Number(e.target.value) })
}
className={inputCls}
>
{HEIGHTS.map((h) => (
@@ -157,8 +193,14 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
</div>
) : (
<div>
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.audioFormat")}</label>
<select value={spec.audio_format ?? "m4a"} onChange={set("audio_format")} className={inputCls}>
<label className="block text-sm font-medium mb-1">
{t("downloads.profiles.audioFormat")}
</label>
<select
value={spec.audio_format ?? "m4a"}
onChange={set("audio_format")}
className={inputCls}
>
<option value="m4a">M4A</option>
<option value="mp3">MP3</option>
<option value="opus">Opus</option>
@@ -168,15 +210,23 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
{spec.mode !== "a" && (
<>
<div>
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.container")}</label>
<select value={spec.container ?? "mp4"} onChange={set("container")} className={inputCls}>
<label className="block text-sm font-medium mb-1">
{t("downloads.profiles.container")}
</label>
<select
value={spec.container ?? "mp4"}
onChange={set("container")}
className={inputCls}
>
<option value="mp4">MP4</option>
<option value="mkv">MKV</option>
<option value="webm">WebM</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.vcodec")}</label>
<label className="block text-sm font-medium mb-1">
{t("downloads.profiles.vcodec")}
</label>
<select value={spec.vcodec ?? ""} onChange={set("vcodec")} className={inputCls}>
<option value="">{t("downloads.profiles.any")}</option>
<option value="h264">H.264</option>
@@ -189,14 +239,33 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
</div>
<div className="space-y-1.5 pt-1">
{spec.mode !== "a" && (
<Check label={t("downloads.profiles.embedSubs")} checked={spec.embed_subs} onChange={(v) => patch({ embed_subs: v })} />
<Check
label={t("downloads.profiles.embedSubs")}
checked={spec.embed_subs}
onChange={(v) => patch({ embed_subs: v })}
/>
)}
<Check label={t("downloads.profiles.embedChapters")} checked={spec.embed_chapters} onChange={(v) => patch({ embed_chapters: v })} />
<Check label={t("downloads.profiles.embedThumbnail")} checked={spec.embed_thumbnail} onChange={(v) => patch({ embed_thumbnail: v })} />
<Check label={t("downloads.profiles.sponsorblock")} checked={spec.sponsorblock} onChange={(v) => patch({ sponsorblock: v })} />
<Check
label={t("downloads.profiles.embedChapters")}
checked={spec.embed_chapters}
onChange={(v) => patch({ embed_chapters: v })}
/>
<Check
label={t("downloads.profiles.embedThumbnail")}
checked={spec.embed_thumbnail}
onChange={(v) => patch({ embed_thumbnail: v })}
/>
<Check
label={t("downloads.profiles.sponsorblock")}
checked={spec.sponsorblock}
onChange={(v) => patch({ sponsorblock: v })}
/>
</div>
<div className="flex justify-end gap-2 pt-1">
<button onClick={() => setFormOpen(false)} className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition">
<button
onClick={() => setFormOpen(false)}
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
>
{t("downloads.actions.cancel")}
</button>
<button
+1 -3
View File
@@ -62,9 +62,7 @@ export default function SavedViewsWidget({
const [draftName, setDraftName] = useState("");
const [renameId, setRenameId] = useState<number | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
// Keep the default-view mirror (read on load) in sync with the server state.
useEffect(() => {
+54 -16
View File
@@ -58,7 +58,13 @@ function statusKey(job: SchedulerJob): keyof typeof DOT_CLASS {
return "idle";
}
function StatusDot({ k, withTooltip = true }: { k: keyof typeof DOT_CLASS; withTooltip?: boolean }) {
function StatusDot({
k,
withTooltip = true,
}: {
k: keyof typeof DOT_CLASS;
withTooltip?: boolean;
}) {
const { t } = useTranslation();
const dot = <span className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${DOT_CLASS[k]}`} />;
return withTooltip ? <Tooltip hint={t(`scheduler.dot.${k}`)}>{dot}</Tooltip> : dot;
@@ -101,7 +107,10 @@ function JobProgress({ p }: { p: SchedulerJob["progress"] }) {
</div>
<div className="h-1.5 rounded-full bg-border overflow-hidden">
{pct != null ? (
<div className="h-full rounded-full bg-accent transition-[width]" style={{ width: `${pct}%` }} />
<div
className="h-full rounded-full bg-accent transition-[width]"
style={{ width: `${pct}%` }}
/>
) : (
// Indeterminate: total unknown, so a slim moving sliver instead of a fill.
<div className="h-full w-1/3 rounded-full bg-accent animate-pulse" />
@@ -164,10 +173,19 @@ function JobRow({
className="w-16 bg-card border border-border rounded px-1.5 py-0.5 text-xs outline-none focus:border-accent"
/>
<span className="text-muted text-xs">{t("scheduler.minutes")}</span>
<button onClick={save} disabled={saving} className="text-emerald-400 hover:opacity-80 disabled:opacity-50" aria-label={t("common.save")}>
<button
onClick={save}
disabled={saving}
className="text-emerald-400 hover:opacity-80 disabled:opacity-50"
aria-label={t("common.save")}
>
<Check className="w-3.5 h-3.5" />
</button>
<button onClick={() => setEditing(false)} className="text-muted hover:text-fg" aria-label={t("common.cancel")}>
<button
onClick={() => setEditing(false)}
className="text-muted hover:text-fg"
aria-label={t("common.cancel")}
>
<X className="w-3.5 h-3.5" />
</button>
</span>
@@ -272,7 +290,9 @@ function MaintenanceSettings({
</button>
</div>
<div className="text-[11px] text-muted mt-1.5">
{t("scheduler.maintenance.batchNote", { default: m.revalidate_batch_default.toLocaleString() })}
{t("scheduler.maintenance.batchNote", {
default: m.revalidate_batch_default.toLocaleString(),
})}
</div>
</div>
);
@@ -296,7 +316,13 @@ function Stat({
<div className="flex items-center gap-2 text-muted text-xs">
<Icon className="w-4 h-4 shrink-0" />
<Tooltip hint={hint ?? ""}>
<span className={hint ? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help" : ""}>
<span
className={
hint
? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help"
: ""
}
>
{label}
</span>
</Tooltip>
@@ -365,14 +391,20 @@ export default function Scheduler() {
const runMut = useMutation({
mutationFn: (jobId: string) => api.runSchedulerJob(jobId),
onSuccess: (_d, jobId) => {
notify({ level: "success", message: t("scheduler.triggered", { job: t(`scheduler.jobs.${jobId}`, jobId) }) });
notify({
level: "success",
message: t("scheduler.triggered", { job: t(`scheduler.jobs.${jobId}`, jobId) }),
});
qc.invalidateQueries({ queryKey: ["scheduler"] });
},
});
const runAllMut = useMutation({
mutationFn: () => api.runAllSchedulerJobs(),
onSuccess: (res) => {
notify({ level: "success", message: t("scheduler.triggeredAll", { count: res.started.length }) });
notify({
level: "success",
message: t("scheduler.triggeredAll", { count: res.started.length }),
});
qc.invalidateQueries({ queryKey: ["scheduler"] });
},
});
@@ -390,10 +422,8 @@ export default function Scheduler() {
},
});
if (q.isLoading && !data)
return <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
if (!data)
return <div className="p-8 text-muted">{t("common.somethingWrong")}</div>;
if (q.isLoading && !data) return <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
if (!data) return <div className="p-8 text-muted">{t("common.somethingWrong")}</div>;
const quotaPct = data.quota.daily_budget
? Math.min(100, Math.round((data.quota.used_today / data.quota.daily_budget) * 100))
@@ -472,7 +502,9 @@ export default function Scheduler() {
{/* Jobs */}
<div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.jobsTitle")}</div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("scheduler.jobsTitle")}
</div>
<StatusLegend />
<div className="space-y-1.5">
{data.jobs.map((job) => (
@@ -490,7 +522,9 @@ export default function Scheduler() {
{/* Queue */}
<div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.queueTitle")}</div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("scheduler.queueTitle")}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<Stat
icon={Database}
@@ -527,7 +561,9 @@ export default function Scheduler() {
{/* Quota */}
<div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.quotaTitle")}</div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("scheduler.quotaTitle")}
</div>
<div className="glass-card rounded-xl p-4">
<div className="flex items-center gap-2 text-sm">
<Gauge className="w-4 h-4 text-muted shrink-0" />
@@ -557,7 +593,9 @@ export default function Scheduler() {
{/* Maintenance settings */}
<div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.maintenance.title")}</div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("scheduler.maintenance.title")}
</div>
<MaintenanceSettings
m={data.maintenance}
onSave={(batch) => batchMut.mutate(batch)}
+84 -77
View File
@@ -193,7 +193,11 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
label={t("settings.appearance.performanceMode")}
hint={t("settings.appearance.performanceModeHint")}
>
<Switch label={t("settings.appearance.performanceMode")} checked={perf} onChange={setPerf} />
<Switch
label={t("settings.appearance.performanceMode")}
checked={perf}
onChange={setPerf}
/>
</SettingRow>
<SettingRow
label={t("settings.appearance.showHints")}
@@ -384,7 +388,10 @@ function SignInMethods({ me }: { me: Me }) {
setNext("");
// Refresh `me` so has_password flips and the section switches to "Change password".
await qc.invalidateQueries({ queryKey: ["me"] });
notify({ level: "success", message: t("settings.account.password.saved", { email: me.email }) });
notify({
level: "success",
message: t("settings.account.password.saved", { email: me.email }),
});
} catch (e: any) {
setErr(e?.detail ?? t("settings.account.password.failed"));
} finally {
@@ -516,7 +523,10 @@ function PlexWatchSync() {
if (imp) {
notify({
level: "success",
message: t("settings.plexSync.imported", { watched: imp.watched, in_progress: imp.in_progress }),
message: t("settings.plexSync.imported", {
watched: imp.watched,
in_progress: imp.in_progress,
}),
});
// The browse grid's watch badges read plex_states — refresh them.
qc.invalidateQueries({ queryKey: ["plex"] });
@@ -550,11 +560,7 @@ function PlexWatchSync() {
<Section title={t("settings.plexSync.title")}>
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.plexSync.intro")}</p>
<SettingRow label={t("settings.plexSync.toggle")} hint={t("settings.plexSync.toggleHint")}>
<Switch
checked={shown}
disabled={busy}
onChange={(v) => !busy && toggle.mutate(v)}
/>
<Switch checked={shown} disabled={busy} onChange={(v) => !busy && toggle.mutate(v)} />
</SettingRow>
{busy ? (
<div className="mt-2 flex items-center gap-2 text-xs text-muted">
@@ -565,7 +571,9 @@ function PlexWatchSync() {
enabled && (
<div className="mt-2 flex items-center justify-between gap-3">
<p className="text-xs text-muted">
{last ? t("settings.plexSync.lastSync", { when: last }) : t("settings.plexSync.never")}
{last
? t("settings.plexSync.lastSync", { when: last })
: t("settings.plexSync.never")}
</p>
<button
onClick={() => reimport.mutate()}
@@ -602,78 +610,77 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
};
return (
<>
<Section title={t("settings.account.title")}>
<div className="flex items-center gap-3 mb-3">
<Avatar
src={me.avatar_url}
fallback={me.display_name ?? me.email}
className="w-12 h-12 rounded-full"
/>
<div className="min-w-0">
<div className="font-semibold truncate">{me.display_name ?? me.email.split("@")[0]}</div>
<div className="text-xs text-muted truncate">{me.email}</div>
<div className="text-xs text-muted capitalize">{me.role}</div>
</div>
</div>
</Section>
{!me.is_demo && <SignInMethods me={me} />}
{me.role === "admin" && me.plex_enabled && <PlexWatchSync />}
{me.is_demo ? (
<Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed">
{t("settings.account.demoNotice")}
</p>
</Section>
) : me.google_enabled ? (
// YouTube access requires Google OAuth; hidden when this instance has no Google configured.
<Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed mb-1">
{t("settings.account.youtubeAccessIntro")}
</p>
<div className="divide-y divide-border">
<AccessRow
title={t("settings.account.readTitle")}
granted={me.can_read}
grantedHint={t("settings.account.readGrantedHint")}
enableHint={t("settings.account.readEnableHint")}
onEnable={() => {
window.location.href = "/auth/upgrade?access=read";
}}
/>
<AccessRow
title={t("settings.account.writeTitle")}
granted={me.can_write}
grantedHint={t("settings.account.writeGrantedHint")}
enableHint={t("settings.account.writeEnableHint")}
onEnable={() => {
window.location.href = "/auth/upgrade?access=write";
}}
<Section title={t("settings.account.title")}>
<div className="flex items-center gap-3 mb-3">
<Avatar
src={me.avatar_url}
fallback={me.display_name ?? me.email}
className="w-12 h-12 rounded-full"
/>
<div className="min-w-0">
<div className="font-semibold truncate">
{me.display_name ?? me.email.split("@")[0]}
</div>
<div className="text-xs text-muted truncate">{me.email}</div>
<div className="text-xs text-muted capitalize">{me.role}</div>
</div>
</div>
<button
onClick={onOpenWizard}
className="mt-2 text-sm text-accent hover:underline"
>
{t("settings.account.walkMeThrough")}
</button>
</Section>
) : null}
{!me.is_demo && (
<Section title={t("settings.account.dangerZone")}>
<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-red-500/40 text-red-400 hover:bg-red-500/10 transition"
>
<Trash2 className="w-4 h-4" />
{t("settings.account.deleteAccount")}
</button>
</Section>
)}
{!me.is_demo && <SignInMethods me={me} />}
{me.role === "admin" && me.plex_enabled && <PlexWatchSync />}
{me.is_demo ? (
<Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed">{t("settings.account.demoNotice")}</p>
</Section>
) : me.google_enabled ? (
// YouTube access requires Google OAuth; hidden when this instance has no Google configured.
<Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed mb-1">
{t("settings.account.youtubeAccessIntro")}
</p>
<div className="divide-y divide-border">
<AccessRow
title={t("settings.account.readTitle")}
granted={me.can_read}
grantedHint={t("settings.account.readGrantedHint")}
enableHint={t("settings.account.readEnableHint")}
onEnable={() => {
window.location.href = "/auth/upgrade?access=read";
}}
/>
<AccessRow
title={t("settings.account.writeTitle")}
granted={me.can_write}
grantedHint={t("settings.account.writeGrantedHint")}
enableHint={t("settings.account.writeEnableHint")}
onEnable={() => {
window.location.href = "/auth/upgrade?access=write";
}}
/>
</div>
<button onClick={onOpenWizard} className="mt-2 text-sm text-accent hover:underline">
{t("settings.account.walkMeThrough")}
</button>
</Section>
) : null}
{!me.is_demo && (
<Section title={t("settings.account.dangerZone")}>
<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-red-500/40 text-red-400 hover:bg-red-500/10 transition"
>
<Trash2 className="w-4 h-4" />
{t("settings.account.deleteAccount")}
</button>
</Section>
)}
</>
);
}
+76 -17
View File
@@ -24,7 +24,10 @@ export default function SetupWizard() {
setTokenError(true);
return;
}
api.setupInfo(token).then(setInfo).catch(() => setTokenError(true));
api
.setupInfo(token)
.then(setInfo)
.catch(() => setTokenError(true));
}, [token]);
// Step state.
@@ -42,8 +45,18 @@ export default function SetupWizard() {
const [testTo, setTestTo] = useState("");
const [testState, setTestState] = useState<"idle" | "sending" | "ok" | "fail">("idle");
if (tokenError) return <Centered><TokenError /></Centered>;
if (!info) return <Centered><Loader2 className="w-6 h-6 animate-spin text-muted" /></Centered>;
if (tokenError)
return (
<Centered>
<TokenError />
</Centered>
);
if (!info)
return (
<Centered>
<Loader2 className="w-6 h-6 animate-spin text-muted" />
</Centered>
);
// Steps adapt to the instance: Google + SMTP need encrypted secret storage (TOKEN_ENCRYPTION_KEY).
const steps: StepId[] = [
@@ -72,7 +85,10 @@ export default function SetupWizard() {
await api.setupAdmin(token, email, password);
} else if (step === "google" && (gid.trim() || gsecret.trim())) {
if (!gid.trim() || !gsecret.trim()) throw { detail: t("setup.google.bothRequired") };
await api.setupConfig(token, { google_client_id: gid.trim(), google_client_secret: gsecret.trim() });
await api.setupConfig(token, {
google_client_id: gid.trim(),
google_client_secret: gsecret.trim(),
});
} else if (step === "smtp" && smtp.host.trim()) {
await api.setupConfig(token, {
smtp_host: smtp.host.trim(),
@@ -166,24 +182,65 @@ export default function SetupWizard() {
{step === "google" && (
<Section title={t("setup.google.title")} desc={t("setup.google.desc")}>
<input value={gid} onChange={(e) => setGid(e.target.value)} placeholder={t("setup.google.clientId")} className={inputCls} />
<input value={gsecret} onChange={(e) => setGsecret(e.target.value)} placeholder={t("setup.google.clientSecret")} className={inputCls} />
<input
value={gid}
onChange={(e) => setGid(e.target.value)}
placeholder={t("setup.google.clientId")}
className={inputCls}
/>
<input
value={gsecret}
onChange={(e) => setGsecret(e.target.value)}
placeholder={t("setup.google.clientSecret")}
className={inputCls}
/>
<p className="text-xs text-muted">{t("setup.google.skipHint")}</p>
</Section>
)}
{step === "smtp" && (
<Section title={t("setup.smtp.title")} desc={t("setup.smtp.desc")}>
<input value={smtp.host} onChange={(e) => setSmtp({ ...smtp, host: e.target.value })} placeholder={t("setup.smtp.host")} className={inputCls} />
<input
value={smtp.host}
onChange={(e) => setSmtp({ ...smtp, host: e.target.value })}
placeholder={t("setup.smtp.host")}
className={inputCls}
/>
<div className="flex gap-2">
<input value={smtp.port} onChange={(e) => setSmtp({ ...smtp, port: e.target.value })} placeholder={t("setup.smtp.port")} className={`${inputCls} w-24`} />
<input value={smtp.user} onChange={(e) => setSmtp({ ...smtp, user: e.target.value })} placeholder={t("setup.smtp.user")} className={inputCls} />
<input
value={smtp.port}
onChange={(e) => setSmtp({ ...smtp, port: e.target.value })}
placeholder={t("setup.smtp.port")}
className={`${inputCls} w-24`}
/>
<input
value={smtp.user}
onChange={(e) => setSmtp({ ...smtp, user: e.target.value })}
placeholder={t("setup.smtp.user")}
className={inputCls}
/>
</div>
<input value={smtp.from} onChange={(e) => setSmtp({ ...smtp, from: e.target.value })} placeholder={t("setup.smtp.from")} className={inputCls} />
<input type="password" value={smtp.password} onChange={(e) => setSmtp({ ...smtp, password: e.target.value })} placeholder={t("setup.smtp.password")} className={inputCls} />
<input
value={smtp.from}
onChange={(e) => setSmtp({ ...smtp, from: e.target.value })}
placeholder={t("setup.smtp.from")}
className={inputCls}
/>
<input
type="password"
value={smtp.password}
onChange={(e) => setSmtp({ ...smtp, password: e.target.value })}
placeholder={t("setup.smtp.password")}
className={inputCls}
/>
{smtp.host.trim() && (
<div className="flex items-center gap-2 pt-1">
<input value={testTo} onChange={(e) => setTestTo(e.target.value)} placeholder={t("setup.smtp.testTo")} className={inputCls} />
<input
value={testTo}
onChange={(e) => setTestTo(e.target.value)}
placeholder={t("setup.smtp.testTo")}
className={inputCls}
/>
<button
type="button"
onClick={sendTest}
@@ -195,8 +252,12 @@ export default function SetupWizard() {
</button>
</div>
)}
{testState === "ok" && <p className="text-xs text-emerald-500">{t("setup.smtp.testOk")}</p>}
{testState === "fail" && <p className="text-xs text-red-400">{t("setup.smtp.testFail")}</p>}
{testState === "ok" && (
<p className="text-xs text-emerald-500">{t("setup.smtp.testOk")}</p>
)}
{testState === "fail" && (
<p className="text-xs text-red-400">{t("setup.smtp.testFail")}</p>
)}
<p className="text-xs text-muted">{t("setup.smtp.skipHint")}</p>
</Section>
)}
@@ -237,9 +298,7 @@ export default function SetupWizard() {
}
function Centered({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-bg text-fg grid place-items-center p-4">{children}</div>
);
return <div className="min-h-screen bg-bg text-fg grid place-items-center p-4">{children}</div>;
}
function Section({
+57 -13
View File
@@ -23,7 +23,10 @@ function UserShare({ job }: { job: DownloadJob }) {
const s = q.trim().toLowerCase();
if (!s) return list.slice(0, 8);
return list
.filter((u) => u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s))
.filter(
(u) =>
u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s),
)
.slice(0, 8);
}, [q, list]);
@@ -107,19 +110,37 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
const badges: string[] = [];
if (link.has_password) badges.push(t("downloads.share.hasPassword"));
if (link.allow_download) badges.push(t("downloads.share.downloadable"));
if (link.expires_at) badges.push(t("downloads.share.expiresOn", { date: new Date(link.expires_at).toLocaleDateString() }));
if (link.expires_at)
badges.push(
t("downloads.share.expiresOn", { date: new Date(link.expires_at).toLocaleDateString() }),
);
return (
<div className="rounded-lg glass-card p-2.5 space-y-2">
<div className="flex items-center gap-2">
<Link2 className="w-4 h-4 text-muted shrink-0" />
<input readOnly value={fullUrl} className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-none" />
<button onClick={copy} title={t("downloads.share.copy")} className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg transition">
<input
readOnly
value={fullUrl}
className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-none"
/>
<button
onClick={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-emerald-400" /> : <Copy className="w-4 h-4" />}
</button>
<button
onClick={async () => {
if (await confirm({ title: t("downloads.share.revoke"), message: t("downloads.share.revokeConfirm"), confirmLabel: t("downloads.share.revoke"), danger: true }))
if (
await confirm({
title: t("downloads.share.revoke"),
message: t("downloads.share.revokeConfirm"),
confirmLabel: t("downloads.share.revoke"),
danger: true,
})
)
revoke.mutate();
}}
title={t("downloads.share.revoke")}
@@ -129,7 +150,9 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
</button>
</div>
<div className="flex items-center gap-2 text-xs text-muted flex-wrap">
<span className="inline-flex items-center gap-1"><Eye className="w-3 h-3" /> {t("downloads.share.views", { n: link.view_count })}</span>
<span className="inline-flex items-center gap-1">
<Eye className="w-3 h-3" /> {t("downloads.share.views", { n: link.view_count })}
</span>
{badges.map((b) => (
<span key={b} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-surface">
{b === t("downloads.share.hasPassword") && <Lock className="w-3 h-3" />}
@@ -149,7 +172,10 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
function LinkShare({ job }: { job: DownloadJob }) {
const { t } = useTranslation();
const qc = useQueryClient();
const links = useQuery({ queryKey: ["download-links", job.id], queryFn: () => api.downloadLinks(job.id) });
const links = useQuery({
queryKey: ["download-links", job.id],
queryFn: () => api.downloadLinks(job.id),
});
const [creating, setCreating] = useState(false);
const [allowDownload, setAllowDownload] = useState(false);
const [expiryDays, setExpiryDays] = useState<number | null>(null);
@@ -191,7 +217,11 @@ function LinkShare({ job }: { job: DownloadJob }) {
{creating ? (
<div className="mt-2 rounded-lg border border-border p-3 space-y-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={allowDownload} onChange={(e) => setAllowDownload(e.target.checked)} />
<input
type="checkbox"
checked={allowDownload}
onChange={(e) => setAllowDownload(e.target.checked)}
/>
{t("downloads.share.allowDownload")}
</label>
<div className="flex items-center gap-2 text-sm">
@@ -202,10 +232,14 @@ function LinkShare({ job }: { job: DownloadJob }) {
onClick={() => setExpiryDays(d)}
className={clsx(
"px-2 py-1 rounded-md border text-xs transition",
expiryDays === d ? "border-accent bg-accent/10 text-accent" : "border-border text-muted hover:text-fg"
expiryDays === d
? "border-accent bg-accent/10 text-accent"
: "border-border text-muted hover:text-fg",
)}
>
{d === null ? t("downloads.share.expiryNever") : t("downloads.share.days", { n: d })}
{d === null
? t("downloads.share.expiryNever")
: t("downloads.share.days", { n: d })}
</button>
))}
</div>
@@ -218,10 +252,17 @@ function LinkShare({ job }: { job: DownloadJob }) {
autoComplete="new-password"
/>
<div className="flex justify-end gap-2">
<button onClick={() => setCreating(false)} className={clsx(btnCls, "text-muted hover:text-fg hover:bg-surface")}>
<button
onClick={() => setCreating(false)}
className={clsx(btnCls, "text-muted hover:text-fg hover:bg-surface")}
>
{t("common.cancel")}
</button>
<button onClick={() => create.mutate()} disabled={create.isPending} className={clsx(btnCls, "bg-accent text-accent-fg hover:opacity-90")}>
<button
onClick={() => create.mutate()}
disabled={create.isPending}
className={clsx(btnCls, "bg-accent text-accent-fg hover:opacity-90")}
>
{t("downloads.share.createLink")}
</button>
</div>
@@ -229,7 +270,10 @@ function LinkShare({ job }: { job: DownloadJob }) {
) : (
<button
onClick={() => setCreating(true)}
className={clsx(btnCls, "mt-2 border border-border text-muted hover:text-fg hover:border-muted inline-flex items-center gap-1.5")}
className={clsx(
btnCls,
"mt-2 border border-border text-muted hover:text-fg hover:border-muted inline-flex items-center gap-1.5",
)}
>
<Link2 className="w-4 h-4" /> {t("downloads.share.newLink")}
</button>
+2 -4
View File
@@ -95,14 +95,12 @@ export default function Sidebar({
const facetsReady = !!facetsQuery.data;
const facetCounts = facetsQuery.data?.counts ?? {};
const chipCount = (tag: Tag): number =>
facetsReady ? facetCounts[String(tag.id)] ?? 0 : tag.channel_count;
facetsReady ? (facetCounts[String(tag.id)] ?? 0) : tag.channel_count;
const visibleChips = (list: Tag[]): Tag[] => {
const shown = facetsReady
? list.filter((tg) => chipCount(tg) > 0 || filters.tags.includes(tg.id))
: list;
return [...shown].sort(
(a, b) => chipCount(b) - chipCount(a) || a.name.localeCompare(b.name)
);
return [...shown].sort((a, b) => chipCount(b) - chipCount(a) || a.name.localeCompare(b.name));
};
const languages = tags.filter((t) => t.category === "language");
+31 -10
View File
@@ -50,7 +50,6 @@ export default function Stats() {
);
}
// Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions.
function Overview({ me }: { me: Me }) {
const { t } = useTranslation();
@@ -63,7 +62,10 @@ function Overview({ me }: { me: Me }) {
onSuccess: (r: { subscriptions?: number }) => {
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: ["channels"] });
notify({ level: "success", message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }) });
notify({
level: "success",
message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }),
});
},
onError: () => notify({ level: "error", message: t("stats.sync.syncFailed") }),
});
@@ -88,7 +90,10 @@ function Overview({ me }: { me: Me }) {
<SettingRow label={t("stats.sync.channels")} hint={t("stats.sync.channelsHint")}>
{s.channels_total}
</SettingRow>
<SettingRow label={t("stats.sync.recentSynced")} hint={t("stats.sync.recentSyncedHint")}>
<SettingRow
label={t("stats.sync.recentSynced")}
hint={t("stats.sync.recentSyncedHint")}
>
{`${s.channels_recent_synced}/${s.channels_total}`}
</SettingRow>
<SettingRow label={t("stats.sync.fullHistory")} hint={t("stats.sync.fullHistoryHint")}>
@@ -121,9 +126,15 @@ function Overview({ me }: { me: Me }) {
<SettingRow label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}>
{usage.data.today.toLocaleString()}
</SettingRow>
<SettingRow label={t("stats.sync.last7d")}>{usage.data.last_7d.toLocaleString()}</SettingRow>
<SettingRow label={t("stats.sync.last30d")}>{usage.data.last_30d.toLocaleString()}</SettingRow>
<SettingRow label={t("stats.sync.allTime")}>{usage.data.all_time.toLocaleString()}</SettingRow>
<SettingRow label={t("stats.sync.last7d")}>
{usage.data.last_7d.toLocaleString()}
</SettingRow>
<SettingRow label={t("stats.sync.last30d")}>
{usage.data.last_30d.toLocaleString()}
</SettingRow>
<SettingRow label={t("stats.sync.allTime")}>
{usage.data.all_time.toLocaleString()}
</SettingRow>
</div>
{Object.keys(usage.data.by_action).length > 0 && (
<div className="mt-2 pt-2 border-t border-border space-y-1 text-xs text-muted">
@@ -198,7 +209,9 @@ function SystemStats() {
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm transition"
>
{s.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
{s.paused ? t("stats.sync.resumeBackgroundSync") : t("stats.sync.pauseBackgroundSync")}
{s.paused
? t("stats.sync.resumeBackgroundSync")
: t("stats.sync.pauseBackgroundSync")}
</button>
</Tooltip>
</Section>
@@ -238,7 +251,10 @@ function SystemStats() {
{/* Daily totals (instance-wide, Pacific days) */}
<div className="glass-card rounded-xl p-3 mb-4">
<div className="text-xs text-muted mb-2">
{t("stats.dailyTotal", { count: data.range_days, units: grandTotal.toLocaleString() })}
{t("stats.dailyTotal", {
count: data.range_days,
units: grandTotal.toLocaleString(),
})}
</div>
{data.daily.length === 0 ? (
<div className="text-muted text-sm">{t("stats.noUsageInRange")}</div>
@@ -283,11 +299,16 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
const isSystem = row.user_id === null;
return (
<div className="glass-card rounded-xl p-3">
<button onClick={() => setOpen((o) => !o)} className="w-full flex items-center gap-3 text-left">
<button
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center gap-3 text-left"
>
<span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
{isSystem ? t("stats.system") : row.email}
</span>
<span className="text-sm font-semibold tabular-nums shrink-0">{row.total.toLocaleString()}</span>
<span className="text-sm font-semibold tabular-nums shrink-0">
{row.total.toLocaleString()}
</span>
</button>
{/* Proportion bar */}
<div className="mt-2 h-1.5 bg-border/40 rounded-full overflow-hidden">
+3 -4
View File
@@ -37,8 +37,7 @@ export default function SyncStatus({
refetchInterval: (query) => {
const d = query.state.data as MyStatus | undefined;
const busy =
!!d &&
(d.sync_active || d.channels_recent_pending > 0 || d.channels_deep_pending > 0);
!!d && (d.sync_active || d.channels_recent_pending > 0 || d.channels_deep_pending > 0);
return busy ? 8_000 : 30_000;
},
refetchIntervalInBackground: true,
@@ -101,7 +100,7 @@ export default function SyncStatus({
// history) so the collapsed chip still tells you when to look.
const needsAttention = data.paused || notFull > 0;
const countsText = `${formatViews(data.my_videos)} ${t("header.sync.yours")} / ${formatViews(
data.total_videos
data.total_videos,
)} ${t("header.sync.total")}`;
return (
<div className="pointer-events-auto shrink-0">
@@ -171,7 +170,7 @@ export default function SyncStatus({
</div>
)}
</div>,
document.body
document.body,
)}
</div>
);
+1 -1
View File
@@ -98,7 +98,7 @@ function TagRow({
</button>
))}
</div>,
document.body
document.body,
)}
</div>
);
+3 -18
View File
@@ -1,24 +1,9 @@
import { useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import {
AlertCircle,
AlertTriangle,
CheckCircle2,
Info,
X,
XCircle,
} from "lucide-react";
import {
dismiss,
getActiveToasts,
subscribe,
type NotifLevel,
} from "../lib/notifications";
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
import { dismiss, getActiveToasts, subscribe, type NotifLevel } from "../lib/notifications";
export const LEVEL_STYLE: Record<
NotifLevel,
{ icon: typeof Info; color: string; bar: string }
> = {
export const LEVEL_STYLE: Record<NotifLevel, { icon: typeof Info; color: string; bar: string }> = {
info: { icon: Info, color: "text-accent", bar: "bg-accent" },
success: { icon: CheckCircle2, color: "text-emerald-400", bar: "bg-emerald-400" },
warning: { icon: AlertTriangle, color: "text-amber-400", bar: "bg-amber-400" },
+2 -2
View File
@@ -55,7 +55,7 @@ export default function Tooltip({
const half = tip.offsetWidth / 2;
const left = Math.min(
Math.max(r.left + r.width / 2, half + MARGIN),
window.innerWidth - half - MARGIN
window.innerWidth - half - MARGIN,
);
if (Math.abs(left - coords.left) > 0.5) setCoords((c) => (c ? { ...c, left } : c));
}, [coords]);
@@ -87,7 +87,7 @@ export default function Tooltip({
>
{hint}
</div>,
document.body
document.body,
)}
</span>
);
+15 -8
View File
@@ -22,12 +22,7 @@ export default function UndoToolbar({
function onKey(e: KeyboardEvent) {
if (!(e.ctrlKey || e.metaKey)) return;
const tgt = e.target as HTMLElement | null;
if (
tgt &&
(tgt.tagName === "INPUT" ||
tgt.tagName === "TEXTAREA" ||
tgt.isContentEditable)
)
if (tgt && (tgt.tagName === "INPUT" || tgt.tagName === "TEXTAREA" || tgt.isContentEditable))
return;
const k = e.key.toLowerCase();
if (k === "z" && !e.shiftKey) {
@@ -50,10 +45,22 @@ export default function UndoToolbar({
"p-1.5 rounded-lg border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition";
return (
<div className="inline-flex items-center gap-1">
<button onClick={onUndo} disabled={!canUndo} title={t("common.undo")} aria-label={t("common.undo")} className={btn}>
<button
onClick={onUndo}
disabled={!canUndo}
title={t("common.undo")}
aria-label={t("common.undo")}
className={btn}
>
<Undo2 className="w-4 h-4" />
</button>
<button onClick={onRedo} disabled={!canRedo} title={t("common.redo")} aria-label={t("common.redo")} className={btn}>
<button
onClick={onRedo}
disabled={!canRedo}
title={t("common.redo")}
aria-label={t("common.redo")}
className={btn}
>
<Redo2 className="w-4 h-4" />
</button>
</div>
+36 -23
View File
@@ -55,15 +55,17 @@ function Actions({
// `dense` shrinks 28px buttons to 24 and halves the gap: six of them need 188px, but the small
// card's text column bottoms out around 160 (its 180px column less the padding), so at its
// narrowest the last button used to hang outside the card. 154 at dense.
const btn = clsx(
"rounded-md hover:bg-surface text-muted hover:text-fg",
dense ? "p-1" : "p-1.5"
);
const btn = clsx("rounded-md hover:bg-surface text-muted hover:text-fg", dense ? "p-1" : "p-1.5");
return (
// flex-wrap is the safety net, not the plan: the sizing above is what makes them fit. If a
// future button or a narrower column breaks that arithmetic, the row wraps to a second line
// rather than silently rendering a half-clipped control outside the card.
<div className={clsx("flex flex-wrap opacity-0 group-hover:opacity-100 transition", dense ? "gap-0.5" : "gap-1")}>
<div
className={clsx(
"flex flex-wrap opacity-0 group-hover:opacity-100 transition",
dense ? "gap-0.5" : "gap-1",
)}
>
<button
onClick={act("watched")}
title={video.status === "watched" ? t("card.watchedUnmark") : t("card.markWatched")}
@@ -89,11 +91,7 @@ function Actions({
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
className={clsx(btn, video.status === "hidden" && "text-accent")}
>
{video.status === "hidden" ? (
<Eye className="w-4 h-4" />
) : (
<EyeOff className="w-4 h-4" />
)}
{video.status === "hidden" ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
</button>
{onResetState && resettable && (
<button
@@ -117,7 +115,7 @@ function Actions({
function openInApp(
e: React.MouseEvent,
video: Video,
onOpen?: (v: Video, startAt?: number | null) => void
onOpen?: (v: Video, startAt?: number | null) => void,
): void {
if (!onOpen || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
e.preventDefault();
@@ -229,7 +227,7 @@ function Thumb({
onClick={(e) => openInApp(e, video, onOpen)}
className={clsx(
"block relative rounded-xl overflow-hidden bg-surface border border-border shadow-md group-hover:shadow-2xl transition-shadow",
className
className,
)}
>
{video.thumbnail_url ? (
@@ -279,7 +277,7 @@ function Thumb({
aria-label={t("card.continue")}
className={clsx(
"inline-flex items-center justify-center gap-1.5 font-semibold bg-accent text-accent-fg shadow-lg hover:opacity-90 transition",
small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 rounded-lg text-sm"
small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 rounded-lg text-sm",
)}
>
<Play className="w-4 h-4 fill-current" />
@@ -291,7 +289,7 @@ function Thumb({
aria-label={t("card.restart")}
className={clsx(
"inline-flex items-center justify-center gap-1.5 font-medium bg-white/15 text-white backdrop-blur-sm hover:bg-white/25 transition",
small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 rounded-lg text-sm"
small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 rounded-lg text-sm",
)}
>
<RotateCcw className="w-4 h-4" />
@@ -305,10 +303,12 @@ function Thumb({
aria-label={t("card.play")}
className={clsx(
"inline-flex items-center justify-center rounded-full bg-accent text-accent-fg shadow-lg hover:scale-105 transition",
small ? "w-9 h-9" : "w-12 h-12"
small ? "w-9 h-9" : "w-12 h-12",
)}
>
<Play className={clsx("fill-current translate-x-[1px]", small ? "w-4 h-4" : "w-5 h-5")} />
<Play
className={clsx("fill-current translate-x-[1px]", small ? "w-4 h-4" : "w-5 h-5")}
/>
</button>
)}
</div>
@@ -403,7 +403,12 @@ function VideoCard({
const published = (
<>
{relativeTime(video.published_at)}
{video.published_at && <>{" · "}{formatDate(video.published_at, i18n.language)}</>}
{video.published_at && (
<>
{" · "}
{formatDate(video.published_at, i18n.language)}
</>
)}
</>
);
// Show the full text in a native tooltip only when it's actually cut off — vertically where the
@@ -436,10 +441,10 @@ function VideoCard({
target="_blank"
rel="noreferrer"
onClick={(e) => openInApp(e, video, onOpen)}
title={titleClamped ? video.title ?? undefined : undefined}
title={titleClamped ? (video.title ?? undefined) : undefined}
className={clsx(
"font-medium leading-snug hover:text-accent",
noThumb ? "block truncate" : "line-clamp-2"
noThumb ? "block truncate" : "line-clamp-2",
)}
>
{video.title}
@@ -453,7 +458,7 @@ function VideoCard({
e.stopPropagation();
openChannel(video.channel_id, video.channel_title ?? t("card.thisChannel"));
}}
title={channelClamped ? video.channel_title ?? undefined : undefined}
title={channelClamped ? (video.channel_title ?? undefined) : undefined}
className="text-sm text-muted truncate block w-fit max-w-full text-left hover:text-fg"
>
{video.channel_title}
@@ -498,7 +503,11 @@ function VideoCard({
const pct = resumePct(video);
const fits = (needs: number) => width === 0 || width >= needs;
return (
<div data-testid="video-card" data-video-id={video.id} className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}>
<div
data-testid="video-card"
data-video-id={video.id}
className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}
>
{/* Actions lead: this is where the eye already is (the title is the thing you read), so
they're the shortest pointer trip from it. */}
<div className="shrink-0 w-48">{actions}</div>
@@ -551,7 +560,11 @@ function VideoCard({
if (spec.family === "row") {
return (
<div data-testid="video-card" data-video-id={video.id} className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}>
<div
data-testid="video-card"
data-video-id={video.id}
className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}
>
<Thumb
video={video}
className={clsx("aspect-video shrink-0", spec.actionsBelow ? "w-40" : "w-44")}
@@ -587,7 +600,7 @@ function VideoCard({
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150 hover:-translate-y-1",
"flex flex-col",
spec.dense ? "p-2" : "p-2.5",
watched && "opacity-55"
watched && "opacity-55",
)}
>
{/* The small card's image is in the SAME size class as a row's (its column bottoms out at
+118 -39
View File
@@ -1,7 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Crop, Eye, EyeOff, Pause, Play, Scissors, SplitSquareHorizontal, Loader2, X } from "lucide-react";
import {
Crop,
Eye,
EyeOff,
Pause,
Play,
Scissors,
SplitSquareHorizontal,
Loader2,
X,
} from "lucide-react";
import clsx from "clsx";
import Modal from "./Modal";
import { notify } from "../lib/notifications";
@@ -19,7 +29,8 @@ function parseTc(v: string): number | null {
if (!v) return null;
if (v.includes(":")) {
const [m, s] = v.split(":");
const mm = Number(m), ss = Number(s);
const mm = Number(m),
ss = Number(s);
if (!isFinite(mm) || !isFinite(ss)) return null;
return mm * 60 + ss;
}
@@ -90,7 +101,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// the true length (e.g. 213.44 vs 213). Without this the untouched last segment stays short, so
// the no-op guard (isFullSingle: end >= duration) reads false and enables a "Create" that files a
// clip of an unmodified video and drops its tail. Only touches the untouched segment (end===srcDur).
setSegments((s) => (s.length === 1 && s[0].end === srcDur ? [{ ...s[0], end: d }] : s));
setSegments((s) => (s.length === 1 && s[0]!.end === srcDur ? [{ ...s[0]!, end: d }] : s));
};
const onTimeUpdate = () => {
const v = videoRef.current;
@@ -113,7 +124,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
setSegments((segs) => {
const i = segs.findIndex((s) => time > s.start + MIN_SEG && time < s.end - MIN_SEG);
if (i < 0) return segs;
const s = segs[i];
const s = segs[i]!; // i >= 0 from findIndex, so segs[i] exists
const b: Seg = { id: nid(), start: time, end: s.end, keep: s.keep };
const next = [...segs.slice(0, i), { ...s, end: time }, b, ...segs.slice(i + 1)];
return next;
@@ -122,18 +133,19 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const moveBoundary = (bi: number, time: number) =>
setSegments((segs) => {
if (bi < 0 || bi >= segs.length - 1) return segs;
const lo = segs[bi].start + MIN_SEG;
const hi = segs[bi + 1].end - MIN_SEG;
// bi is in [0, length-2] here, so bi and bi+1 both index existing segments.
const lo = segs[bi]!.start + MIN_SEG;
const hi = segs[bi + 1]!.end - MIN_SEG;
const tt = Math.max(lo, Math.min(hi, time));
const copy = [...segs];
copy[bi] = { ...copy[bi], end: tt };
copy[bi + 1] = { ...copy[bi + 1], start: tt };
copy[bi] = { ...copy[bi]!, end: tt };
copy[bi + 1] = { ...copy[bi + 1]!, start: tt };
return copy;
});
const deleteBoundary = (bi: number) =>
setSegments((segs) => {
if (segs.length < 2) return segs;
const merged = { ...segs[bi], end: segs[bi + 1].end };
const merged = { ...segs[bi]!, end: segs[bi + 1]!.end };
return [...segs.slice(0, bi), merged, ...segs.slice(bi + 2)];
});
const toggleKeep = (id: number) =>
@@ -149,7 +161,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const r = el.getBoundingClientRect();
return Math.max(0, Math.min(1, (clientX - r.left) / r.width)) * duration;
},
[duration]
[duration],
);
useEffect(() => {
if (!drag) return;
@@ -171,7 +183,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// --- crop drag ---
const overlayRef = useRef<HTMLDivElement>(null);
const cropDrag = useRef<null | { mode: "move" | "resize"; px: number; py: number; orig: Frac }>(null);
const cropDrag = useRef<null | { mode: "move" | "resize"; px: number; py: number; orig: Frac }>(
null,
);
const onCropDown = (e: React.PointerEvent, mode: "move" | "resize") => {
e.preventDefault();
e.stopPropagation();
@@ -233,8 +247,8 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
0,
Math.min(
Math.max(0, (trackW || HOVER_W) - HOVER_W),
hoverX - (trackRef.current?.getBoundingClientRect().left ?? 0) - HOVER_W / 2
)
hoverX - (trackRef.current?.getBoundingClientRect().left ?? 0) - HOVER_W / 2,
),
)
: 0;
@@ -242,7 +256,10 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const kept = segments.filter((s) => s.keep);
const keptDur = kept.reduce((a, s) => a + (s.end - s.start), 0);
const isFullSingle =
segments.length === 1 && kept.length === 1 && kept[0].start <= 0.01 && kept[0].end >= duration - 0.01;
segments.length === 1 &&
kept.length === 1 &&
kept[0]!.start <= 0.01 &&
kept[0]!.end >= duration - 0.01;
const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || !isFullSingle);
const willJoin = output === "join" && kept.length > 1;
const reencode = cropOn || accurate;
@@ -253,7 +270,12 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const vh = v?.videoHeight || job.asset?.height || 0;
if (!cropOn || !vw || !vh) return undefined;
const even = (n: number) => Math.max(2, Math.round(n / 2) * 2);
return { x: even(crop.x * vw), y: even(crop.y * vh), w: even(crop.w * vw), h: even(crop.h * vh) };
return {
x: even(crop.x * vw),
y: even(crop.y * vh),
w: even(crop.w * vw),
h: even(crop.h * vh),
};
};
const create = useMutation({
@@ -266,12 +288,16 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
accurate,
};
if (cp) spec.crop = cp;
await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: name.trim() || undefined });
await api.enqueueEdit({
source_job_id: job.id,
edit_spec: spec,
display_name: name.trim() || undefined,
});
return { files: 1 };
}
const N = kept.length;
for (let i = 0; i < N; i++) {
const s = kept[i];
const s = kept[i]!; // i < N === kept.length, so kept[i] exists
const spec: EditSpec = { trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) } };
if (cp) spec.crop = cp;
else spec.accurate = accurate;
@@ -331,12 +357,21 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
{/* transport */}
<div className="flex items-center gap-3">
<button onClick={playPause} className="p-2 rounded-lg bg-surface hover:bg-card transition" title={t("editor.playPause")}>
<button
onClick={playPause}
className="p-2 rounded-lg bg-surface hover:bg-card transition"
title={t("editor.playPause")}
>
{playing ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
</button>
<div className="text-xs text-muted tabular-nums">{tc(current)} / {tc(duration)}</div>
<div className="text-xs text-muted tabular-nums">
{tc(current)} / {tc(duration)}
</div>
<div className="flex-1" />
<button onClick={splitAtPlayhead} className={clsx(btnCls, "bg-surface hover:bg-card inline-flex items-center gap-1.5")}>
<button
onClick={splitAtPlayhead}
className={clsx(btnCls, "bg-surface hover:bg-card inline-flex items-center gap-1.5")}
>
<SplitSquareHorizontal className="w-4 h-4" /> {t("editor.splitHere")}
</button>
</div>
@@ -360,7 +395,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
<div
key={i}
className="h-full flex-1"
style={tileStyle(Math.round(((i + 0.5) / stripCells) * ((strip!.count ?? 1) - 1)))}
style={tileStyle(
Math.round(((i + 0.5) / stripCells) * ((strip!.count ?? 1) - 1)),
)}
/>
))}
</div>
@@ -372,7 +409,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
className={clsx(
"absolute inset-y-0 border-r border-black/40 last:border-r-0",
s.keep ? "bg-accent/10" : "bg-black/60",
s.id === selId && "ring-2 ring-inset ring-white/70"
s.id === selId && "ring-2 ring-inset ring-white/70",
)}
style={{
left: pct(s.start),
@@ -395,7 +432,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
}}
className={clsx(
"absolute top-1 left-1 p-0.5 rounded bg-black/50 backdrop-blur-sm",
s.keep ? "text-accent" : "text-muted"
s.keep ? "text-accent" : "text-muted",
)}
title={s.keep ? t("editor.drop") : t("editor.keep")}
>
@@ -405,7 +442,11 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
))}
{/* draggable boundary markers */}
{boundaries.map((b, bi) => (
<div key={bi} className="absolute inset-y-0 -ml-1.5 w-3 flex items-center justify-center z-base" style={{ left: pct(b) }}>
<div
key={bi}
className="absolute inset-y-0 -ml-1.5 w-3 flex items-center justify-center z-base"
style={{ left: pct(b) }}
>
<div
className="w-1 h-full bg-white/90 cursor-ew-resize rounded"
onPointerDown={(e) => {
@@ -426,7 +467,10 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
</div>
))}
{/* playhead */}
<div className="absolute inset-y-0 w-0.5 bg-white pointer-events-none z-menu" style={{ left: pct(current) }} />
<div
className="absolute inset-y-0 w-0.5 bg-white pointer-events-none z-menu"
style={{ left: pct(current) }}
/>
</div>
{/* hover-scrub thumbnail */}
@@ -435,8 +479,16 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
className="absolute bottom-full mb-2 pointer-events-none z-chrome rounded-md overflow-hidden border border-border shadow-xl bg-black"
style={{ left: hoverLeft, width: HOVER_W }}
>
<div style={{ width: HOVER_W, height: HOVER_W / aspect, ...tileStyle(Math.floor(hoverTime / (strip.interval_s || 1))) }} />
<div className="text-[10px] text-center text-muted py-0.5 tabular-nums">{tc(hoverTime)}</div>
<div
style={{
width: HOVER_W,
height: HOVER_W / aspect,
...tileStyle(Math.floor(hoverTime / (strip.interval_s || 1))),
}}
/>
<div className="text-[10px] text-center text-muted py-0.5 tabular-nums">
{tc(hoverTime)}
</div>
</div>
)}
</div>
@@ -449,7 +501,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
onClick={() => toggleKeep(sel.id)}
className={clsx(
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium",
sel.keep ? "bg-accent/15 text-accent" : "bg-surface text-muted"
sel.keep ? "bg-accent/15 text-accent" : "bg-surface text-muted",
)}
>
{sel.keep ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
@@ -482,7 +534,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
className={numCls}
/>
</label>
<span className="text-xs text-muted tabular-nums w-16 text-right">{tc(sel.end - sel.start)}</span>
<span className="text-xs text-muted tabular-nums w-16 text-right">
{tc(sel.end - sel.start)}
</span>
</div>
)}
@@ -496,7 +550,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
disabled={m === "join" && kept.length < 2}
className={clsx(
"px-3 py-1.5 rounded-lg border transition disabled:opacity-40",
output === m ? "border-accent bg-accent/10 text-accent" : "border-border text-muted hover:text-fg"
output === m
? "border-accent bg-accent/10 text-accent"
: "border-border text-muted hover:text-fg",
)}
>
{t(`editor.output.${m}`)}
@@ -514,7 +570,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
onClick={() => setCropOn((v) => !v)}
className={clsx(
"flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition",
cropOn ? "border-accent text-accent bg-accent/10" : "border-border text-muted hover:text-fg"
cropOn
? "border-accent text-accent bg-accent/10"
: "border-border text-muted hover:text-fg",
)}
>
<Crop className="w-4 h-4" /> {cropOn ? t("editor.cropOn") : t("editor.cropOff")}
@@ -529,37 +587,58 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
onClick={() => setAccurate(m === "accurate")}
className={clsx(
"flex-1 px-3 py-2 rounded-lg border text-left transition",
on ? "border-accent bg-accent/10" : "border-border hover:border-muted"
on ? "border-accent bg-accent/10" : "border-border hover:border-muted",
)}
title={t(`editor.${m}.hint`)}
>
<div className={clsx("text-sm font-medium", on ? "text-accent" : "text-fg")}>{t(`editor.${m}.label`)}</div>
<div className={clsx("text-sm font-medium", on ? "text-accent" : "text-fg")}>
{t(`editor.${m}.label`)}
</div>
</button>
);
})}
</div>
) : (
<div className="flex items-center px-3 text-xs text-muted">{t("editor.cropReencode")}</div>
<div className="flex items-center px-3 text-xs text-muted">
{t("editor.cropReencode")}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">{t("editor.nameLabel")}</label>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={defaultName} className={inputCls} />
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={defaultName}
className={inputCls}
/>
</div>
<div className="flex items-center justify-between pt-1">
<span className="text-xs text-muted">{reencode ? t("editor.willReencode") : t("editor.willCopy")}</span>
<span className="text-xs text-muted">
{reencode ? t("editor.willReencode") : t("editor.willCopy")}
</span>
<div className="flex gap-2">
<button onClick={onClose} className={clsx(btnCls, "text-muted hover:text-fg hover:bg-surface")}>
<button
onClick={onClose}
className={clsx(btnCls, "text-muted hover:text-fg hover:bg-surface")}
>
{t("common.cancel")}
</button>
<button
onClick={() => create.mutate()}
disabled={!valid || create.isPending}
className={clsx(btnCls, "bg-accent text-accent-fg hover:opacity-90 inline-flex items-center gap-1.5")}
className={clsx(
btnCls,
"bg-accent text-accent-fg hover:opacity-90 inline-flex items-center gap-1.5",
)}
>
{create.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Scissors className="w-4 h-4" />}
{create.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Scissors className="w-4 h-4" />
)}
{willJoin
? t("editor.createJoin")
: kept.length > 1
+4 -2
View File
@@ -52,7 +52,7 @@ export default function ViewSwitcher<T extends string>({
// nothing. The menu then shows no checked item, which is honest — nothing IS selected.
const currentIdx = Math.max(
0,
options.findIndex((o) => o.id === value)
options.findIndex((o) => o.id === value),
);
// Seeding the roving index HERE — rather than in an effect keyed on `open` — matters: an effect
// would only schedule the update, so the focus effect below would still see the previous index
@@ -111,7 +111,9 @@ export default function ViewSwitcher<T extends string>({
>
<CurrentIcon className="w-4 h-4 shrink-0" />
<span className={clsx("text-sm whitespace-nowrap", labelClassName)}>{current.label}</span>
<ChevronDown className={`w-3 h-3 shrink-0 transition-transform ${open ? "rotate-180" : ""}`} />
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
{open && (
<div
+10 -2
View File
@@ -1,4 +1,12 @@
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
Fragment,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
// The virtualization engine, lifted out of VirtualFeed so any long client- or server-side list can
@@ -109,7 +117,7 @@ export default function VirtualGrid<T>({
const cols = columnsFor(minCol, w);
setColCount(cols);
setItemWidth(
minCol == null ? Math.min(w, maxCol ?? w) : Math.floor((w - GRID_GAP * (cols - 1)) / cols)
minCol == null ? Math.min(w, maxCol ?? w) : Math.floor((w - GRID_GAP * (cols - 1)) / cols),
);
};
measure();
+7 -4
View File
@@ -67,9 +67,10 @@ const STR = {
},
} as const;
const lang = ((navigator.language || "en").slice(0, 2) as keyof typeof STR) in STR
? ((navigator.language || "en").slice(0, 2) as keyof typeof STR)
: "en";
const lang =
((navigator.language || "en").slice(0, 2) as keyof typeof STR) in STR
? ((navigator.language || "en").slice(0, 2) as keyof typeof STR)
: "en";
const T = STR[lang];
export default function WatchPage() {
@@ -129,7 +130,9 @@ export default function WatchPage() {
<span className="font-semibold text-fg">Siftlode</span>
</div>
{status === "loading" && <div className="flex-1 grid place-items-center text-muted">{T.loading}</div>}
{status === "loading" && (
<div className="flex-1 grid place-items-center text-muted">{T.loading}</div>
)}
{status === "gone" && (
<div className="flex-1 grid place-items-center text-center">
+33 -10
View File
@@ -115,9 +115,7 @@ export default function Welcome() {
</section>
</main>
{zoomed && (
<Lightbox src={zoomed.src} label={zoomed.label} onClose={() => setZoomed(null)} />
)}
{zoomed && <Lightbox src={zoomed.src} label={zoomed.label} onClose={() => setZoomed(null)} />}
<footer className="border-t border-border py-6 text-center text-xs text-muted flex flex-col sm:flex-row gap-3 justify-center items-center">
<span>
@@ -305,7 +303,10 @@ function AuthCard() {
// disappear once the config loads.
const [googleEnabled, setGoogleEnabled] = useState(true);
useEffect(() => {
api.authConfig().then((c) => setGoogleEnabled(c.google_enabled)).catch(() => {});
api
.authConfig()
.then((c) => setGoogleEnabled(c.google_enabled))
.catch(() => {});
}, []);
const [done, setDone] = useState<string>(""); // success/info message replacing the form
@@ -351,7 +352,9 @@ function AuthCard() {
setDone(t("welcome.auth.resetDone"));
}
} catch (err) {
setError(err instanceof HttpError && err.detail ? err.detail : t("welcome.auth.genericError"));
setError(
err instanceof HttpError && err.detail ? err.detail : t("welcome.auth.genericError"),
);
} finally {
setBusy(false);
}
@@ -457,7 +460,7 @@ function AuthCard() {
? "welcome.auth.forgotButton"
: mode === "reset"
? "welcome.auth.resetButton"
: "welcome.auth.signinButton"
: "welcome.auth.signinButton",
)}
</button>
</form>
@@ -466,15 +469,33 @@ function AuthCard() {
<div className="flex items-center justify-between mt-3 text-xs">
{mode === "signin" ? (
<>
<button onClick={() => { reset(); setMode("register"); }} className="text-accent hover:underline">
<button
onClick={() => {
reset();
setMode("register");
}}
className="text-accent hover:underline"
>
{t("welcome.auth.createLink")}
</button>
<button onClick={() => { reset(); setMode("forgot"); }} className="text-muted hover:text-fg">
<button
onClick={() => {
reset();
setMode("forgot");
}}
className="text-muted hover:text-fg"
>
{t("welcome.auth.forgotLink")}
</button>
</>
) : (
<button onClick={() => { reset(); setMode("signin"); }} className="text-accent hover:underline">
<button
onClick={() => {
reset();
setMode("signin");
}}
className="text-accent hover:underline"
>
{t("welcome.auth.backToSignin")}
</button>
)}
@@ -534,5 +555,7 @@ function AuthCard() {
function Banner({ tone, children }: { tone: "ok" | "warn"; children: React.ReactNode }) {
const cls = tone === "ok" ? "bg-accent/15 text-fg" : "bg-amber-500/15 text-fg";
return <div className={`rounded-xl px-3 py-2 text-xs leading-relaxed mt-3 ${cls}`}>{children}</div>;
return (
<div className={`rounded-xl px-3 py-2 text-xs leading-relaxed mt-3 ${cls}`}>{children}</div>
);
}
+8 -2
View File
@@ -31,7 +31,10 @@ interface WizardActions {
// Only the actions are shared — the open flag stays local to the provider (it renders the wizard
// itself), so no consumer needs to subscribe to it.
const ActionsContext = createContext<WizardActions>({ openWizard: () => {}, closeWizard: () => {} });
const ActionsContext = createContext<WizardActions>({
openWizard: () => {},
closeWizard: () => {},
});
export function useWizard(): WizardActions {
return useContext(ActionsContext);
@@ -51,7 +54,10 @@ export function WizardProvider({ children }: { children: ReactNode }) {
if (me && shouldAutoOpenOnboarding(me)) setOpen(true);
}, [me?.id, me?.can_read, me?.can_write]);
const actions = useMemo<WizardActions>(() => ({ openWizard, closeWizard }), [openWizard, closeWizard]);
const actions = useMemo<WizardActions>(
() => ({ openWizard, closeWizard }),
[openWizard, closeWizard],
);
return (
<ActionsContext.Provider value={actions}>
+3 -1
View File
@@ -13,6 +13,8 @@ export function subsColumn<T extends { subscriber_count: number | null }>(t: TFu
nowrap: true,
sortable: true,
sortValue: (c) => c.subscriber_count ?? -1,
render: (c) => <span className="text-muted tabular-nums">{formatCountOrDash(c.subscriber_count)}</span>,
render: (c) => (
<span className="text-muted tabular-nums">{formatCountOrDash(c.subscriber_count)}</span>
),
};
}
+12 -4
View File
@@ -70,11 +70,19 @@ export default function LegalLayout({
<div className="space-y-5 text-sm leading-relaxed text-fg/90">{children}</div>
</div>
<footer className="mt-6 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
<a href="/" className="hover:text-fg transition">Home</a>
<a href="/privacy" className="hover:text-fg transition">Privacy Policy</a>
<a href="/terms" className="hover:text-fg transition">Terms of Service</a>
<a href="/" className="hover:text-fg transition">
Home
</a>
<a href="/privacy" className="hover:text-fg transition">
Privacy Policy
</a>
<a href="/terms" className="hover:text-fg transition">
Terms of Service
</a>
{contact && (
<a href={`mailto:${contact}`} className="hover:text-fg transition">Contact</a>
<a href={`mailto:${contact}`} className="hover:text-fg transition">
Contact
</a>
)}
</footer>
</div>
+27 -27
View File
@@ -4,19 +4,19 @@ export default function PrivacyPolicy() {
return (
<LegalLayout title="Privacy Policy">
<p>
Siftlode is a personal, non-commercial project operated by an individual in Hungary
("we", "us"). It lets you view and organize your own YouTube subscriptions as a
filterable feed. This policy explains what data Siftlode accesses, how it is used, and
the choices you have. By using Siftlode you agree to this policy.
Siftlode is a personal, non-commercial project operated by an individual in Hungary ("we",
"us"). It lets you view and organize your own YouTube subscriptions as a filterable feed.
This policy explains what data Siftlode accesses, how it is used, and the choices you have.
By using Siftlode you agree to this policy.
</p>
<H2>What we access</H2>
<p>Siftlode only accesses data you explicitly authorize, in two separate steps:</p>
<ul className="list-disc pl-5 space-y-1">
<li>
<span className="text-fg font-medium">Sign-in (always):</span> your basic Google
profile name, email address, profile picture, and Google account identifier used
solely to create and identify your account.
<span className="text-fg font-medium">Sign-in (always):</span> your basic Google profile
name, email address, profile picture, and Google account identifier used solely to
create and identify your account.
</li>
<li>
<span className="text-fg font-medium">YouTube read (optional, opt-in):</span> with the{" "}
@@ -25,9 +25,9 @@ export default function PrivacyPolicy() {
thumbnails, durations, view counts, publish dates), used to build your feed.
</li>
<li>
<span className="text-fg font-medium">YouTube manage (optional, opt-in):</span> with
the <code className="text-xs">youtube</code> scope, the ability to unsubscribe from a
channel on YouTube performed only when you click to do so.
<span className="text-fg font-medium">YouTube manage (optional, opt-in):</span> with the{" "}
<code className="text-xs">youtube</code> scope, the ability to unsubscribe from a channel
on YouTube performed only when you click to do so.
</li>
</ul>
<p>
@@ -37,30 +37,30 @@ export default function PrivacyPolicy() {
<H2>How we use it</H2>
<p>
Your data is used exclusively to provide Siftlode's features to you: building and
displaying your personalized subscription feed, and carrying out actions (such as
unsubscribing) that you initiate. We do not use it for advertising, profiling, or any
purpose unrelated to the app.
Your data is used exclusively to provide Siftlode's features to you: building and displaying
your personalized subscription feed, and carrying out actions (such as unsubscribing) that
you initiate. We do not use it for advertising, profiling, or any purpose unrelated to the
app.
</p>
<H2>Limited Use disclosure</H2>
<p>
Siftlode's use and transfer of information received from Google APIs to any other app
will adhere to the{" "}
Siftlode's use and transfer of information received from Google APIs to any other app will
adhere to the{" "}
<LegalLink href="https://developers.google.com/terms/api-services-user-data-policy">
Google API Services User Data Policy
</LegalLink>
, including the Limited Use requirements. We do not sell your data, do not transfer it
to third parties (except as needed to operate the app, e.g. the hosting infrastructure
we control), and do not use it for advertising.
, including the Limited Use requirements. We do not sell your data, do not transfer it to
third parties (except as needed to operate the app, e.g. the hosting infrastructure we
control), and do not use it for advertising.
</p>
<H2>Storage &amp; security</H2>
<p>
Data is stored in a private PostgreSQL database on a server we control, accessible only
to the operator. Google OAuth refresh tokens are encrypted at rest. Access is restricted
to the emails explicitly invited to the app. No system is perfectly secure, but we take
reasonable measures to protect your data.
Data is stored in a private PostgreSQL database on a server we control, accessible only to
the operator. Google OAuth refresh tokens are encrypted at rest. Access is restricted to the
emails explicitly invited to the app. No system is perfectly secure, but we take reasonable
measures to protect your data.
</p>
<H2>Retention &amp; deletion</H2>
@@ -70,8 +70,8 @@ export default function PrivacyPolicy() {
<LegalLink href="https://myaccount.google.com/permissions">
myaccount.google.com/permissions
</LegalLink>
; once revoked, the stored tokens can no longer be used. To have your account and
associated data deleted, contact <ContactEmail />.
; once revoked, the stored tokens can no longer be used. To have your account and associated
data deleted, contact <ContactEmail />.
</p>
<H2>Cookies</H2>
@@ -83,8 +83,8 @@ export default function PrivacyPolicy() {
<H2>YouTube &amp; Google</H2>
<p>
Siftlode uses YouTube API Services. By using Siftlode you also agree to the{" "}
<LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>,
and your use of Google data is subject to the{" "}
<LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>, and
your use of Google data is subject to the{" "}
<LegalLink href="https://policies.google.com/privacy">Google Privacy Policy</LegalLink>.
</p>
+17 -15
View File
@@ -11,41 +11,43 @@ export default function Terms() {
<H2>The service</H2>
<p>
Siftlode is offered on an invite-only basis, free of charge, and "as is", without
warranties of any kind. It is a hobby project: features may change, and the service may
be slowed, interrupted, or discontinued at any time without notice.
Siftlode is offered on an invite-only basis, free of charge, and "as is", without warranties
of any kind. It is a hobby project: features may change, and the service may be slowed,
interrupted, or discontinued at any time without notice.
</p>
<H2>Your account &amp; acceptable use</H2>
<p>
You sign in with your Google account and may only access Siftlode with an email that has
been invited. You agree to use the service lawfully, not to abuse, disrupt, probe, or
overload it, and not to attempt to access other users' data. We may suspend or revoke
access at our discretion, for example in case of misuse.
overload it, and not to attempt to access other users' data. We may suspend or revoke access
at our discretion, for example in case of misuse.
</p>
<H2>Third-party services</H2>
<p>
Siftlode uses YouTube API Services. By using Siftlode you agree to be bound by the{" "}
<LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>.
Your data is handled as described in our{" "}
<a href="/privacy" className="text-accent hover:underline">Privacy Policy</a> and, for
Google data, the{" "}
<LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>. Your
data is handled as described in our{" "}
<a href="/privacy" className="text-accent hover:underline">
Privacy Policy
</a>{" "}
and, for Google data, the{" "}
<LegalLink href="https://policies.google.com/privacy">Google Privacy Policy</LegalLink>.
</p>
<H2>No warranty &amp; limitation of liability</H2>
<p>
To the maximum extent permitted by law, the service is provided without warranty, and
the operator is not liable for any indirect, incidental, or consequential damages, or
for any loss of data, arising from your use of Siftlode. Your sole remedy if you are
dissatisfied is to stop using the service and revoke its access to your account.
To the maximum extent permitted by law, the service is provided without warranty, and the
operator is not liable for any indirect, incidental, or consequential damages, or for any
loss of data, arising from your use of Siftlode. Your sole remedy if you are dissatisfied is
to stop using the service and revoke its access to your account.
</p>
<H2>Governing law</H2>
<p>
These Terms are governed by the laws of Hungary and applicable European Union law,
without regard to conflict-of-law rules.
These Terms are governed by the laws of Hungary and applicable European Union law, without
regard to conflict-of-law rules.
</p>
<H2>Changes</H2>
+15 -7
View File
@@ -17,8 +17,16 @@ import { type SortState } from "../lib/columnSort";
type ColumnFilter<T> =
| { kind: "text"; get: (row: T) => string }
| { kind: "select"; options: { value: string; label: string }[]; test: (row: T, value: string) => boolean }
| { kind: "multi"; options: { value: string; label: string }[]; test: (row: T, values: string[]) => boolean };
| {
kind: "select";
options: { value: string; label: string }[];
test: (row: T, value: string) => boolean;
}
| {
kind: "multi";
options: { value: string; label: string }[];
test: (row: T, values: string[]) => boolean;
};
export interface Column<T> {
key: string;
@@ -55,7 +63,7 @@ export function filterRows<T>(rows: T[], columns: Column<T>[], filters: FilterMa
if (f.kind === "text") return f.get(row).toLowerCase().includes(String(val).toLowerCase());
if (f.kind === "select") return f.test(row, String(val));
return f.test(row, val as string[]);
})
}),
);
}
@@ -247,13 +255,13 @@ export function ColumnHead<T>({
className="inline-flex items-center gap-1 hover:text-fg cursor-pointer"
>
{col.header}
{isSorted && sort && (
sort.dir === "asc" ? (
{isSorted &&
sort &&
(sort.dir === "asc" ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
)
)}
))}
</button>
) : (
<span>{col.header}</span>
+1
View File
@@ -23,6 +23,7 @@ for (const [path, mod] of Object.entries(mods)) {
const m = path.match(/\/locales\/([a-z]{2})\/([a-zA-Z0-9_]+)\.json$/);
if (!m) continue;
const [, lang, area] = m;
if (!lang || !area) continue; // both groups are required by the regex; satisfies the checker
(resources[lang] ??= { translation: {} }).translation[area] = mod.default;
}
+1 -1
View File
@@ -232,4 +232,4 @@
"count_other": "{{count}} titles",
"remove": "Remove from filter"
}
}
}
+1 -1
View File
@@ -232,4 +232,4 @@
"count_other": "{{count}} cím",
"remove": "Eltávolítás a szűrőből"
}
}
}
+217 -44
View File
@@ -66,18 +66,102 @@ html[data-theme="light"] {
doesn't scroll. Dark theme uses the dark backdrops, light theme the lighter set in /light/. Only
when the backdrop is on (the "Background image" setting; off in perf). The [data-theme] qualifier
also out-specifies the ambient `background` shorthand (which would else reset background-size). */
html[data-backdrop="on"][data-theme="dark"][data-scheme="midnight"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/midnight.svg"); }
html[data-backdrop="on"][data-theme="dark"][data-scheme="forest"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/forest.svg"); }
html[data-backdrop="on"][data-theme="dark"][data-scheme="slate"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/slate.svg"); }
html[data-backdrop="on"][data-theme="dark"][data-scheme="youtube"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/youtube.svg"); }
html[data-backdrop="on"][data-theme="dark"][data-scheme="starship"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/starship.svg"); }
html[data-backdrop="on"][data-theme="dark"][data-scheme="matrix"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/matrix.svg"); }
html[data-backdrop="on"][data-theme="light"][data-scheme="midnight"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/light/midnight.svg"); }
html[data-backdrop="on"][data-theme="light"][data-scheme="forest"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/light/forest.svg"); }
html[data-backdrop="on"][data-theme="light"][data-scheme="slate"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/light/slate.svg"); }
html[data-backdrop="on"][data-theme="light"][data-scheme="youtube"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/light/youtube.svg"); }
html[data-backdrop="on"][data-theme="light"][data-scheme="starship"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/light/starship.svg"); }
html[data-backdrop="on"][data-theme="light"][data-scheme="matrix"] body { background-image: linear-gradient(color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent)), url("/backdrops/light/matrix.svg"); }
html[data-backdrop="on"][data-theme="dark"][data-scheme="midnight"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/midnight.svg");
}
html[data-backdrop="on"][data-theme="dark"][data-scheme="forest"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/forest.svg");
}
html[data-backdrop="on"][data-theme="dark"][data-scheme="slate"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/slate.svg");
}
html[data-backdrop="on"][data-theme="dark"][data-scheme="youtube"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/youtube.svg");
}
html[data-backdrop="on"][data-theme="dark"][data-scheme="starship"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/starship.svg");
}
html[data-backdrop="on"][data-theme="dark"][data-scheme="matrix"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/matrix.svg");
}
html[data-backdrop="on"][data-theme="light"][data-scheme="midnight"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/light/midnight.svg");
}
html[data-backdrop="on"][data-theme="light"][data-scheme="forest"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/light/forest.svg");
}
html[data-backdrop="on"][data-theme="light"][data-scheme="slate"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/light/slate.svg");
}
html[data-backdrop="on"][data-theme="light"][data-scheme="youtube"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/light/youtube.svg");
}
html[data-backdrop="on"][data-theme="light"][data-scheme="starship"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/light/starship.svg");
}
html[data-backdrop="on"][data-theme="light"][data-scheme="matrix"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/light/matrix.svg");
}
html[data-backdrop="on"][data-theme="dark"] body,
html[data-backdrop="on"][data-theme="light"] body {
background-size: cover;
@@ -106,9 +190,21 @@ body {
subtle but a touch richer (three soft pools, incl. a bottom glow) now that the nav,
header and dialogs are all frosted glass and refract it. */
background:
radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) calc(20% * var(--ambient)), transparent), transparent 60%),
radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) calc(14% * var(--ambient)), transparent), transparent 55%),
radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) calc(11% * var(--ambient)), transparent), transparent 60%),
radial-gradient(
1100px 620px at 10% -10%,
color-mix(in srgb, var(--accent) calc(20% * var(--ambient)), transparent),
transparent 60%
),
radial-gradient(
1000px 720px at 115% 10%,
color-mix(in srgb, var(--accent) calc(14% * var(--ambient)), transparent),
transparent 55%
),
radial-gradient(
900px 640px at 50% 118%,
color-mix(in srgb, var(--accent) calc(11% * var(--ambient)), transparent),
transparent 60%
),
var(--bg);
background-attachment: fixed;
}
@@ -130,12 +226,19 @@ body {
The strong blur turns whatever is behind into soft colour, not a sharp image.
Layer 1 (top) = the optional scrim; layer 2 = the translucent surface tint. */
background:
linear-gradient(color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)),
linear-gradient(
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
),
color-mix(in srgb, var(--surface) var(--glass-surface-alpha), transparent);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent);
border-top-color: color-mix(in srgb, #fff var(--glass-edge), color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent));
border-top-color: color-mix(
in srgb,
#fff var(--glass-edge),
color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent)
);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff var(--glass-inset), transparent),
0 18px 44px -16px rgba(0, 0, 0, 0.6);
@@ -145,10 +248,17 @@ body {
where blur adds almost nothing visually but is GPU-expensive and triggers reflow.
Translucency + border + depth keep the look; blur is reserved for .glass overlays. */
background:
linear-gradient(color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)),
linear-gradient(
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
),
color-mix(in srgb, var(--card) var(--glass-card-alpha), transparent);
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
border-top-color: color-mix(in srgb, #fff var(--glass-edge), color-mix(in srgb, var(--border) 65%, transparent));
border-top-color: color-mix(
in srgb,
#fff var(--glass-edge),
color-mix(in srgb, var(--border) 65%, transparent)
);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) / 2), transparent),
0 8px 22px -14px rgba(0, 0, 0, 0.45);
@@ -157,12 +267,19 @@ body {
/* Floating menus/popovers hover over undimmed content (no backdrop scrim like dialogs),
so they must be near-opaque to stay readable only a hint of translucency + the blur. */
background:
linear-gradient(color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)),
linear-gradient(
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
),
color-mix(in srgb, var(--surface) var(--glass-menu-alpha), transparent);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-top-color: color-mix(in srgb, #fff var(--glass-edge), color-mix(in srgb, var(--border) 80%, transparent));
border-top-color: color-mix(
in srgb,
#fff var(--glass-edge),
color-mix(in srgb, var(--border) 80%, transparent)
);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) - 2%), transparent),
0 18px 44px -16px rgba(0, 0, 0, 0.6);
@@ -180,9 +297,21 @@ body {
(The full "videos behind glass everywhere" effect is Phase 2 / end-of-project polish.) */
html[data-theme="dark"] body {
background:
radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) calc(34% * var(--ambient)), transparent), transparent 60%),
radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) calc(24% * var(--ambient)), transparent), transparent 55%),
radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) calc(18% * var(--ambient)), transparent), transparent 60%),
radial-gradient(
1100px 620px at 10% -10%,
color-mix(in srgb, var(--accent) calc(34% * var(--ambient)), transparent),
transparent 60%
),
radial-gradient(
1000px 720px at 115% 10%,
color-mix(in srgb, var(--accent) calc(24% * var(--ambient)), transparent),
transparent 55%
),
radial-gradient(
900px 640px at 50% 118%,
color-mix(in srgb, var(--accent) calc(18% * var(--ambient)), transparent),
transparent 60%
),
var(--bg);
background-attachment: fixed;
}
@@ -190,8 +319,10 @@ html[data-theme="dark"] body {
the frosted sheen shows even over dark UI (over colourful content it just looks richer). */
html[data-theme="dark"] .glass,
html[data-theme="dark"] .glass-menu {
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)) brightness(var(--glass-dark-bright));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)) brightness(var(--glass-dark-bright));
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate))
brightness(var(--glass-dark-bright));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate))
brightness(var(--glass-dark-bright));
}
/* Toasts surface bottom-left (by the nav's notification bell), off the conventional
top-right; a brighter rim draws the eye there. Most needed in dark mode, where the
@@ -228,39 +359,81 @@ html[data-perf="1"] body {
/* ===== Motion ===== */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes overlayIn {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes overlayOut {
from { opacity: 1; }
to { opacity: 0; }
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes panelIn {
from { transform: translateX(100%); }
to { transform: translateX(0); }
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes panelOut {
from { transform: translateX(0); }
to { transform: translateX(100%); }
from {
transform: translateX(0);
}
to {
transform: translateX(100%);
}
}
@keyframes popIn {
from { opacity: 0; transform: translateY(-6px) scale(0.97); }
to { opacity: 1; transform: none; }
from {
opacity: 0;
transform: translateY(-6px) scale(0.97);
}
to {
opacity: 1;
transform: none;
}
}
/* One-shot attention flash for a chat dock window on a new message. Inset glow so the
window's own overflow-hidden doesn't clip it; pulses twice then fades. */
@keyframes chatFlash {
0% { box-shadow: inset 0 0 0 0 transparent; }
18% { box-shadow: inset 0 0 0 3px var(--accent), inset 0 0 22px 0 color-mix(in srgb, var(--accent) 55%, transparent); }
40% { box-shadow: inset 0 0 0 1px transparent; }
62% { box-shadow: inset 0 0 0 3px var(--accent), inset 0 0 22px 0 color-mix(in srgb, var(--accent) 55%, transparent); }
100% { box-shadow: inset 0 0 0 0 transparent; }
0% {
box-shadow: inset 0 0 0 0 transparent;
}
18% {
box-shadow:
inset 0 0 0 3px var(--accent),
inset 0 0 22px 0 color-mix(in srgb, var(--accent) 55%, transparent);
}
40% {
box-shadow: inset 0 0 0 1px transparent;
}
62% {
box-shadow:
inset 0 0 0 3px var(--accent),
inset 0 0 22px 0 color-mix(in srgb, var(--accent) 55%, transparent);
}
100% {
box-shadow: inset 0 0 0 0 transparent;
}
}
.chat-flash {
animation: chatFlash 1.1s ease-out;
}
.chat-flash { animation: chatFlash 1.1s ease-out; }
/* ===== Color schemes (accent + neutrals), each with dark + light ===== */
+152 -74
View File
@@ -237,7 +237,8 @@ function localizeDetail(d: any): string {
if (d.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: d.limit });
return t("downloads.errors.quota.generic");
}
if (d?.code === "edit") return t(`downloads.errors.edit.${d.reason}`, t("downloads.errors.edit.generic"));
if (d?.code === "edit")
return t(`downloads.errors.edit.${d.reason}`, t("downloads.errors.edit.generic"));
return t("errors.generic");
}
@@ -789,7 +790,13 @@ export interface PlexItemDetail {
markers: PlexMarker[];
audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[];
// `text`=true → offered as a WebVTT <track>; image subs (PGS/VobSub) are text=false (hidden).
subtitle_streams: { ord: number; label: string; language?: string | null; codec?: string; text: boolean }[];
subtitle_streams: {
ord: number;
label: string;
language?: string | null;
codec?: string;
text: boolean;
}[];
status: string;
position_seconds: number;
show_title?: string | null;
@@ -992,13 +999,7 @@ interface DownloadAsset {
expires_at: string | null;
}
export type DownloadStatus =
| "queued"
| "running"
| "paused"
| "done"
| "error"
| "canceled";
export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled";
// Phase-2 editor recipe. A single `trim` (one output file) or a `segments` cut-list joined into
// one file. `accurate` re-encodes for a frame-accurate cut (a crop always re-encodes).
@@ -1101,8 +1102,7 @@ export const api = {
return r && r.authenticated ? (r as Me) : null;
},
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
deleteAccount: (): Promise<{ deleted: boolean }> =>
req("/api/me/account", { method: "DELETE" }),
deleteAccount: (): Promise<{ deleted: boolean }> => req("/api/me/account", { method: "DELETE" }),
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
// Signs the current tab's active account out of the browser wallet (per-tab aware via the
@@ -1138,7 +1138,7 @@ export const api = {
req(
`/api/videos/${id}/state`,
{ method: "POST", body: JSON.stringify({ status }) },
{ idempotent: true }
{ idempotent: true },
),
clearState: (id: string) =>
req(`/api/videos/${id}/state`, { method: "DELETE" }, { idempotent: true }),
@@ -1152,7 +1152,7 @@ export const api = {
duration_seconds: Math.floor(durationSeconds),
}),
},
{ idempotent: true }
{ idempotent: true },
),
// Fire-and-forget YT progress save that survives page unload (F5/close/navigate).
saveProgressBeacon: (id: string, positionSeconds: number, durationSeconds: number): void =>
@@ -1172,22 +1172,18 @@ export const api = {
channels: (): Promise<ManagedChannel[]> => req("/api/channels"),
updateChannel: (
id: string,
patch: { priority?: number; hidden?: boolean; deep_requested?: boolean }
patch: { priority?: number; hidden?: boolean; deep_requested?: boolean },
) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
resetChannelBackfill: (id: string) =>
req(`/api/channels/${id}/reset-backfill`, { method: "POST" }),
deepAll: (on = true) =>
req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
deepAll: (on = true) => req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
attachChannelTag: (id: string, tagId: number) =>
req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }),
detachChannelTag: (id: string, tagId: number) =>
req(`/api/channels/${id}/tags/${tagId}`, { method: "DELETE" }),
unsubscribeChannel: (id: string) =>
req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
discoveredChannels: (): Promise<DiscoveredChannel[]> =>
req("/api/channels/discovery"),
subscribeChannel: (id: string) =>
req(`/api/channels/${id}/subscribe`, { method: "POST" }),
unsubscribeChannel: (id: string) => req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
discoveredChannels: (): Promise<DiscoveredChannel[]> => req("/api/channels/discovery"),
subscribeChannel: (id: string) => req(`/api/channels/${id}/subscribe`, { method: "POST" }),
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
// --- channel page (About detail + ephemeral exploration of un-subscribed channels) ---
channelDetail: (id: string): Promise<ChannelDetail> => req(`/api/channels/${id}`),
@@ -1201,12 +1197,13 @@ export const api = {
return req(
`/api/channels/${id}/explore${qs ? `?${qs}` : ""}`,
{ method: "POST" },
{ quiet: true }
{ quiet: true },
);
},
// --- per-user channel blocklist (excluded from live search + feed/explore) ---
blockedChannels: (): Promise<{ id: string; title: string | null; handle: string | null; thumbnail_url: string | null }[]> =>
req("/api/channels/blocked/list"),
blockedChannels: (): Promise<
{ id: string; title: string | null; handle: string | null; thumbnail_url: string | null }[]
> => req("/api/channels/blocked/list"),
blockChannel: (id: string) => req(`/api/channels/${id}/block`, { method: "POST" }),
unblockChannel: (id: string) => req(`/api/channels/${id}/block`, { method: "DELETE" }),
// Admin: reclaim all un-kept discovery content (search results + explored channels) now.
@@ -1247,8 +1244,7 @@ export const api = {
req("/api/playlists/sync-youtube", { method: "POST" }),
revertPlaylist: (id: number): Promise<{ items: number }> =>
req(`/api/playlists/${id}/revert-youtube`, { method: "POST" }),
playlistPushPlan: (id: number): Promise<PushPlan> =>
req(`/api/playlists/${id}/push-plan`),
playlistPushPlan: (id: number): Promise<PushPlan> => req(`/api/playlists/${id}/push-plan`),
pushPlaylist: (id: number): Promise<PushResult> =>
req(`/api/playlists/${id}/push`, { method: "POST" }),
@@ -1307,18 +1303,35 @@ export const api = {
return req(`/api/plex/collections${qs ? `?${qs}` : ""}`);
},
// --- Collection editing (admin only; writes back to Plex) ---
plexCreateCollection: (library: string, title: string, item_rating_key: string): Promise<PlexCollection> =>
req(`/api/plex/collections`, { method: "POST", body: JSON.stringify({ library, title, item_rating_key }) }),
plexCreateCollection: (
library: string,
title: string,
item_rating_key: string,
): Promise<PlexCollection> =>
req(`/api/plex/collections`, {
method: "POST",
body: JSON.stringify({ library, title, item_rating_key }),
}),
plexCollectionAddItem: (rk: string, itemRk: string): Promise<PlexCollection> =>
req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { method: "POST" }),
req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, {
method: "POST",
}),
plexCollectionRemoveItem: (rk: string, itemRk: string): Promise<PlexCollection> =>
req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { method: "DELETE" }),
req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, {
method: "DELETE",
}),
plexRenameCollection: (rk: string, title: string): Promise<PlexCollection> =>
req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "PATCH", body: JSON.stringify({ title }) }),
req(`/api/plex/collections/${encodeURIComponent(rk)}`, {
method: "PATCH",
body: JSON.stringify({ title }),
}),
plexDeleteCollection: (rk: string): Promise<{ deleted: string }> =>
req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "DELETE" }),
plexSetCollectionEditable: (rk: string, editable: boolean): Promise<PlexCollection> =>
req(`/api/plex/collections/${encodeURIComponent(rk)}/editable`, { method: "POST", body: JSON.stringify({ editable }) }),
req(`/api/plex/collections/${encodeURIComponent(rk)}/editable`, {
method: "POST",
body: JSON.stringify({ editable }),
}),
// --- Playlists (Siftlode-native, per-user, ordered) ---
// `contains`=single rating_key → per-playlist has_item; `containsGroup`=a set of rating_keys (a whole
// season/show) → per-playlist group_in + a top-level group_size (for the bulk add-to-playlist dialog).
@@ -1334,39 +1347,76 @@ export const api = {
},
plexPlaylist: (id: number): Promise<PlexPlaylistDetail> => req(`/api/plex/playlists/${id}`),
plexCreatePlaylist: (title: string, item_rating_key?: string): Promise<PlexPlaylist> =>
req(`/api/plex/playlists`, { method: "POST", body: JSON.stringify({ title, item_rating_key }) }),
plexPlaylistAddBulk: (id: number, itemRks: string[]): Promise<{ added: number; item_count: number }> =>
req(`/api/plex/playlists/${id}/items/bulk`, { method: "POST", body: JSON.stringify({ item_rating_keys: itemRks }) }),
plexPlaylistRemoveBulk: (id: number, itemRks: string[]): Promise<{ removed: number; item_count: number }> =>
req(`/api/plex/playlists/${id}/items/remove-bulk`, { method: "POST", body: JSON.stringify({ item_rating_keys: itemRks }) }),
req(`/api/plex/playlists`, {
method: "POST",
body: JSON.stringify({ title, item_rating_key }),
}),
plexPlaylistAddBulk: (
id: number,
itemRks: string[],
): Promise<{ added: number; item_count: number }> =>
req(`/api/plex/playlists/${id}/items/bulk`, {
method: "POST",
body: JSON.stringify({ item_rating_keys: itemRks }),
}),
plexPlaylistRemoveBulk: (
id: number,
itemRks: string[],
): Promise<{ removed: number; item_count: number }> =>
req(`/api/plex/playlists/${id}/items/remove-bulk`, {
method: "POST",
body: JSON.stringify({ item_rating_keys: itemRks }),
}),
plexRenamePlaylist: (id: number, title: string): Promise<PlexPlaylist> =>
req(`/api/plex/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ title }) }),
plexDeletePlaylist: (id: number): Promise<{ deleted: number }> =>
req(`/api/plex/playlists/${id}`, { method: "DELETE" }),
plexPlaylistAddItem: (id: number, itemRk: string): Promise<{ ok: boolean }> =>
req(`/api/plex/playlists/${id}/items`, { method: "POST", body: JSON.stringify({ item_rating_key: itemRk }) }),
req(`/api/plex/playlists/${id}/items`, {
method: "POST",
body: JSON.stringify({ item_rating_key: itemRk }),
}),
plexPlaylistRemoveItem: (id: number, itemRk: string): Promise<{ ok: boolean }> =>
req(`/api/plex/playlists/${id}/items/${encodeURIComponent(itemRk)}`, { method: "DELETE" }),
plexReorderPlaylist: (id: number, itemRks: string[]): Promise<{ ok: boolean }> =>
req(`/api/plex/playlists/${id}/order`, { method: "PUT", body: JSON.stringify({ item_rating_keys: itemRks }) }),
req(`/api/plex/playlists/${id}/order`, {
method: "PUT",
body: JSON.stringify({ item_rating_keys: itemRks }),
}),
plexShow: (id: string): Promise<PlexShowDetail> =>
req(`/api/plex/show/${encodeURIComponent(id)}`),
// Mark a whole show / season watched or unwatched (all its episodes) for the current user.
plexShowState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
req(`/api/plex/show/${encodeURIComponent(rk)}/state`, { method: "POST", body: JSON.stringify({ watched }) }),
req(`/api/plex/show/${encodeURIComponent(rk)}/state`, {
method: "POST",
body: JSON.stringify({ watched }),
}),
plexSeasonState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
req(`/api/plex/season/${encodeURIComponent(rk)}/state`, { method: "POST", body: JSON.stringify({ watched }) }),
req(`/api/plex/season/${encodeURIComponent(rk)}/state`, {
method: "POST",
body: JSON.stringify({ watched }),
}),
plexItem: (id: string): Promise<PlexItemDetail> =>
req(`/api/plex/item/${encodeURIComponent(id)}`),
// Cards for the actor filter-chips currently applied: name + cached headshot + in-scope film/show count.
plexPeopleCards: (names: string[], scope: string): Promise<{ people: PlexPersonCard[] }> =>
req(`/api/plex/people/cards?scope=${encodeURIComponent(scope)}&names=${encodeURIComponent(names.join(","))}`),
plexSession: (id: string, start = 0, audio?: number | null, aoff = 0, multi = false): Promise<PlexPlaySession> => {
req(
`/api/plex/people/cards?scope=${encodeURIComponent(scope)}&names=${encodeURIComponent(names.join(","))}`,
),
plexSession: (
id: string,
start = 0,
audio?: number | null,
aoff = 0,
multi = false,
): Promise<PlexPlaySession> => {
const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) });
if (audio != null) p.set("audio", String(audio));
if (aoff) p.set("aoff", String(aoff));
if (multi) p.set("multi", "1"); // ≥2 audio tracks → multi-rendition HLS (client-side audio switch)
return req(`/api/plex/stream/${encodeURIComponent(id)}/session?${p.toString()}`, { method: "POST" });
return req(`/api/plex/stream/${encodeURIComponent(id)}/session?${p.toString()}`, {
method: "POST",
});
},
// WebVTT URL for a text subtitle track (used directly as a <track src>). Same-origin → cookie-authed.
// `offset` = the session's real media start (0 for direct-play); the backend shifts the absolute
@@ -1408,7 +1458,9 @@ export const api = {
}),
// Two-way Plex watch-sync (Phase A: owner link + one-time "Plex is master" import).
plexWatchLink: (): Promise<PlexWatchLink> => req("/api/plex/watch/link"),
plexWatchSetLink: (enabled: boolean): Promise<PlexWatchLink & { import: PlexWatchImport | null }> =>
plexWatchSetLink: (
enabled: boolean,
): Promise<PlexWatchLink & { import: PlexWatchImport | null }> =>
req("/api/plex/watch/link", { method: "POST", body: JSON.stringify({ enabled }) }),
plexWatchImport: (): Promise<PlexWatchLink & { import: PlexWatchImport }> =>
req("/api/plex/watch/import", { method: "POST" }),
@@ -1428,7 +1480,10 @@ export const api = {
adminDeleteUser: (id: number): Promise<{ deleted: number }> =>
req(`/api/admin/users/${id}`, { method: "DELETE" }),
schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
updateSchedulerJob: (jobId: string, intervalMinutes: number): Promise<{ id: string; interval_minutes: number }> =>
updateSchedulerJob: (
jobId: string,
intervalMinutes: number,
): Promise<{ id: string; interval_minutes: number }> =>
req(`/api/admin/scheduler/jobs/${jobId}`, {
method: "PATCH",
body: JSON.stringify({ interval_minutes: intervalMinutes }),
@@ -1455,22 +1510,50 @@ export const api = {
setupInfo: (token: string): Promise<{ secrets_manageable: boolean }> =>
req("/api/setup/info", { headers: setupHeaders(token) }, { quiet: true }),
setupAdmin: (token: string, email: string, password: string): Promise<{ ok: boolean }> =>
req("/api/setup/admin", { method: "POST", headers: setupHeaders(token), body: JSON.stringify({ email, password }) }, { quiet: true }),
req(
"/api/setup/admin",
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify({ email, password }) },
{ quiet: true },
),
setupConfig: (token: string, values: Record<string, unknown>): Promise<{ saved: string[] }> =>
req("/api/setup/config", { method: "POST", headers: setupHeaders(token), body: JSON.stringify(values) }, { quiet: true }),
req(
"/api/setup/config",
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify(values) },
{ quiet: true },
),
setupTestEmail: (token: string, to: string): Promise<{ ok: boolean }> =>
req("/api/setup/test-email", { method: "POST", headers: setupHeaders(token), body: JSON.stringify({ to }) }, { quiet: true }),
req(
"/api/setup/test-email",
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify({ to }) },
{ quiet: true },
),
setupFinish: (token: string): Promise<{ ok: boolean }> =>
req("/api/setup/finish", { method: "POST", headers: setupHeaders(token) }, { quiet: true }),
// --- auth: email + password (errors handled inline via quiet) ---
register: (email: string, password: string): Promise<{ status: string }> =>
req("/auth/register", { method: "POST", body: JSON.stringify({ email, password }) }, { quiet: true }),
req(
"/auth/register",
{ method: "POST", body: JSON.stringify({ email, password }) },
{ quiet: true },
),
passwordLogin: (email: string, password: string): Promise<{ ok: boolean }> =>
req("/auth/password-login", { method: "POST", body: JSON.stringify({ email, password }) }, { quiet: true }),
req(
"/auth/password-login",
{ method: "POST", body: JSON.stringify({ email, password }) },
{ quiet: true },
),
requestPasswordReset: (email: string): Promise<{ status: string }> =>
req("/auth/password-reset/request", { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }),
req(
"/auth/password-reset/request",
{ method: "POST", body: JSON.stringify({ email }) },
{ quiet: true },
),
confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
req("/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) }, { quiet: true }),
req(
"/auth/password-reset/confirm",
{ method: "POST", body: JSON.stringify({ token, password }) },
{ quiet: true },
),
// Confirm an email-verification token the SPA read from the URL fragment (SB3).
verifyEmail: (token: string): Promise<{ ok: boolean }> =>
req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }),
@@ -1481,7 +1564,7 @@ export const api = {
req(
"/auth/set-password",
{ method: "POST", body: JSON.stringify({ password, current_password: currentPassword }) },
{ quiet: true }
{ quiet: true },
),
// --- onboarding / admin ---
requestAccess: (email: string): Promise<{ status: string }> =>
@@ -1490,18 +1573,15 @@ export const api = {
// sign-in. Returns whether a session was established.
demoLogin: (email: string): Promise<{ authenticated: boolean }> =>
req("/auth/demo", { method: "POST", body: JSON.stringify({ email }) }),
adminDemoWhitelist: (): Promise<DemoWhitelistEntry[]> =>
req("/api/admin/demo/whitelist"),
adminDemoWhitelist: (): Promise<DemoWhitelistEntry[]> => req("/api/admin/demo/whitelist"),
addDemoWhitelist: (email: string): Promise<DemoWhitelistEntry> =>
req("/api/admin/demo/whitelist", { method: "POST", body: JSON.stringify({ email }) }),
removeDemoWhitelist: (id: number) =>
req(`/api/admin/demo/whitelist/${id}`, { method: "DELETE" }),
removeDemoWhitelist: (id: number) => req(`/api/admin/demo/whitelist/${id}`, { method: "DELETE" }),
resetDemo: (): Promise<{ reset: boolean; playlists_seeded: number }> =>
req("/api/admin/demo/reset", { method: "POST" }),
adminInvites: (status?: string): Promise<Invite[]> =>
req(`/api/admin/invites${status ? `?status=${status}` : ""}`),
approveInvite: (id: number) =>
req(`/api/admin/invites/${id}/approve`, { method: "POST" }),
approveInvite: (id: number) => req(`/api/admin/invites/${id}/approve`, { method: "POST" }),
denyInvite: (id: number) => req(`/api/admin/invites/${id}/deny`, { method: "POST" }),
addInvite: (email: string) =>
req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }),
@@ -1511,10 +1591,8 @@ export const api = {
req(`/api/me/notifications?include_dismissed=${includeDismissed}`),
notificationUnreadCount: (): Promise<{ count: number }> =>
req("/api/me/notifications/unread_count"),
markNotificationRead: (id: number) =>
req(`/api/me/notifications/${id}/read`, { method: "POST" }),
markAllNotificationsRead: () =>
req("/api/me/notifications/read_all", { method: "POST" }),
markNotificationRead: (id: number) => req(`/api/me/notifications/${id}/read`, { method: "POST" }),
markAllNotificationsRead: () => req("/api/me/notifications/read_all", { method: "POST" }),
dismissNotification: (id: number) =>
req(`/api/me/notifications/${id}/dismiss`, { method: "POST" }),
clearNotifications: () => req("/api/me/notifications/clear", { method: "POST" }),
@@ -1563,19 +1641,17 @@ export const api = {
req("/api/downloads/profiles", { method: "POST", body: JSON.stringify(p) }),
updateDownloadProfile: (
id: number,
patch: { name?: string; spec?: DownloadSpec }
patch: { name?: string; spec?: DownloadSpec },
): Promise<DownloadProfile> =>
req(`/api/downloads/profiles/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
deleteDownloadProfile: (id: number) =>
req(`/api/downloads/profiles/${id}`, { method: "DELETE" }),
deleteDownloadProfile: (id: number) => req(`/api/downloads/profiles/${id}`, { method: "DELETE" }),
enqueueDownload: (body: {
source: string;
profile_id?: number;
spec?: DownloadSpec;
display_name?: string;
}): Promise<DownloadJob> =>
req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
}): Promise<DownloadJob> => req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
enqueueEdit: (body: {
source_job_id: number;
edit_spec: EditSpec;
@@ -1606,7 +1682,7 @@ export const api = {
display_uploader?: string;
display_uploader_url?: string;
extra_links?: string[];
}
},
): Promise<DownloadJob> =>
req(`/api/downloads/${id}`, { method: "PATCH", body: JSON.stringify(meta) }),
shareDownload: (id: number, email: string): Promise<{ shared_with: string }> =>
@@ -1619,12 +1695,12 @@ export const api = {
downloadLinks: (jobId: number): Promise<ShareLink[]> => req(`/api/downloads/${jobId}/links`),
createDownloadLink: (
jobId: number,
body: { allow_download?: boolean; expires_days?: number | null; password?: string }
body: { allow_download?: boolean; expires_days?: number | null; password?: string },
): Promise<ShareLink> =>
req(`/api/downloads/${jobId}/links`, { method: "POST", body: JSON.stringify(body) }),
updateDownloadLink: (
linkId: number,
patch: { allow_download?: boolean; expires_days?: number | null; password?: string }
patch: { allow_download?: boolean; expires_days?: number | null; password?: string },
): Promise<ShareLink> =>
req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }),
revokeDownloadLink: (linkId: number): Promise<{ deleted: number }> =>
@@ -1651,7 +1727,9 @@ export const api = {
req(`/api/admin/downloads/quota/${userId}`),
adminSetDownloadQuota: (
userId: number,
patch: Partial<Pick<AdminDownloadQuota, "max_bytes" | "max_concurrent" | "max_jobs" | "unlimited">>
patch: Partial<
Pick<AdminDownloadQuota, "max_bytes" | "max_concurrent" | "max_jobs" | "unlimited">
>,
): Promise<AdminDownloadQuota> =>
req(`/api/admin/downloads/quota/${userId}`, { method: "PUT", body: JSON.stringify(patch) }),
adminResetDownloadQuota: (userId: number): Promise<AdminDownloadQuota> =>
+20 -10
View File
@@ -48,7 +48,8 @@ export function initDock(uid: number): void {
let restored: DockChat[] = [];
try {
const raw = localStorage.getItem(storageKey()!);
if (raw) restored = (JSON.parse(raw) as Omit<DockChat, "flash">[]).map((c) => ({ ...c, flash: 0 }));
if (raw)
restored = (JSON.parse(raw) as Omit<DockChat, "flash">[]).map((c) => ({ ...c, flash: 0 }));
} catch {
restored = [];
}
@@ -60,17 +61,20 @@ export function openChat(partner: PartnerLike): void {
const existing = chats.find((c) => c.partnerId === partner.id);
if (existing) {
// Re-opening focuses it: ensure expanded and move to the front.
emit([
{ ...existing, minimized: false },
...chats.filter((c) => c.partnerId !== partner.id),
]);
emit([{ ...existing, minimized: false }, ...chats.filter((c) => c.partnerId !== partner.id)]);
return;
}
emit(
[
{ partnerId: partner.id, name: partner.name, avatar: partner.avatar_url, minimized: false, flash: 0 },
{
partnerId: partner.id,
name: partner.name,
avatar: partner.avatar_url,
minimized: false,
flash: 0,
},
...chats,
].slice(0, MAX_OPEN)
].slice(0, MAX_OPEN),
);
}
@@ -81,9 +85,15 @@ export function notifyIncoming(partner: PartnerLike): void {
if (!existing) {
emit(
[
{ partnerId: partner.id, name: partner.name, avatar: partner.avatar_url, minimized: false, flash: 1 },
{
partnerId: partner.id,
name: partner.name,
avatar: partner.avatar_url,
minimized: false,
flash: 1,
},
...chats,
].slice(0, MAX_OPEN)
].slice(0, MAX_OPEN),
);
return;
}
@@ -105,6 +115,6 @@ export function useDockChats(): DockChat[] {
return () => subs.delete(cb);
},
() => chats,
() => chats
() => chats,
);
}
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { sortRows, parseSortState, cycleSort, type SortState } from "./columnSort";
interface Row {
name: string;
views: number;
}
const rows: Row[] = [
{ name: "beta", views: 30 },
{ name: "alpha", views: 100 },
{ name: "gamma", views: 5 },
];
const columns = [
{ key: "name", sortValue: (r: Row) => r.name },
{ key: "views", sortValue: (r: Row) => r.views },
{ key: "nosort" }, // a column with no sortValue
];
describe("sortRows", () => {
it("returns the same array reference when there is no sort", () => {
expect(sortRows(rows, columns, null)).toBe(rows);
});
it("returns the input unchanged for a column that has no sortValue", () => {
expect(sortRows(rows, columns, { key: "nosort", dir: "asc" })).toBe(rows);
});
it("sorts numbers by numeric difference, not string order", () => {
// string order would put "100" before "30"; numeric must not.
const asc = sortRows(rows, columns, { key: "views", dir: "asc" }).map((r) => r.views);
expect(asc).toEqual([5, 30, 100]);
const desc = sortRows(rows, columns, { key: "views", dir: "desc" }).map((r) => r.views);
expect(desc).toEqual([100, 30, 5]);
});
it("sorts strings with locale compare, ascending and descending", () => {
expect(sortRows(rows, columns, { key: "name", dir: "asc" }).map((r) => r.name)).toEqual([
"alpha",
"beta",
"gamma",
]);
expect(sortRows(rows, columns, { key: "name", dir: "desc" }).map((r) => r.name)).toEqual([
"gamma",
"beta",
"alpha",
]);
});
it("does not mutate the input array", () => {
const before = rows.map((r) => r.name);
sortRows(rows, columns, { key: "name", dir: "asc" });
expect(rows.map((r) => r.name)).toEqual(before);
});
});
describe("parseSortState", () => {
it("accepts a well-formed state", () => {
expect(parseSortState({ key: "views", dir: "desc" })).toEqual({ key: "views", dir: "desc" });
});
it.each([
["null", null],
["a string", "views"],
["missing dir", { key: "views" }],
["a bad dir", { key: "views", dir: "sideways" }],
["a non-string key", { key: 3, dir: "asc" }],
])("coerces %s to null rather than passing junk to the comparator", (_label, raw) => {
expect(parseSortState(raw)).toBeNull();
});
});
describe("cycleSort", () => {
it("walks unsorted → asc → desc → unsorted for the same key", () => {
let s: SortState = null;
s = cycleSort(s, "views");
expect(s).toEqual({ key: "views", dir: "asc" });
s = cycleSort(s, "views");
expect(s).toEqual({ key: "views", dir: "desc" });
s = cycleSort(s, "views");
expect(s).toBeNull();
});
it("jumps straight to ascending when a different column is clicked", () => {
expect(cycleSort({ key: "name", dir: "desc" }, "views")).toEqual({ key: "views", dir: "asc" });
});
});
+19 -16
View File
@@ -14,9 +14,8 @@ const INLINE_RE =
function tsToSeconds(ts: string): number {
const parts = ts.split(":").map((n) => parseInt(n, 10));
return parts.length === 3
? parts[0] * 3600 + parts[1] * 60 + parts[2]
: parts[0] * 60 + parts[1];
const [a = 0, b = 0, c = 0] = parts; // hh:mm:ss or mm:ss — absent segments default to 0
return parts.length === 3 ? a * 3600 + b * 60 + c : a * 60 + b;
}
// Parse a YouTube `t`/`start` param: "90", "90s", or "1h2m3s".
@@ -26,9 +25,7 @@ function parseStart(t: string | null): number | null {
const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
if (!m) return null;
const total =
parseInt(m[1] || "0", 10) * 3600 +
parseInt(m[2] || "0", 10) * 60 +
parseInt(m[3] || "0", 10);
parseInt(m[1] || "0", 10) * 3600 + parseInt(m[2] || "0", 10) * 60 + parseInt(m[3] || "0", 10);
return total || null;
}
@@ -48,7 +45,7 @@ function parseYouTube(url: string): { id: string; start: number | null } | null
if (u.pathname === "/watch") id = u.searchParams.get("v");
else {
const m = u.pathname.match(/^\/(?:shorts|embed|live)\/([^/?#]+)/);
if (m) id = m[1];
if (m) id = m[1] ?? null;
}
}
if (!id) return null;
@@ -62,7 +59,7 @@ export function renderDescription(
onSeek: (seconds: number) => void;
onLoadVideo: (id: string, start: number | null) => void;
t: TFunction;
}
},
): ReactNode[] {
const { t } = opts;
const out: ReactNode[] = [];
@@ -70,7 +67,10 @@ export function renderDescription(
const linkCls = "text-accent hover:underline break-all";
// Tidy YouTube descriptions: drop trailing spaces and remove blank lines entirely
// (they're just noise in the popover), keeping single line breaks.
const clean = text.replace(/[ \t]+\n/g, "\n").replace(/\n{2,}/g, "\n").trim();
const clean = text
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{2,}/g, "\n")
.trim();
for (const chunk of clean.split(URL_RE)) {
if (/^https?:\/\//.test(chunk)) {
const href = chunk.replace(/[.,;:!?)\]]+$/, "");
@@ -87,13 +87,13 @@ export function renderDescription(
title={sameVideo ? t("player.jumpToTime") : t("player.playInApp")}
>
{href}
</button>
</button>,
);
} else {
out.push(
<a key={key++} href={href} target="_blank" rel="noreferrer" className={linkCls}>
{href}
</a>
</a>,
);
}
continue;
@@ -110,9 +110,10 @@ export function renderDescription(
out.push(
<a key={key++} href={`mailto:${email}`} className={linkCls}>
{email}
</a>
</a>,
);
if (m[1].length > email.length) out.push(<span key={key++}>{m[1].slice(email.length)}</span>);
if (m[1].length > email.length)
out.push(<span key={key++}>{m[1].slice(email.length)}</span>);
} else if (m[2]) {
// Hashtag → YouTube's hashtag feed (mirrors native YouTube behavior).
const tag = m[2].slice(1);
@@ -125,10 +126,12 @@ export function renderDescription(
className={linkCls}
>
{m[2]}
</a>
</a>,
);
} else {
const ts = m[3];
// Reached only when neither m[1] (email) nor m[2] (hashtag) matched, so the third
// alternative — the timestamp group — is the one that fired.
const ts = m[3]!;
out.push(
<button
key={key++}
@@ -136,7 +139,7 @@ export function renderDescription(
className="text-accent hover:underline"
>
{ts}
</button>
</button>,
);
}
last = m.index + m[0].length;

Some files were not shown because too many files have changed in this diff Show More