Merge: promote dev to prod
This commit is contained in:
+10
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
dist
|
||||
node_modules
|
||||
playwright-report
|
||||
test-results
|
||||
e2e/.auth
|
||||
package-lock.json
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": false,
|
||||
"semi": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()}`);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
Generated
+2435
File diff suppressed because it is too large
Load Diff
+20
-2
@@ -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
@@ -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.
|
||||
|
||||
@@ -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}` : "") : "…",
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")} />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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 <html>). Reset clears these back to the scheme.
|
||||
Overrides the live scheme (inline on <html>). 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -78,6 +78,6 @@ export default function Modal({
|
||||
<div className="px-5 pb-5">{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 0–100
|
||||
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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -98,7 +98,7 @@ function TagRow({
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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 & 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 & 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>
|
||||
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -232,4 +232,4 @@
|
||||
"count_other": "{{count}} titles",
|
||||
"remove": "Remove from filter"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,4 +232,4 @@
|
||||
"count_other": "{{count}} cím",
|
||||
"remove": "Eltávolítás a szűrőből"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+217
-44
@@ -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
@@ -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> =>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user