style(frontend): Prettier config + one-time repo-wide format pass
Prettier 3 (pinned), printWidth 100, double quotes — chosen to match the existing hand-formatting and minimise reflow. This commit is ONLY the mechanical format pass (`prettier --write`) plus the config/ignore/scripts, isolated so it never pollutes a feature diff. Verified behaviour-neutral: typecheck, eslint (0 errors), and vitest all green before and after. See .git-blame-ignore-revs — `git blame` skips this sha.
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
@@ -59,7 +59,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 +70,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 +83,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 +95,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()}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+34
-45
@@ -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);
|
||||
@@ -136,7 +126,6 @@ export default function App() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
// First-run install gate: a fresh instance reports configured=false and runs in setup mode.
|
||||
// We check this before anything else and render the wizard; `me` is held back until we know the
|
||||
@@ -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}` : "") : "…",
|
||||
|
||||
@@ -133,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
|
||||
@@ -151,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" />
|
||||
)}
|
||||
@@ -180,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();
|
||||
@@ -121,16 +133,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 +152,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
|
||||
@@ -161,114 +174,120 @@ export default function ConfigPanel() {
|
||||
</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 +368,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 +379,4 @@ function Field({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +323,7 @@ export default function Feed({
|
||||
})
|
||||
.catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved })));
|
||||
},
|
||||
[qc]
|
||||
[qc],
|
||||
);
|
||||
|
||||
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
|
||||
@@ -410,7 +416,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 +493,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 +605,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}
|
||||
@@ -295,7 +347,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 +367,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 +384,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 +398,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,7 +117,7 @@ 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,
|
||||
@@ -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);
|
||||
@@ -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,7 +273,7 @@ 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 = () => {
|
||||
@@ -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
|
||||
@@ -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();
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
@@ -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"] }));
|
||||
}
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -149,7 +160,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 +182,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 +246,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 +255,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 +269,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,7 +287,11 @@ 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;
|
||||
@@ -331,12 +356,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 +394,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 +408,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 +431,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 +441,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 +466,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 +478,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 +500,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 +533,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 +549,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 +569,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 +586,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>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,9 +14,7 @@ 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];
|
||||
return parts.length === 3 ? parts[0] * 3600 + parts[1] * 60 + parts[2] : parts[0] * 60 + parts[1];
|
||||
}
|
||||
|
||||
// Parse a YouTube `t`/`start` param: "90", "90s", or "1h2m3s".
|
||||
@@ -26,9 +24,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;
|
||||
}
|
||||
|
||||
@@ -62,7 +58,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 +66,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 +86,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 +109,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,7 +125,7 @@ export function renderDescription(
|
||||
className={linkCls}
|
||||
>
|
||||
{m[2]}
|
||||
</a>
|
||||
</a>,
|
||||
);
|
||||
} else {
|
||||
const ts = m[3];
|
||||
@@ -136,7 +136,7 @@ export function renderDescription(
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{ts}
|
||||
</button>
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
|
||||
+44
-13
@@ -87,18 +87,22 @@ export async function clearDevice(userId: number): Promise<void> {
|
||||
|
||||
// --- key derivation ---
|
||||
async function wrapKeyFrom(passphrase: string, salt: Uint8Array): Promise<CryptoKey> {
|
||||
const base = await subtle.importKey("raw", bsrc(enc.encode(passphrase)), "PBKDF2", false, ["deriveKey"]);
|
||||
const base = await subtle.importKey("raw", bsrc(enc.encode(passphrase)), "PBKDF2", false, [
|
||||
"deriveKey",
|
||||
]);
|
||||
return subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt: bsrc(salt), iterations: PBKDF2_ITERS, hash: "SHA-256" },
|
||||
base,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
}
|
||||
async function importPrivate(pkcs8: ArrayBuffer): Promise<CryptoKey> {
|
||||
// Non-extractable: usable for deriveBits but can never be exported again.
|
||||
return subtle.importKey("pkcs8", bsrc(pkcs8), { name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
|
||||
return subtle.importKey("pkcs8", bsrc(pkcs8), { name: "ECDH", namedCurve: "P-256" }, false, [
|
||||
"deriveBits",
|
||||
]);
|
||||
}
|
||||
|
||||
// Try to restore the unlocked key from this device's storage. Returns true if unlocked.
|
||||
@@ -134,7 +138,11 @@ export async function setup(userId: number, passphrase: string): Promise<KeySetu
|
||||
// wrapKey would be GCM nonce-reuse (leaks keystream + enables GHASH forgery). It is no longer a
|
||||
// verification oracle either — unlock() authenticates the passphrase via the wrapped-key GCM tag.
|
||||
const checkIv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const keyCheck = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(checkIv) }, wrapKey, bsrc(enc.encode("ok")));
|
||||
const keyCheck = await subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: bsrc(checkIv) },
|
||||
wrapKey,
|
||||
bsrc(enc.encode("ok")),
|
||||
);
|
||||
const spki = await subtle.exportKey("spki", kp.publicKey);
|
||||
|
||||
await installKey(userId, await importPrivate(pkcs8));
|
||||
@@ -148,7 +156,11 @@ export async function setup(userId: number, passphrase: string): Promise<KeySetu
|
||||
}
|
||||
|
||||
// Unlock on a device from the server blob; throws if the passphrase is wrong.
|
||||
export async function unlock(userId: number, passphrase: string, bundle: MyKeyBundle): Promise<void> {
|
||||
export async function unlock(
|
||||
userId: number,
|
||||
passphrase: string,
|
||||
bundle: MyKeyBundle,
|
||||
): Promise<void> {
|
||||
if (!bundle.wrapped_private_key || !bundle.salt || !bundle.wrap_iv || !bundle.key_check) {
|
||||
throw new Error("incomplete key bundle");
|
||||
}
|
||||
@@ -158,7 +170,7 @@ export async function unlock(userId: number, passphrase: string, bundle: MyKeyBu
|
||||
const pkcs8 = await subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: bsrc(ub64(bundle.wrap_iv)) },
|
||||
wrapKey,
|
||||
bsrc(ub64(bundle.wrapped_private_key))
|
||||
bsrc(ub64(bundle.wrapped_private_key)),
|
||||
);
|
||||
await installKey(userId, await importPrivate(pkcs8));
|
||||
}
|
||||
@@ -168,15 +180,26 @@ async function convKeyFor(partnerId: number, partnerPubB64: string): Promise<Cry
|
||||
const cached = convKeys.get(partnerId);
|
||||
if (cached) return cached;
|
||||
if (!privKey) throw new Error("locked");
|
||||
const pub = await subtle.importKey("spki", bsrc(ub64(partnerPubB64)), { name: "ECDH", namedCurve: "P-256" }, false, []);
|
||||
const pub = await subtle.importKey(
|
||||
"spki",
|
||||
bsrc(ub64(partnerPubB64)),
|
||||
{ name: "ECDH", namedCurve: "P-256" },
|
||||
false,
|
||||
[],
|
||||
);
|
||||
const bits = await subtle.deriveBits({ name: "ECDH", public: pub }, privKey, 256);
|
||||
const hk = await subtle.importKey("raw", bits, "HKDF", false, ["deriveKey"]);
|
||||
const key = await subtle.deriveKey(
|
||||
{ name: "HKDF", hash: "SHA-256", salt: bsrc(new Uint8Array(0)), info: bsrc(enc.encode(HKDF_INFO)) },
|
||||
{
|
||||
name: "HKDF",
|
||||
hash: "SHA-256",
|
||||
salt: bsrc(new Uint8Array(0)),
|
||||
info: bsrc(enc.encode(HKDF_INFO)),
|
||||
},
|
||||
hk,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
convKeys.set(partnerId, key);
|
||||
return key;
|
||||
@@ -185,11 +208,15 @@ async function convKeyFor(partnerId: number, partnerPubB64: string): Promise<Cry
|
||||
export async function encryptFor(
|
||||
partnerId: number,
|
||||
partnerPubB64: string,
|
||||
plaintext: string
|
||||
plaintext: string,
|
||||
): Promise<{ ciphertext: string; iv: string }> {
|
||||
const key = await convKeyFor(partnerId, partnerPubB64);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ct = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(iv) }, key, bsrc(enc.encode(plaintext)));
|
||||
const ct = await subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: bsrc(iv) },
|
||||
key,
|
||||
bsrc(enc.encode(plaintext)),
|
||||
);
|
||||
return { ciphertext: b64(ct), iv: b64(iv) };
|
||||
}
|
||||
|
||||
@@ -197,9 +224,13 @@ export async function decryptFrom(
|
||||
partnerId: number,
|
||||
partnerPubB64: string,
|
||||
ciphertext: string,
|
||||
iv: string
|
||||
iv: string,
|
||||
): Promise<string> {
|
||||
const key = await convKeyFor(partnerId, partnerPubB64);
|
||||
const pt = await subtle.decrypt({ name: "AES-GCM", iv: bsrc(ub64(iv)) }, key, bsrc(ub64(ciphertext)));
|
||||
const pt = await subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: bsrc(ub64(iv)) },
|
||||
key,
|
||||
bsrc(ub64(ciphertext)),
|
||||
);
|
||||
return dec.decode(pt);
|
||||
}
|
||||
|
||||
@@ -34,10 +34,7 @@ const SORT_MAP: Record<Exclude<SortKey, "shuffle" | "relevance">, { asc: string;
|
||||
priority: { desc: "priority", asc: "priority_asc" },
|
||||
};
|
||||
|
||||
export const SORT_DEFAULT_DIR: Record<
|
||||
Exclude<SortKey, "shuffle" | "relevance">,
|
||||
"asc" | "desc"
|
||||
> = {
|
||||
export const SORT_DEFAULT_DIR: Record<Exclude<SortKey, "shuffle" | "relevance">, "asc" | "desc"> = {
|
||||
date: "desc",
|
||||
popular: "desc",
|
||||
duration: "desc",
|
||||
|
||||
@@ -34,17 +34,45 @@ export interface FeedViewSpec {
|
||||
|
||||
// The whole matrix in one place. Adding a view here makes TS point at every switch that needs it.
|
||||
export const FEED_VIEW_SPEC: Record<FeedView, FeedViewSpec> = {
|
||||
cards: { family: "card", minCol: 260, maxCol: null, thumb: true, dense: false, actionsBelow: false },
|
||||
cardsSmall: { family: "card", minCol: 180, maxCol: null, thumb: true, dense: true, actionsBelow: false },
|
||||
cards: {
|
||||
family: "card",
|
||||
minCol: 260,
|
||||
maxCol: null,
|
||||
thumb: true,
|
||||
dense: false,
|
||||
actionsBelow: false,
|
||||
},
|
||||
cardsSmall: {
|
||||
family: "card",
|
||||
minCol: 180,
|
||||
maxCol: null,
|
||||
thumb: true,
|
||||
dense: true,
|
||||
actionsBelow: false,
|
||||
},
|
||||
// Single column on purpose: the point of the list is that your eye runs down one edge with the
|
||||
// titles stacked. Density must not cost that — `tiles` is the multi-column option instead.
|
||||
// Keeps the 896px cap: its actions dock at the right edge, so a wider row would only stretch the
|
||||
// dead space between them and the meta.
|
||||
rows: { family: "row", minCol: null, maxCol: 896, thumb: true, dense: false, actionsBelow: false },
|
||||
rows: {
|
||||
family: "row",
|
||||
minCol: null,
|
||||
maxCol: 896,
|
||||
thumb: true,
|
||||
dense: false,
|
||||
actionsBelow: false,
|
||||
},
|
||||
// Much wider (1472): everything sits on one line and the columns after the title are fixed, so
|
||||
// there's no gap for the extra width to stretch — it all goes to the title. Raised from 1280 at
|
||||
// the user's request once they saw how much room was left over with the rail and filters hidden.
|
||||
rowsCompact: { family: "row", minCol: null, maxCol: 1472, thumb: false, dense: false, actionsBelow: false },
|
||||
rowsCompact: {
|
||||
family: "row",
|
||||
minCol: null,
|
||||
maxCol: 1472,
|
||||
thumb: false,
|
||||
dense: false,
|
||||
actionsBelow: false,
|
||||
},
|
||||
// 380 = thumbnail (160) + gap (12) + ~192 for the text + the block's own padding (16). The text
|
||||
// floor is the action row's measured 156px plus enough for a title to be worth reading.
|
||||
// Tuned by measuring the real layouts, not guessed: 3 columns need 3n+32 px, so 380 turns the
|
||||
@@ -52,7 +80,14 @@ export const FEED_VIEW_SPEC: Record<FeedView, FeedViewSpec> = {
|
||||
// (~955px) still gets 2. 470 was tried first and needed 956px for two — the common layout fell
|
||||
// back to ONE column and the mode looked broken; 400 then left 596px tiles whose titles ended
|
||||
// halfway, which is the very waste this mode exists to kill.
|
||||
tiles: { family: "row", minCol: 380, maxCol: null, thumb: true, dense: false, actionsBelow: true },
|
||||
tiles: {
|
||||
family: "row",
|
||||
minCol: 380,
|
||||
maxCol: null,
|
||||
thumb: true,
|
||||
dense: false,
|
||||
actionsBelow: true,
|
||||
},
|
||||
};
|
||||
|
||||
function isFeedView(v: unknown): v is FeedView {
|
||||
|
||||
@@ -26,7 +26,7 @@ const PLATFORMS: Record<string, string> = {
|
||||
// (to the platform's profile URL); the "<platform>: " label stays as text. Bare @handles
|
||||
// with no platform, and #hashtags, are left plain.
|
||||
const URL_SOURCE =
|
||||
'(?:https?://|www\\.)[^\\s<]+[^\\s<.,!?;:)\\]}"\']|[a-z0-9-]+(?:\\.[a-z0-9-]+)*\\.[a-z]{2,}/[^\\s<]*[^\\s<.,!?;:)\\]}"\']';
|
||||
"(?:https?://|www\\.)[^\\s<]+[^\\s<.,!?;:)\\]}\"']|[a-z0-9-]+(?:\\.[a-z0-9-]+)*\\.[a-z]{2,}/[^\\s<]*[^\\s<.,!?;:)\\]}\"']";
|
||||
const MENTION_SOURCE =
|
||||
"\\b(" +
|
||||
Object.keys(PLATFORMS).join("|") +
|
||||
|
||||
@@ -45,7 +45,11 @@ function open() {
|
||||
try {
|
||||
const data = JSON.parse(ev.data);
|
||||
if (data?.type === "message" && data.message) {
|
||||
const e: IncomingMessage = { message: data.message as Message, from: data.from, to: data.to };
|
||||
const e: IncomingMessage = {
|
||||
message: data.message as Message,
|
||||
from: data.from,
|
||||
to: data.to,
|
||||
};
|
||||
for (const h of handlers) h(e);
|
||||
} else if (data?.type === "unread") {
|
||||
// Sent to a user's OTHER tabs when they read a thread elsewhere, so the badge updates live.
|
||||
|
||||
@@ -48,11 +48,35 @@ const MODULES: readonly ModuleDef[] = [
|
||||
{ id: "playlists", labelKey: "header.account.playlists", icon: ListVideo },
|
||||
{ id: "plex", labelKey: "plex.navLabel", icon: Clapperboard, gate: (me) => me.plex_enabled },
|
||||
{ id: "notifications", labelKey: "inbox.navLabel", icon: Bell, hasBadge: true },
|
||||
{ id: "messages", labelKey: "messages.navLabel", icon: MessageSquare, hasBadge: true, gate: notDemo },
|
||||
{ id: "downloads", labelKey: "downloads.navLabel", icon: Download, hasBadge: true, gate: notDemo },
|
||||
{
|
||||
id: "messages",
|
||||
labelKey: "messages.navLabel",
|
||||
icon: MessageSquare,
|
||||
hasBadge: true,
|
||||
gate: notDemo,
|
||||
},
|
||||
{
|
||||
id: "downloads",
|
||||
labelKey: "downloads.navLabel",
|
||||
icon: Download,
|
||||
hasBadge: true,
|
||||
gate: notDemo,
|
||||
},
|
||||
{ id: "stats", labelKey: "header.account.stats", icon: BarChart3 },
|
||||
{ id: "scheduler", labelKey: "header.account.scheduler", icon: Activity, system: true, gate: isAdmin },
|
||||
{ id: "config", labelKey: "header.account.config", icon: SlidersHorizontal, system: true, gate: isAdmin },
|
||||
{
|
||||
id: "scheduler",
|
||||
labelKey: "header.account.scheduler",
|
||||
icon: Activity,
|
||||
system: true,
|
||||
gate: isAdmin,
|
||||
},
|
||||
{
|
||||
id: "config",
|
||||
labelKey: "header.account.config",
|
||||
icon: SlidersHorizontal,
|
||||
system: true,
|
||||
gate: isAdmin,
|
||||
},
|
||||
{ id: "users", labelKey: "header.account.users", icon: Users, system: true, gate: isAdmin },
|
||||
{ id: "audit", labelKey: "header.account.audit", icon: ScrollText, system: true, gate: isAdmin },
|
||||
];
|
||||
@@ -69,9 +93,10 @@ const SETTINGS_MODULE: ModuleDef = {
|
||||
// place. If a Page is left unregistered, moduleDef()/moduleLabelKey[] return undefined and NavSidebar
|
||||
// throws on `.icon`; the coverage isn't compiler-enforced here because the ordered array can't also
|
||||
// be an exhaustive Page map, so treat the array as the checklist when the Page union grows.
|
||||
const BY_ID = Object.fromEntries(
|
||||
[...MODULES, SETTINGS_MODULE].map((m) => [m.id, m]),
|
||||
) as Record<Page, ModuleDef>;
|
||||
const BY_ID = Object.fromEntries([...MODULES, SETTINGS_MODULE].map((m) => [m.id, m])) as Record<
|
||||
Page,
|
||||
ModuleDef
|
||||
>;
|
||||
|
||||
// Look up a module's definition by page id (every Page is covered, incl. settings).
|
||||
export function moduleDef(id: Page): ModuleDef {
|
||||
|
||||
@@ -107,7 +107,9 @@ export function configureNotifications(p: Partial<NotifSettings>): void {
|
||||
let audioCtx: AudioContext | null = null;
|
||||
function beep(): void {
|
||||
try {
|
||||
const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
const Ctx =
|
||||
window.AudioContext ||
|
||||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
audioCtx = audioCtx || new Ctx();
|
||||
if (audioCtx.state === "suspended") void audioCtx.resume();
|
||||
const osc = audioCtx.createOscillator();
|
||||
@@ -143,7 +145,13 @@ function load(): Notification[] {
|
||||
// live `action` callback, which can't survive serialization.
|
||||
return raw.map(
|
||||
(n) =>
|
||||
({ repeat: 1, ...(n as object), action: undefined, dismissed: true, transient: false }) as Notification
|
||||
({
|
||||
repeat: 1,
|
||||
...(n as object),
|
||||
action: undefined,
|
||||
dismissed: true,
|
||||
transient: false,
|
||||
}) as Notification,
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
@@ -202,13 +210,14 @@ export function notify(input: NotifyInput): number {
|
||||
// config.durationMs === 0 means "don't auto-dismiss" (toast stays until dismissed).
|
||||
// A transient status stays put until its producer clears it (no auto-dismiss timer),
|
||||
// same as an interaction-awaiting notice.
|
||||
const duration = requiresInteraction || input.transient
|
||||
? undefined
|
||||
: level === "error" || level === "fatal"
|
||||
? ERROR_TTL
|
||||
: config.durationMs > 0
|
||||
? config.durationMs
|
||||
: undefined;
|
||||
const duration =
|
||||
requiresInteraction || input.transient
|
||||
? undefined
|
||||
: level === "error" || level === "fatal"
|
||||
? ERROR_TTL
|
||||
: config.durationMs > 0
|
||||
? config.durationMs
|
||||
: undefined;
|
||||
|
||||
// Coalesce a rapid burst of identical notifications (same level+title+message within the
|
||||
// window) into one entry with a bumped repeat count, instead of appending a fresh copy each
|
||||
@@ -224,14 +233,14 @@ export function notify(input: NotifyInput): number {
|
||||
now - n.ts <= COALESCE_WINDOW_MS &&
|
||||
n.level === level &&
|
||||
(n.title ?? "") === (input.title ?? "") &&
|
||||
n.message === input.message
|
||||
n.message === input.message,
|
||||
);
|
||||
if (prior) {
|
||||
// Re-surface the existing toast (undismiss + unread) and re-arm its auto-dismiss timer.
|
||||
items = items.map((n) =>
|
||||
n.id === prior.id
|
||||
? { ...n, ts: now, repeat: n.repeat + 1, dismissed: false, read: false, duration }
|
||||
: n
|
||||
: n,
|
||||
);
|
||||
emit();
|
||||
if (duration) setTimeout(() => dismiss(prior.id), duration);
|
||||
@@ -260,7 +269,10 @@ export function notify(input: NotifyInput): number {
|
||||
},
|
||||
];
|
||||
emit();
|
||||
if (config.sound && (input.sound || requiresInteraction || level === "error" || level === "fatal")) {
|
||||
if (
|
||||
config.sound &&
|
||||
(input.sound || requiresInteraction || level === "error" || level === "fatal")
|
||||
) {
|
||||
beep();
|
||||
}
|
||||
if (duration) setTimeout(() => dismiss(id), duration);
|
||||
@@ -301,7 +313,7 @@ export function resolveVideo(videoId: string): void {
|
||||
!(
|
||||
(n.meta?.kind === "video-hidden" || n.meta?.kind === "video-watched") &&
|
||||
n.meta.videoId === videoId
|
||||
)
|
||||
),
|
||||
);
|
||||
if (items.length !== before) emit();
|
||||
}
|
||||
|
||||
@@ -12,8 +12,20 @@ const v = (id: string, title: string, channel: string, secs: number | null = 60)
|
||||
({ id, title, channel_title: channel, duration_seconds: secs }) as Video;
|
||||
|
||||
const columns = [
|
||||
{ key: "title", header: "Title", render: () => null, sortable: true, sortValue: (x: Video) => x.title ?? "" },
|
||||
{ key: "channel", header: "Channel", render: () => null, sortable: true, sortValue: (x: Video) => x.channel_title ?? "" },
|
||||
{
|
||||
key: "title",
|
||||
header: "Title",
|
||||
render: () => null,
|
||||
sortable: true,
|
||||
sortValue: (x: Video) => x.title ?? "",
|
||||
},
|
||||
{
|
||||
key: "channel",
|
||||
header: "Channel",
|
||||
render: () => null,
|
||||
sortable: true,
|
||||
sortValue: (x: Video) => x.channel_title ?? "",
|
||||
},
|
||||
{
|
||||
key: "duration",
|
||||
header: "Length",
|
||||
|
||||
@@ -20,7 +20,7 @@ export function applyView<T extends Grouped>(
|
||||
rows: T[],
|
||||
columns: Sortable<T>[],
|
||||
sort: SortState,
|
||||
group: boolean
|
||||
group: boolean,
|
||||
): T[] {
|
||||
const sorted = sortRows(rows, columns, sort);
|
||||
if (!group) return sorted;
|
||||
|
||||
@@ -12,7 +12,8 @@ import { api } from "./api";
|
||||
|
||||
export function useDetailPrefs() {
|
||||
const qc = useQueryClient();
|
||||
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ?? {}) as {
|
||||
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ??
|
||||
{}) as {
|
||||
plexInfoArtBg?: boolean;
|
||||
plexInfoCast?: boolean;
|
||||
plexInfoRelated?: boolean;
|
||||
@@ -65,7 +66,11 @@ export function Filterable({
|
||||
}) {
|
||||
if (!onClick) return <span className={className}>{children}</span>;
|
||||
return (
|
||||
<button onClick={onClick} title={title} className={`${className ?? ""} cursor-pointer hover:text-accent transition`}>
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`${className ?? ""} cursor-pointer hover:text-accent transition`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
@@ -145,9 +150,15 @@ export function DetailCustomizeMenu({
|
||||
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
|
||||
>
|
||||
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
|
||||
{hasCast && <PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />}
|
||||
{hasCast && (
|
||||
<PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />
|
||||
)}
|
||||
{hasRelated && onToggleRelated && (
|
||||
<PrefToggle label={t("plex.info.prefRelated")} on={showRelated ?? true} onClick={onToggleRelated} />
|
||||
<PrefToggle
|
||||
label={t("plex.info.prefRelated")}
|
||||
on={showRelated ?? true}
|
||||
onClick={onToggleRelated}
|
||||
/>
|
||||
)}
|
||||
{extra}
|
||||
</div>
|
||||
@@ -156,15 +167,27 @@ export function DetailCustomizeMenu({
|
||||
);
|
||||
}
|
||||
|
||||
export function PrefToggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) {
|
||||
export function PrefToggle({
|
||||
label,
|
||||
on,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
on: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left hover:bg-surface"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span className={`relative h-4 w-7 rounded-full transition ${on ? "bg-accent" : "bg-border"}`}>
|
||||
<span className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all ${on ? "left-3.5" : "left-0.5"}`} />
|
||||
<span
|
||||
className={`relative h-4 w-7 rounded-full transition ${on ? "bg-accent" : "bg-border"}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all ${on ? "left-3.5" : "left-0.5"}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -21,16 +21,16 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
"Playlists get real column headers — and sorting one no longer rewrites it behind your back.",
|
||||
features: [
|
||||
"The playlist view has sortable, filterable column headers: sort by title, channel or length, and filter by title or channel to find something in a long list.",
|
||||
"Sorting is now only a way of LOOKING at the list. Your playlist's own order stays exactly as you saved it until you press \"Save this order\" — so trying a sort can no longer rewrite the list, or leave a YouTube-synced playlist marked as unsynced. Saving is undoable like any other reorder.",
|
||||
"The list tells you which view you are in: whether the current sort matches your saved order, differs from it and can be saved, or is filtered (where saving would drop the hidden videos, so it is not offered). The \"#\" column always shows each video's position in the SAVED order.",
|
||||
'Sorting is now only a way of LOOKING at the list. Your playlist\'s own order stays exactly as you saved it until you press "Save this order" — so trying a sort can no longer rewrite the list, or leave a YouTube-synced playlist marked as unsynced. Saving is undoable like any other reorder.',
|
||||
'The list tells you which view you are in: whether the current sort matches your saved order, differs from it and can be saved, or is filtered (where saving would drop the hidden videos, so it is not offered). The "#" column always shows each video\'s position in the SAVED order.',
|
||||
"Drag-reordering now works from the keyboard: focus a drag handle, press space, move with the arrow keys and press space again.",
|
||||
],
|
||||
fixes: [
|
||||
"Deleting a playlist synced with YouTube asked twice, and cancelling the second question still deleted it locally. It is one question now, with each outcome on its own button — and closing the dialog deletes nothing.",
|
||||
"Confirmation dialogs put the keyboard focus on Cancel when the action is destructive. Previously the confirm button took focus, so a stray Enter on an open dialog could run \"Delete user\" or \"Clear audit log\".",
|
||||
'Confirmation dialogs put the keyboard focus on Cancel when the action is destructive. Previously the confirm button took focus, so a stray Enter on an open dialog could run "Delete user" or "Clear audit log".',
|
||||
"Removing a video from a playlist now asks first, and keeps your undo history for the videos that remain.",
|
||||
"Typing in a table's column filter re-created the input on every keystroke, which moved the cursor and broke accented and IME input mid-word. This affected the channel manager, Discover and the audit log as well.",
|
||||
"A playlist that failed to load showed \"Loading…\" forever; it now says so and offers to retry. Failed reorders and renames are reported instead of passing silently.",
|
||||
'A playlist that failed to load showed "Loading…" forever; it now says so and offers to retry. Failed reorders and renames are reported instead of passing silently.',
|
||||
"Reordering a playlist no longer requires YouTube write access — it never needed it.",
|
||||
],
|
||||
chores: [
|
||||
@@ -60,12 +60,12 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
"The channel manager has a card view alongside the familiar table. Pick it from the switcher next to the tabs; your choice sticks, and both Subscriptions and Discover follow it.",
|
||||
"A new About column shows each channel's description, and hovering it opens the full profile — description, links, country, language, topics and keywords — without leaving the list. Links in descriptions are clickable, and social handles resolve to their profiles.",
|
||||
"Sorting is shared between the two views: sort by clicking a column header in the table or from the dropdown in cards, and your choice carries over when you switch.",
|
||||
"Both views page through your channels — \"Rows per page\" in the table, \"Cards per page\" in cards — and the table now has a pager at the bottom as well as the top. Turning a page takes you back to the top of the list.",
|
||||
'Both views page through your channels — "Rows per page" in the table, "Cards per page" in cards — and the table now has a pager at the bottom as well as the top. Turning a page takes you back to the top of the list.',
|
||||
"Filter the table column by column: funnels on priority, channel, sync state and tags narrow it without disturbing your top-level filters.",
|
||||
"Moving between the manager's tabs now goes into your browser history, so Back steps back through the tabs you visited instead of leaving the manager altogether.",
|
||||
],
|
||||
fixes: [
|
||||
"The channel table stays centred with \"Rows per page\" set to All. A single long channel name used to stretch its column and push the whole table off to one side.",
|
||||
'The channel table stays centred with "Rows per page" set to All. A single long channel name used to stretch its column and push the whole table off to one side.',
|
||||
"The tag editor no longer opens behind the card below it.",
|
||||
],
|
||||
},
|
||||
@@ -82,8 +82,7 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.45.0",
|
||||
date: "2026-07-19",
|
||||
summary:
|
||||
"Read the feed the way you like it, and explore your Plex library by cast.",
|
||||
summary: "Read the feed the way you like it, and explore your Plex library by cast.",
|
||||
features: [
|
||||
"The feed has five view modes now — large cards, small cards, rows, compact rows, and a multi-column tile view. Pick the density that suits you from the switcher next to the sort control; your choice sticks.",
|
||||
"Explore your Plex library by the people in it: click any cast member on a film or show's page to see everything they're in. Pick several and, by default, you'll get titles featuring any of them — flip to \"All\" to require every one. The actors you've picked now show as cards above the grid, each with a photo and a title count, and a click to drop them.",
|
||||
@@ -181,9 +180,7 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.39.1",
|
||||
date: "2026-07-12",
|
||||
fixes: [
|
||||
"Signing out and back in no longer disconnects your YouTube access.",
|
||||
],
|
||||
fixes: ["Signing out and back in no longer disconnects your YouTube access."],
|
||||
},
|
||||
{
|
||||
version: "0.39.0",
|
||||
@@ -197,9 +194,7 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
version: "0.38.0",
|
||||
date: "2026-07-12",
|
||||
summary: "The Plex filter sidebar can now be collapsed, plus a large internal cleanup.",
|
||||
fixes: [
|
||||
"The Plex library no longer stops loading more titles after you open one and go back.",
|
||||
],
|
||||
fixes: ["The Plex library no longer stops loading more titles after you open one and go back."],
|
||||
},
|
||||
{
|
||||
version: "0.37.0",
|
||||
@@ -223,9 +218,7 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.36.4",
|
||||
date: "2026-07-10",
|
||||
chores: [
|
||||
"Internal in-app player cleanup.",
|
||||
],
|
||||
chores: ["Internal in-app player cleanup."],
|
||||
},
|
||||
{
|
||||
version: "0.36.1",
|
||||
@@ -259,7 +252,7 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
date: "2026-07-09",
|
||||
summary: "Polish: the Plex watch-sync switch now responds instantly.",
|
||||
fixes: [
|
||||
"The Plex watch-sync switch now flips the moment you click it and shows an \"Importing…\" indicator while it works, instead of appearing to do nothing until the import finished.",
|
||||
'The Plex watch-sync switch now flips the moment you click it and shows an "Importing…" indicator while it works, instead of appearing to do nothing until the import finished.',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -267,7 +260,7 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
date: "2026-07-09",
|
||||
summary: "Fix: turning on Plex watch sync could show a server error.",
|
||||
fixes: [
|
||||
"Enabling Plex watch sync could briefly show a \"Server error\" if the switch was toggled again while the first import was still running. The import is now safe to run more than once, and the switch is disabled while it works. (If you saw the error, your Plex history still imported correctly.)",
|
||||
'Enabling Plex watch sync could briefly show a "Server error" if the switch was toggled again while the first import was still running. The import is now safe to run more than once, and the switch is disabled while it works. (If you saw the error, your Plex history still imported correctly.)',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -332,23 +325,22 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.31.0",
|
||||
date: "2026-07-07",
|
||||
summary: "Richer downloads — edit details, clickable channels & links, and prettier share-link previews.",
|
||||
summary:
|
||||
"Richer downloads — edit details, clickable channels & links, and prettier share-link previews.",
|
||||
features: [
|
||||
"Downloads: the edit (pencil) button now edits the full details — title, channel name and a channel link, plus any number of extra reference links. The channel and links appear on the download card as clickable links, and a source that carries a channel (like YouTube or Facebook) fills the channel link in automatically.",
|
||||
"Downloads: shared watch pages now show the correct title, channel and links — including your own edits — with every link clickable. (Renaming a download is now reflected on its share link too.)",
|
||||
"Shared links now unfurl richly in chat apps like Messenger, with the video's title, channel and thumbnail instead of a blank card. Videos without a thumbnail still show a clean text card.",
|
||||
],
|
||||
chores: [
|
||||
"Shorter, clearer site description for search engines and link previews.",
|
||||
],
|
||||
chores: ["Shorter, clearer site description for search engines and link previews."],
|
||||
},
|
||||
{
|
||||
version: "0.30.0",
|
||||
date: "2026-07-06",
|
||||
summary: "Plex playlists — your own ordered watch-lists.",
|
||||
features: [
|
||||
"Plex playlists: create your own ordered lists of films (each user has their own — no Plex account needed). Add a film to a playlist from its info page (\"Add to playlist\"), then find your playlists in the Plex sidebar.",
|
||||
"Plex playlists: open a playlist to reorder (up/down), remove items, rename or delete it, and hit \"Play all\" to watch straight through — the player steps through the whole list in order, auto-advancing to the next.",
|
||||
'Plex playlists: create your own ordered lists of films (each user has their own — no Plex account needed). Add a film to a playlist from its info page ("Add to playlist"), then find your playlists in the Plex sidebar.',
|
||||
'Plex playlists: open a playlist to reorder (up/down), remove items, rename or delete it, and hit "Play all" to watch straight through — the player steps through the whole list in order, auto-advancing to the next.',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -387,7 +379,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.28.0",
|
||||
date: "2026-07-06",
|
||||
summary: "Plex collections — browse them, filter by them, and see a title's collection right on its info page.",
|
||||
summary:
|
||||
"Plex collections — browse them, filter by them, and see a title's collection right on its info page.",
|
||||
features: [
|
||||
"Plex collections: every Plex collection (franchises like Avatar, curated sets, and community lists like IMDb Top 250) is now mirrored locally by the background sync, so browsing is instant — no more waiting on Plex's slow collection queries.",
|
||||
"Plex library: a searchable Collection filter in the sidebar — pick a collection to see just its titles, combinable with every other filter.",
|
||||
@@ -399,7 +392,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.27.0",
|
||||
date: "2026-07-06",
|
||||
summary: "Search Plex by cast & crew — find every film an actor or director is in, with clickable person cards.",
|
||||
summary:
|
||||
"Search Plex by cast & crew — find every film an actor or director is in, with clickable person cards.",
|
||||
features: [
|
||||
"Plex search: the top search box now matches cast and crew names, not just titles — search “drew” and Drew Barrymore's films come up first (ranked above films that merely mention the word).",
|
||||
"Plex search: matching actors and directors appear as person cards (photo + how many titles) above the results — click one to filter the library to their films and add them to the sidebar filter.",
|
||||
@@ -408,7 +402,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.26.0",
|
||||
date: "2026-07-05",
|
||||
summary: "Powerful Plex filters — by genre, IMDb rating, year, length, added date and age rating — and click any detail on the info page to filter by it. Plus much faster poster loading.",
|
||||
summary:
|
||||
"Powerful Plex filters — by genre, IMDb rating, year, length, added date and age rating — and click any detail on the info page to filter by it. Plus much faster poster loading.",
|
||||
features: [
|
||||
"Plex library: a greatly expanded filter panel for movies — genre, IMDb rating, year range, length, when it was added to Plex, and age rating — plus new sort orders (release date, year, IMDb rating, length) with an ascending/descending toggle.",
|
||||
"Plex filters: pick several genres and choose Any (match one) or All (match every one); stack multiple actors, directors and studios at once (from the info page).",
|
||||
@@ -422,7 +417,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.25.0",
|
||||
date: "2026-07-05",
|
||||
summary: "Rich Plex media info — an info page from any title, an info overlay while watching, IMDb scores and cast photos.",
|
||||
summary:
|
||||
"Rich Plex media info — an info page from any title, an info overlay while watching, IMDb scores and cast photos.",
|
||||
features: [
|
||||
"Plex: every movie/episode now has a media info page (the “i” button on a card) with the poster, IMDb score + a link to IMDb, content rating, genres, director, a full cast list with photos, and the synopsis — plus Play/Resume and watch controls.",
|
||||
"Plex player: press “I” (or the info button) for the same rich info as an overlay, without stopping playback.",
|
||||
@@ -433,7 +429,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.24.0",
|
||||
date: "2026-07-05",
|
||||
summary: "Plex player polish: a Stop button, a keyboard help overlay, time-remaining and a clock, and Back returns you to your place in the library.",
|
||||
summary:
|
||||
"Plex player polish: a Stop button, a keyboard help overlay, time-remaining and a clock, and Back returns you to your place in the library.",
|
||||
features: [
|
||||
"Plex player: added an explicit Stop button, a time-remaining readout next to the seek bar, and a live clock in the top corner (both fade with the controls).",
|
||||
"Plex player: press “H” (or the keyboard button) for an on-screen cheat sheet of all shortcuts.",
|
||||
@@ -446,7 +443,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.23.2",
|
||||
date: "2026-07-05",
|
||||
summary: "The player now steps through the exact list it was opened with, so watching along no longer breaks Loop and Next/Previous.",
|
||||
summary:
|
||||
"The player now steps through the exact list it was opened with, so watching along no longer breaks Loop and Next/Previous.",
|
||||
fixes: [
|
||||
"Player: it now plays through a frozen snapshot of the filtered, sorted list it was launched with. Previously, as each video was auto-marked watched it dropped out of a filtered feed, silently shrinking the queue mid-playback — which jumped playback to an unrelated video, and stopped Loop “All” from ever wrapping back to the first video once earlier ones were watched. Next/Previous and both Loop modes now work against the original list for the whole session.",
|
||||
],
|
||||
@@ -477,7 +475,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.22.3",
|
||||
date: "2026-07-04",
|
||||
summary: "Step through the feed in the player, vertical videos fit, and downloads show their source.",
|
||||
summary:
|
||||
"Step through the feed in the player, vertical videos fit, and downloads show their source.",
|
||||
features: [
|
||||
"In-app player: step to the previous/next video in the feed's order — click the arrows beside the player or press Shift+←/→ (plain ←/→ still seek within the video).",
|
||||
"Player auto-advance and loop, saved to your account: when a video ends, play the next, previous or a random one — and loop just that video or the whole list. Works for players opened from the feed and from playlists.",
|
||||
@@ -506,7 +505,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.22.1",
|
||||
date: "2026-07-04",
|
||||
summary: "Downloaded and shared videos now play in every browser, including on iPhone and iPad.",
|
||||
summary:
|
||||
"Downloaded and shared videos now play in every browser, including on iPhone and iPad.",
|
||||
fixes: [
|
||||
"Downloads now use widely-compatible video (H.264) and audio (AAC) so a saved file — and a public watch link — plays everywhere, including iOS/Safari, where the previous format left a broken player. Existing downloads keep their old format; re-download to get the compatible version.",
|
||||
],
|
||||
@@ -514,7 +514,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.22.0",
|
||||
date: "2026-07-04",
|
||||
summary: "The Download Center — save videos to the server, edit them into clips, and share them.",
|
||||
summary:
|
||||
"The Download Center — save videos to the server, edit them into clips, and share them.",
|
||||
features: [
|
||||
"Download Center: save any video (or a search result) to the server with yt-dlp, laid out Plex-style with a poster + info file. Choose a built-in format preset or make your own, keep an eye on your per-user storage quota, and save the finished file to your device.",
|
||||
"Built-in video editor: trim, crop, and cut a download into segments — keep the parts you want as separate clips or join them into one file. Pick a precise (frame-accurate) or fast cut, with a scrub timeline and a hover preview.",
|
||||
@@ -528,7 +529,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.21.0",
|
||||
date: "2026-07-02",
|
||||
summary: "Run a different account in each browser tab, a roomier collapsible layout, and nav polish.",
|
||||
summary:
|
||||
"Run a different account in each browser tab, a roomier collapsible layout, and nav polish.",
|
||||
features: [
|
||||
"Multi-account, per tab: open two tabs and sign into a different account in each — each tab stays on its own account. Switching or adding an account in one tab no longer changes what the others show. Every tab keeps its own filters, theme and settings.",
|
||||
"Roomier layout: the filter sidebar is now a full-height column you can collapse like the left nav (a thin rail keeps the active-filter count). Your video counts and sync status moved to the top of the left rail, and the Mine/Library switch sits with the filters. Both panels remember whether you left them open or collapsed.",
|
||||
@@ -544,9 +546,7 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
version: "0.20.2",
|
||||
date: "2026-07-01",
|
||||
summary: "A pop-up flash fix, and an open-source link on the sign-in page.",
|
||||
features: [
|
||||
"Siftlode is open source — the sign-in page now links to its public source code.",
|
||||
],
|
||||
features: ["Siftlode is open source — the sign-in page now links to its public source code."],
|
||||
fixes: [
|
||||
"Opening one pop-up from inside another — for example Release Notes from the About box — no longer makes it flash and vanish instantly. It opens and stays put, and Back closes it as expected.",
|
||||
],
|
||||
@@ -591,7 +591,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.18.0",
|
||||
date: "2026-06-29",
|
||||
summary: "Search all of YouTube from your feed, and new keyboard/scroll controls in the video player.",
|
||||
summary:
|
||||
"Search all of YouTube from your feed, and new keyboard/scroll controls in the video player.",
|
||||
features: [
|
||||
"Live YouTube search: type a search and press Enter (or the YouTube button) to search all of YouTube right from the feed — results show up as normal cards you can play, save and add to playlists. They're kept out of your regular Library by default; a Source filter lets you show or hide search-found videos. Shorts and currently-live/upcoming results are left out automatically.",
|
||||
"Live search uses no YouTube API quota by default, so it's no longer limited to a handful of searches per day. (Admins can switch back to the official API under Configuration → Quota if the quota-free source ever stops working.)",
|
||||
@@ -665,8 +666,7 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.14.0",
|
||||
date: "2026-06-21",
|
||||
summary:
|
||||
"Run your own Siftlode: a guided first-run setup and a one-command self-host package.",
|
||||
summary: "Run your own Siftlode: a guided first-run setup and a one-command self-host package.",
|
||||
features: [
|
||||
"A first-run setup wizard: the very first time a fresh instance is opened, a guided wizard walks you through creating the first admin account and (optionally) entering your Google and email credentials — no editing config files by hand. Once finished, the instance is marked configured and the wizard steps aside.",
|
||||
"Google sign-in is now optional: if an instance hasn't been given Google credentials, the Google buttons and YouTube-account features are simply hidden, so a self-hoster can run the app with email-and-password accounts only.",
|
||||
@@ -762,7 +762,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.9.0",
|
||||
date: "2026-06-18",
|
||||
summary: "A notifications inbox, and automatic clean-up of videos that can no longer be played.",
|
||||
summary:
|
||||
"A notifications inbox, and automatic clean-up of videos that can no longer be played.",
|
||||
features: [
|
||||
"New Notifications center in the left sidebar, with an unread badge: one place for both durable system updates (about your library and background jobs) and your recent activity (with quick Unhide / Unwatch / Find-in-feed actions). Mark items read, dismiss them individually, or clear everything. Pop-up toasts still appear as before.",
|
||||
"Automatic library maintenance: a background job spots videos that can no longer be played anywhere — deleted, made private, or premieres that never went live — hides them from your feed right away, and removes them after a grace period. Genuine 24/7 livestreams are left alone.",
|
||||
@@ -788,7 +789,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.7.0",
|
||||
date: "2026-06-17",
|
||||
summary: "A redesigned, sortable Channel manager — plus a reorganized sidebar and several fixes.",
|
||||
summary:
|
||||
"A redesigned, sortable Channel manager — plus a reorganized sidebar and several fixes.",
|
||||
features: [
|
||||
"Channel manager is now a sortable, filterable table: sort by any column, filter channels by name or tag straight from the headers, choose how many rows per page (or show them all), and jump to any page. Your sorting, filters and page are remembered across reloads.",
|
||||
"Channel rows: open a channel on YouTube (middle-click the name or the ↗ icon), a single “fully synced” badge once a channel is fully caught up, and instant tag toggling. Admins can reset a channel to re-fetch it from scratch.",
|
||||
@@ -871,8 +873,7 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.2.0",
|
||||
date: "2026-06-15",
|
||||
summary:
|
||||
"Browse beyond your own subscriptions, and a much smarter filter sidebar.",
|
||||
summary: "Browse beyond your own subscriptions, and a much smarter filter sidebar.",
|
||||
features: [
|
||||
"Shared library: a new Mine / Library switch in the header lets you browse every channel the community has added — not just your own subscriptions — and even works without granting YouTube access. Your watch, save and hide state always stays private to you.",
|
||||
"Smarter filter chips: topic and language chips now show live counts for your current view and hide the ones that no longer match anything, so narrowing down is quicker. Chips are ordered by size, and topics have a clear Any / All match toggle (All = channels that have all the selected topics).",
|
||||
|
||||
@@ -80,7 +80,10 @@ export function activeAccountId(): number | null {
|
||||
* accounts, two tabs would fight over it). Defaults to the current tab's active account; returns
|
||||
* null when no account is known yet, so callers fall back to defaults / skip persistence instead
|
||||
* of touching another account's data. */
|
||||
export function accountKey(base: string, id: number | null | undefined = activeAccountId()): string | null {
|
||||
export function accountKey(
|
||||
base: string,
|
||||
id: number | null | undefined = activeAccountId(),
|
||||
): string | null {
|
||||
return id != null ? `${base}.${id}` : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export function clearedFilters(current: FeedFilters): FeedFilters {
|
||||
*/
|
||||
export function useActiveFilters(
|
||||
filters: FeedFilters,
|
||||
setFilters: (f: FeedFilters) => void
|
||||
setFilters: (f: FeedFilters) => void,
|
||||
): ActiveFilter[] {
|
||||
const { t } = useTranslation();
|
||||
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
||||
|
||||
@@ -25,8 +25,7 @@ export function useCardPager<T>(rows: T[], sizeKey: string, enabled: boolean) {
|
||||
if (enabled && page > totalPages - 1) setPage(totalPages - 1);
|
||||
}, [enabled, page, totalPages]);
|
||||
|
||||
const paged =
|
||||
!enabled || size <= 0 ? rows : rows.slice(safePage * size, safePage * size + size);
|
||||
const paged = !enabled || size <= 0 ? rows : rows.slice(safePage * size, safePage * size + size);
|
||||
|
||||
return {
|
||||
paged,
|
||||
|
||||
@@ -12,7 +12,7 @@ export function useLiveQuery<T>(
|
||||
// (e.g. poll faster while a job is running). react-query re-evaluates it after each fetch
|
||||
// against fresh data — the right place for this, vs. a React state toggle that can lag or
|
||||
// stall the live updates.
|
||||
opts: { intervalMs?: number | ((data: T | undefined) => number); enabled?: boolean } = {}
|
||||
opts: { intervalMs?: number | ((data: T | undefined) => number); enabled?: boolean } = {},
|
||||
) {
|
||||
const { intervalMs = 4000, enabled = true } = opts;
|
||||
return useQuery({
|
||||
|
||||
@@ -10,7 +10,10 @@ import { useEffect, useState, type CSSProperties } from "react";
|
||||
// `fadeTop` gates the top edge only. Pass false when the scroller's top is a sticky element (a
|
||||
// table's sticky header): fading there would eat into the header instead of hinting at content, and
|
||||
// the header would visibly change as the fade turns on with the first scroll.
|
||||
export function useScrollFade(fadePx = 20, fadeTop = true): {
|
||||
export function useScrollFade(
|
||||
fadePx = 20,
|
||||
fadeTop = true,
|
||||
): {
|
||||
ref: (node: HTMLElement | null) => void;
|
||||
style: CSSProperties;
|
||||
} {
|
||||
@@ -43,8 +46,10 @@ export function useScrollFade(fadePx = 20, fadeTop = true): {
|
||||
// every child of a long list on each append.
|
||||
const mo = new MutationObserver((records) => {
|
||||
for (const rec of records) {
|
||||
for (const node of Array.from(rec.addedNodes)) if (node instanceof Element) ro.observe(node);
|
||||
for (const node of Array.from(rec.removedNodes)) if (node instanceof Element) ro.unobserve(node);
|
||||
for (const node of Array.from(rec.addedNodes))
|
||||
if (node instanceof Element) ro.observe(node);
|
||||
for (const node of Array.from(rec.removedNodes))
|
||||
if (node instanceof Element) ro.unobserve(node);
|
||||
}
|
||||
update();
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ interface History<T> {
|
||||
|
||||
export function useUndoable<T>(
|
||||
initial: T,
|
||||
opts?: { onApply?: (value: T) => void; limit?: number }
|
||||
opts?: { onApply?: (value: T) => void; limit?: number },
|
||||
): Undoable<T> {
|
||||
const [, force] = useReducer((n: number) => n + 1, 0);
|
||||
const ref = useRef<History<T>>({ past: [], present: initial, future: [] });
|
||||
@@ -48,7 +48,7 @@ export function useUndoable<T>(
|
||||
force();
|
||||
onApplyRef.current?.(next);
|
||||
},
|
||||
[limit]
|
||||
[limit],
|
||||
);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user