fix(ui,i18n): E30 review round 1 — audit rows in the reader's language, one HU name per thing
- audit log: a job run named from its result data (nested phases too), a skipped run says so, a secret's reset reads as a reset, choices named as Configuration names them, interval / batch / counts localized; the inbox shares the same jobRunText - HU: remux is "Közvetlen közvetítés" (it shared "Közvetlen lejátszás" with direct play); backfill is behúzás, enrich is dúsítás, collection is kollekció, the allow-list is fehérlista; the last "jobnak" / "transzkódolás"; a test holds the options of one choice to distinct names - contract: every skipped / reason word a job returns is named in both languages - person page: the bio wait is timed from the open and a failing ask stops the polling; ClampedText (ui) shared with the collection page, whose short summary lost its dead More - a playlist names season 0 the Specials; the channel re-slot compares in the reader's alphabet; an invite's date is in the admin's language; the language-ordered lists are asked again once a language switch is saved (saveLanguage) - poster grids' column minimum in rem; lint refuses minmax(Npx,…)
This commit is contained in:
@@ -428,6 +428,12 @@ const BOX_PX =
|
|||||||
"/(^|[\\s:])(min-|max-)?(w|h|size|left|right|top|bottom|inset|gap|p[xytrbl]?|m[xytrbl]?)-\\[[\\d.]+px\\]/";
|
"/(^|[\\s:])(min-|max-)?(w|h|size|left|right|top|bottom|inset|gap|p[xytrbl]?|m[xytrbl]?)-\\[[\\d.]+px\\]/";
|
||||||
const BOX_PX_MESSAGE =
|
const BOX_PX_MESSAGE =
|
||||||
"px-frozen box size doesn't scale with --font-scale: use the spacing scale (w-37.5, max-w-60), not w-[Npx].";
|
"px-frozen box size doesn't scale with --font-scale: use the spacing scale (w-37.5, max-w-60), not w-[Npx].";
|
||||||
|
// …and a grid's auto-fill column MINIMUM in px (`minmax(150px,1fr)`): the cards' text grows with the
|
||||||
|
// slider while the column floor stays, so the cards get cramped at a large text size (E30 review). A
|
||||||
|
// fixed track (`grid-cols-[1fr_240px]`) is left alone, as is VirtualGrid's measured `minCol` (a number).
|
||||||
|
const GRID_MIN_PX = "/minmax\\([\\d.]+px,/";
|
||||||
|
const GRID_MIN_PX_MESSAGE =
|
||||||
|
"px-frozen box size doesn't scale with --font-scale: give a grid column's minimum in rem (minmax(9.375rem,1fr)), not minmax(Npx,…).";
|
||||||
// A stacking order outside the layer scale (DESIGN.md Tokens: `base < menu < chrome < … < tooltip` in
|
// A stacking order outside the layer scale (DESIGN.md Tokens: `base < menu < chrome < … < tooltip` in
|
||||||
// index.css). A numeric `z-10` equals a level today but doesn't follow it when the scale moves; name it.
|
// index.css). A numeric `z-10` equals a level today but doesn't follow it when the scale moves; name it.
|
||||||
const Z_RAW = "/(^|[\\s:])-?z-([1-9]\\d*|\\[[^\\]]+\\])(?![\\w-])/";
|
const Z_RAW = "/(^|[\\s:])-?z-([1-9]\\d*|\\[[^\\]]+\\])(?![\\w-])/";
|
||||||
@@ -587,6 +593,8 @@ export default tseslint.config(
|
|||||||
// 150px at the default size) — 32 of these were swept in the 2026-09 debt burn.
|
// 150px at the default size) — 32 of these were swept in the 2026-09 debt burn.
|
||||||
{ selector: `Literal[value=${BOX_PX}]`, message: BOX_PX_MESSAGE },
|
{ selector: `Literal[value=${BOX_PX}]`, message: BOX_PX_MESSAGE },
|
||||||
{ selector: `TemplateElement[value.raw=${BOX_PX}]`, message: BOX_PX_MESSAGE },
|
{ selector: `TemplateElement[value.raw=${BOX_PX}]`, message: BOX_PX_MESSAGE },
|
||||||
|
{ selector: `Literal[value=${GRID_MIN_PX}]`, message: GRID_MIN_PX_MESSAGE },
|
||||||
|
{ selector: `TemplateElement[value.raw=${GRID_MIN_PX}]`, message: GRID_MIN_PX_MESSAGE },
|
||||||
{ selector: `Literal[value=${Z_RAW}]`, message: Z_RAW_MESSAGE },
|
{ selector: `Literal[value=${Z_RAW}]`, message: Z_RAW_MESSAGE },
|
||||||
{ selector: `TemplateElement[value.raw=${Z_RAW}]`, message: Z_RAW_MESSAGE },
|
{ selector: `TemplateElement[value.raw=${Z_RAW}]`, message: Z_RAW_MESSAGE },
|
||||||
{ selector: `Literal[value=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
|
{ selector: `Literal[value=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||||
import { qk } from "./lib/queryKeys";
|
import { qk } from "./lib/queryKeys";
|
||||||
|
import { saveLanguage } from "./lib/saveLanguage";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, setUnauthorizedHandler, type Me } from "./lib/api";
|
import { api, setUnauthorizedHandler, type Me } from "./lib/api";
|
||||||
@@ -333,9 +334,9 @@ export default function App() {
|
|||||||
setLanguage(code);
|
setLanguage(code);
|
||||||
// Also from the signed-out login page, where the save 401s — `req` marks that as handled, so
|
// Also from the signed-out login page, where the save 401s — `req` marks that as handled, so
|
||||||
// the reporter stays quiet there.
|
// the reporter stays quiet there.
|
||||||
api
|
saveLanguage(queryClient, code).catch((e: unknown) =>
|
||||||
.savePrefs({ language: code })
|
notifyActionError(e, t("errors.writeFailed.prefs")),
|
||||||
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs")));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
|
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
|
||||||
|
|||||||
@@ -629,7 +629,8 @@ function AdminInvites() {
|
|||||||
// is also where the "you already wrote to them" warning belongs, next to the button that would
|
// is also where the "you already wrote to them" warning belongs, next to the button that would
|
||||||
// do it a second time, rather than only in a tooltip the admin may never hover.
|
// do it a second time, rather than only in a tooltip the admin may never hover.
|
||||||
const onInvite = async (inv: Invite) => {
|
const onInvite = async (inv: Invite) => {
|
||||||
let lang: LangCode = LANGUAGES.some((l) => l.code === inv.lang)
|
// The language the INVITE is written in — not the admin's `lang`, which dates the sentence below.
|
||||||
|
let mailLang: LangCode = LANGUAGES.some((l) => l.code === inv.lang)
|
||||||
? (inv.lang as LangCode)
|
? (inv.lang as LangCode)
|
||||||
: ((i18n.language.split("-")[0] === "hu" ? "hu" : "en") as LangCode);
|
: ((i18n.language.split("-")[0] === "hu" ? "hu" : "en") as LangCode);
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
@@ -647,12 +648,12 @@ function AdminInvites() {
|
|||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<InviteLangPicker initial={lang} onPick={(v) => (lang = v)} />
|
<InviteLangPicker initial={mailLang} onPick={(v) => (mailLang = v)} />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
confirmLabel: t("settings.invites.inviteSend"),
|
confirmLabel: t("settings.invites.inviteSend"),
|
||||||
});
|
});
|
||||||
if (ok) invite.mutate({ inv, lang });
|
if (ok) invite.mutate({ inv, lang: mailLang });
|
||||||
};
|
};
|
||||||
|
|
||||||
const add = useMutation({
|
const add = useMutation({
|
||||||
|
|||||||
@@ -171,7 +171,10 @@ export default function Channels() {
|
|||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const pickPriority = (c: ManagedChannel, next: number) => {
|
const pickPriority = (c: ManagedChannel, next: number) => {
|
||||||
setPriority(c.id, next);
|
setPriority(c.id, next);
|
||||||
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) => old && placeByPriority(old, c.id));
|
qc.setQueryData<ManagedChannel[]>(
|
||||||
|
qk.channels(),
|
||||||
|
(old) => old && placeByPriority(old, c.id, lang),
|
||||||
|
);
|
||||||
setFollow((f) => ({ key: c.id, seq: (f?.seq ?? 0) + 1 }));
|
setFollow((f) => ({ key: c.id, seq: (f?.seq ?? 0) + 1 }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import {
|
|||||||
UserPlus,
|
UserPlus,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, type AppNotification, type JobResult } from "../lib/api";
|
import { api, type AppNotification } from "../lib/api";
|
||||||
import { jobResultText, parseSummary } from "../lib/jobResult";
|
import { jobRunText } from "../lib/jobResult";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
|
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
|
||||||
@@ -530,13 +530,9 @@ function NotificationRow({
|
|||||||
} else if (isScheduler) {
|
} else if (isScheduler) {
|
||||||
// "<Job label> finished/failed", with the result as the body — named in the reader's language
|
// "<Job label> finished/failed", with the result as the body — named in the reader's language
|
||||||
// when it rode along as data (E30), else the English summary of an older notice.
|
// when it rode along as data (E30), else the English summary of an older notice.
|
||||||
const d = n.data as { job_id: string; status?: string; summary?: string; result?: JobResult };
|
const run = jobRunText(t, n.data as { job_id: string }, lang);
|
||||||
const job = t(`scheduler.jobs.${d.job_id}`, d.job_id);
|
title = run.title;
|
||||||
title = t(d.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", {
|
body = run.body || n.body;
|
||||||
job,
|
|
||||||
});
|
|
||||||
const result = d.result ?? parseSummary(d.summary);
|
|
||||||
body = result ? jobResultText(t, result, lang) : d.summary || n.body;
|
|
||||||
} else if (isEmailUndelivered) {
|
} else if (isEmailUndelivered) {
|
||||||
// Email is fail-soft: a drop leaves no error anywhere else, so this notice IS the trace. Name
|
// Email is fail-soft: a drop leaves no error anywhere else, so this notice IS the trace. Name
|
||||||
// the message that was lost, since that is what tells the operator what their users didn't get.
|
// the message that was lost, since that is what tells the operator what their users didn't get.
|
||||||
|
|||||||
@@ -699,7 +699,7 @@ export default function PlexBrowse() {
|
|||||||
{episodes.length > 0 && (
|
{episodes.length > 0 && (
|
||||||
<h2 className="text-sm font-semibold mb-2">{t("plex.unified.titles")}</h2>
|
<h2 className="text-sm font-semibold mb-2">{t("plex.unified.titles")}</h2>
|
||||||
)}
|
)}
|
||||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(9.375rem,1fr))] gap-3">
|
||||||
{items.map((c, i) => (
|
{items.map((c, i) => (
|
||||||
<PlexPosterCard
|
<PlexPosterCard
|
||||||
key={c.id}
|
key={c.id}
|
||||||
@@ -721,7 +721,7 @@ export default function PlexBrowse() {
|
|||||||
{episodes.length > 0 && (
|
{episodes.length > 0 && (
|
||||||
<section className="mt-8">
|
<section className="mt-8">
|
||||||
<h2 className="text-sm font-semibold mb-3">{t("plex.unified.episodes")}</h2>
|
<h2 className="text-sm font-semibold mb-3">{t("plex.unified.episodes")}</h2>
|
||||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
|
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(14.375rem,1fr))] gap-x-3 gap-y-4">
|
||||||
{episodes.map((ep, i) => (
|
{episodes.map((ep, i) => (
|
||||||
<EpisodeCard
|
<EpisodeCard
|
||||||
key={ep.id}
|
key={ep.id}
|
||||||
|
|||||||
@@ -227,3 +227,27 @@ describe("PlexCollectionView — the reader's language (E30)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PlexCollectionView — the summary (E30 review)", () => {
|
||||||
|
it("offers no More under a summary that fits", async () => {
|
||||||
|
vi.spyOn(api, "plexCollection").mockResolvedValue({
|
||||||
|
...detail([card("m1", "movie")]),
|
||||||
|
summary: "One short line.",
|
||||||
|
} as PlexCollectionDetail);
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<PlexCollectionView
|
||||||
|
collectionId="col1"
|
||||||
|
onBack={() => {}}
|
||||||
|
onPlay={vi.fn()}
|
||||||
|
onOpenCard={() => {}}
|
||||||
|
onRestartCard={() => {}}
|
||||||
|
onInfoCard={() => {}}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
await screen.findByText("One short line.");
|
||||||
|
expect(screen.queryByText("plex.collection.more")).toBeNull(); // jsdom: nothing is cut
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { toggleWatchState } from "../lib/plexWatch";
|
|||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
import NavGrid from "./ui/NavGrid";
|
import NavGrid from "./ui/NavGrid";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
|
import { ClampedText } from "./ui/ClampedText";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
import { Input } from "./ui/Input";
|
import { Input } from "./ui/Input";
|
||||||
import { useReturnFocus } from "./ui/useReturnFocus";
|
import { useReturnFocus } from "./ui/useReturnFocus";
|
||||||
@@ -98,7 +99,6 @@ export default function PlexCollectionView({
|
|||||||
});
|
});
|
||||||
const d = q.data;
|
const d = q.data;
|
||||||
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
const renameFocus = useReturnFocus(renaming);
|
const renameFocus = useReturnFocus(renaming);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
@@ -333,21 +333,13 @@ export default function PlexCollectionView({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{d.summary && (
|
{d.summary && (
|
||||||
<div className="mt-3">
|
<ClampedText
|
||||||
<p
|
text={d.summary}
|
||||||
className={`measure text-sm leading-relaxed text-muted ${expanded ? "" : "line-clamp-3"}`}
|
lines={3}
|
||||||
>
|
moreLabel={t("plex.collection.more")}
|
||||||
{d.summary}
|
lessLabel={t("plex.collection.less")}
|
||||||
</p>
|
className="mt-3"
|
||||||
<Button
|
/>
|
||||||
variant="link"
|
|
||||||
aria-expanded={expanded}
|
|
||||||
onClick={() => setExpanded((v) => !v)}
|
|
||||||
className="mt-1 text-xs"
|
|
||||||
>
|
|
||||||
{expanded ? t("plex.collection.less") : t("plex.collection.more")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// and the api is mocked so the query resolves synchronously in the test.
|
// and the api is mocked so the query resolves synchronously in the test.
|
||||||
import { setLanguage } from "../i18n";
|
import { setLanguage } from "../i18n";
|
||||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
import { act, render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -187,6 +187,8 @@ describe("PlexPersonView — the reader's language (E30)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const BIO_POLL = 1600; // just past plexViews' BIO_POLL_MS
|
||||||
|
|
||||||
describe("PlexPersonView — a bio on its way (E30 S6)", () => {
|
describe("PlexPersonView — a bio on its way (E30 S6)", () => {
|
||||||
const view = (
|
const view = (
|
||||||
<PlexPersonView
|
<PlexPersonView
|
||||||
@@ -231,6 +233,43 @@ describe("PlexPersonView — a bio on its way (E30 S6)", () => {
|
|||||||
expect(screen.queryByRole("status")).toBeNull();
|
expect(screen.queryByRole("status")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stops asking once the wait is over, and takes the placeholder away", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
try {
|
||||||
|
plexPerson.mockReset();
|
||||||
|
plexPerson.mockResolvedValue(person({ biography: null, enriching: true })); // TMDB never answers
|
||||||
|
wrap(view);
|
||||||
|
await screen.findByText("Movie One");
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(21_000));
|
||||||
|
const asked = plexPerson.mock.calls.length;
|
||||||
|
expect(asked).toBeGreaterThan(2);
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(10_000));
|
||||||
|
expect(plexPerson).toHaveBeenCalledTimes(asked);
|
||||||
|
expect(screen.queryByRole("status")).toBeNull();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops asking when the page's own request fails", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
try {
|
||||||
|
plexPerson.mockReset();
|
||||||
|
plexPerson
|
||||||
|
.mockResolvedValueOnce(person({ biography: null, enriching: true }))
|
||||||
|
.mockRejectedValue(new Error("server down"));
|
||||||
|
wrap(view);
|
||||||
|
await screen.findByText("Movie One");
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(BIO_POLL));
|
||||||
|
expect(plexPerson).toHaveBeenCalledTimes(2);
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(10_000));
|
||||||
|
expect(plexPerson).toHaveBeenCalledTimes(2); // not every 1.5 s for as long as it fails
|
||||||
|
expect(screen.queryByRole("status")).toBeNull();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("offers More only when the bio is cut, and Less once open", async () => {
|
it("offers More only when the bio is cut, and Less once open", async () => {
|
||||||
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
|
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
|
||||||
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(160);
|
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(160);
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ describe("PlexPlaylistView · a row's menu", () => {
|
|||||||
it("removes one season from its label's menu", async () => {
|
it("removes one season from its label's menu", async () => {
|
||||||
mount([ep("e1", 1, 1), ep("e2", 2, 1), ep("e3", 2, 2)]);
|
mount([ep("e1", 1, 1), ep("e2", 2, 1), ep("e3", 2, 2)]);
|
||||||
fireEvent.click(await screen.findByText("The Show")); // open the group
|
fireEvent.click(await screen.findByText("The Show")); // open the group
|
||||||
const seasonLabel = await screen.findByText(/plex\.playlist\.season · 2/);
|
const seasonLabel = await screen.findByText(/plex\.seasonTitle · 2/);
|
||||||
const more = seasonLabel.parentElement!.querySelector<HTMLElement>(
|
const more = seasonLabel.parentElement!.querySelector<HTMLElement>(
|
||||||
'button[aria-label="plex.playlist.rowMenu"]',
|
'button[aria-label="plex.playlist.rowMenu"]',
|
||||||
)!;
|
)!;
|
||||||
@@ -363,3 +363,12 @@ describe("PlexPlaylistView · the reader's language (E30)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PlexPlaylistView · a season's name (E30 review)", () => {
|
||||||
|
it("names season 0 the Specials, as the show page does", async () => {
|
||||||
|
mount([ep("e0", 0, 1), ep("e1", 1, 1)]);
|
||||||
|
fireEvent.click(await screen.findByText("The Show")); // open the show's seasons
|
||||||
|
expect(await screen.findByText(/^plex\.specials · 1$/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/^plex\.seasonTitle · 1$/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { useReturnFocus } from "./ui/useReturnFocus";
|
|||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
import { PageHeading } from "./ui/Headings";
|
import { PageHeading } from "./ui/Headings";
|
||||||
import { MEDIA_BADGE } from "./ui/mediaCardStyles";
|
import { MEDIA_BADGE } from "./ui/mediaCardStyles";
|
||||||
|
import { seasonTitle } from "../lib/plexDetail";
|
||||||
|
|
||||||
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
|
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
|
||||||
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
|
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
|
||||||
@@ -259,13 +260,11 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A season's name, as its label and in the toast that says it went. */
|
/** A season's name, as its label and in the toast that says it went — named as the show page names it
|
||||||
|
* (season 0 is the Specials). */
|
||||||
function useSeasonLabel() {
|
function useSeasonLabel() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (s: Season) =>
|
return (s: Season) => seasonTitle({ season_number: s.number }, t);
|
||||||
s.number != null
|
|
||||||
? t("plex.playlist.season", { n: s.number })
|
|
||||||
: t("plex.playlist.seasonUnknown");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShowGroup({ block, ctx }: { block: ShowBlock; ctx: Ctx }) {
|
function ShowGroup({ block, ctx }: { block: ShowBlock; ctx: Ctx }) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||||
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { formatDateTime } from "../lib/format";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
@@ -18,7 +19,8 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
useTranslation: () => ({
|
useTranslation: () => ({
|
||||||
// Echo the interpolation too, so a message that forgets its {{email}} is visible in the test.
|
// Echo the interpolation too, so a message that forgets its {{email}} is visible in the test.
|
||||||
t: (key: string, vars?: Record<string, unknown>) =>
|
t: (key: string, vars?: Record<string, unknown>) =>
|
||||||
vars?.email ? `${key}:${String(vars.email)}` : key,
|
(vars?.email ? `${key}:${String(vars.email)}` : key) +
|
||||||
|
(vars?.time ? `@${String(vars.time)}` : ""),
|
||||||
i18n: { language: "en" },
|
i18n: { language: "en" },
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -315,6 +317,19 @@ describe("inviting someone the admin whitelisted", () => {
|
|||||||
expect(dialog.container.textContent).toContain("settings.invites.inviteConfirmAgain");
|
expect(dialog.container.textContent).toContain("settings.invites.inviteConfirmAgain");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("dates the earlier invite in the admin's language, not the invitee's (E30 review)", async () => {
|
||||||
|
listMock.mockResolvedValue([
|
||||||
|
invite({ status: "approved", invited_at: "2026-09-05T09:00:00Z", lang: "hu" }),
|
||||||
|
]);
|
||||||
|
await open();
|
||||||
|
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||||
|
await waitFor(() => expect(confirmMock).toHaveBeenCalled());
|
||||||
|
const dialog = render(<>{confirmMock.mock.calls[0]![0].message}</>);
|
||||||
|
const at = "2026-09-05T09:00:00Z";
|
||||||
|
expect(dialog.container.textContent).toContain(`@${formatDateTime(at, "en")}`);
|
||||||
|
expect(formatDateTime(at, "en")).not.toBe(formatDateTime(at, "hu"));
|
||||||
|
});
|
||||||
|
|
||||||
it("offers none on a denied row, which would contradict the decision", async () => {
|
it("offers none on a denied row, which would contradict the decision", async () => {
|
||||||
listMock.mockResolvedValue([invite({ status: "denied" })]);
|
listMock.mockResolvedValue([invite({ status: "denied" })]);
|
||||||
await open();
|
await open();
|
||||||
|
|||||||
@@ -82,6 +82,68 @@ describe("an audit row's details", () => {
|
|||||||
expect(details(secret)).toMatch(/ \| titok frissítve/);
|
expect(details(secret)).toMatch(/ \| titok frissítve/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("name a nested result and a skipped run from the row's data (E30 review)", () => {
|
||||||
|
const nested = row({
|
||||||
|
target_type: "job",
|
||||||
|
target_id: "people_enrich",
|
||||||
|
after: {
|
||||||
|
status: "ok",
|
||||||
|
summary: "bios={'enriched': 12}, portraits={'cached': 40}",
|
||||||
|
result: { bios: { enriched: 12 }, portraits: { cached: 40 } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(details(nested)).not.toContain("{");
|
||||||
|
expect(details(nested)).toMatch(/^Személyadatok és portrék \(Plex\) kész \| /);
|
||||||
|
const skipped = row({
|
||||||
|
target_type: "job",
|
||||||
|
target_id: "rss_poll",
|
||||||
|
after: { status: "skipped", summary: "sync paused" },
|
||||||
|
});
|
||||||
|
expect(details(skipped)).toBe(
|
||||||
|
"RSS-lekérdezés (új feltöltések) kihagyva | kihagyva: a szinkron szünetel",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tell a secret's reset from its update, and name a choice", () => {
|
||||||
|
const reset = row({
|
||||||
|
action: "config_reset",
|
||||||
|
target_type: "config",
|
||||||
|
target_id: "tmdb_api_key",
|
||||||
|
summary: "tmdb_api_key → default",
|
||||||
|
});
|
||||||
|
expect(details(reset)).toMatch(/ \| titok visszaállítva/);
|
||||||
|
const choice = row({
|
||||||
|
action: "config_set",
|
||||||
|
target_type: "config",
|
||||||
|
target_id: "plex_transcode_hw",
|
||||||
|
before: { value: "auto" },
|
||||||
|
after: { value: "on" },
|
||||||
|
});
|
||||||
|
expect(details(choice)).toMatch(/ \| Automatikus → GPU$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("name an interval, a batch and a count in the reader's language", () => {
|
||||||
|
expect(
|
||||||
|
details(
|
||||||
|
row({
|
||||||
|
action: "scheduler_interval",
|
||||||
|
target_type: "job",
|
||||||
|
target_id: "rss_poll",
|
||||||
|
summary: "rss_poll → 15 min/run",
|
||||||
|
after: { interval_minutes: 15 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe("RSS-lekérdezés (új feltöltések) | 15 percenként");
|
||||||
|
expect(
|
||||||
|
details(row({ action: "maintenance_tune", after: { revalidate_batch: 50000 } })),
|
||||||
|
).toMatch(/^Futásonként újraellenőrzött videók \| 50\s000$/);
|
||||||
|
expect(
|
||||||
|
details(
|
||||||
|
row({ action: "audit_clear", summary: "Cleared 12 audit entries", after: { cleared: 12 } }),
|
||||||
|
),
|
||||||
|
).not.toContain("Cleared");
|
||||||
|
});
|
||||||
|
|
||||||
it("add nothing to a pause, whose action already says it", () => {
|
it("add nothing to a pause, whose action already says it", () => {
|
||||||
expect(details(row({ action: "sync_pause", summary: "Background sync paused" }))).toBe("");
|
expect(details(row({ action: "sync_pause", summary: "Background sync paused" }))).toBe("");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import type { AuditRow } from "../lib/api";
|
import type { AuditRow } from "../lib/api";
|
||||||
import { formatNumber } from "../lib/format";
|
import { formatNumber } from "../lib/format";
|
||||||
import { jobResultText, parseSummary } from "../lib/jobResult";
|
import type { JobResult } from "../lib/api";
|
||||||
|
import { jobResultText, jobRunText } from "../lib/jobResult";
|
||||||
import type { Column } from "./ui/DataTable";
|
import type { Column } from "./ui/DataTable";
|
||||||
import { RelativeTime } from "./ui/RelativeTime";
|
import { RelativeTime } from "./ui/RelativeTime";
|
||||||
|
|
||||||
@@ -14,17 +15,27 @@ function fmt(v: unknown): string {
|
|||||||
return v == null || v === "" ? "—" : String(v);
|
return v == null || v === "" ? "—" : String(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A logged config value in the reader's language: a switch reads on/off, a count is grouped. */
|
/** A logged config value in the reader's language: a switch reads on/off, a count is grouped, a
|
||||||
function configValue(t: TFunction, v: unknown, lang: string): string {
|
* choice is named as the Configuration page names it. */
|
||||||
|
function configValue(t: TFunction, key: string, v: unknown, lang: string): string {
|
||||||
if (typeof v === "boolean") return t(v ? "audit.on" : "audit.off");
|
if (typeof v === "boolean") return t(v ? "audit.on" : "audit.off");
|
||||||
if (typeof v === "number") return formatNumber(v, lang);
|
if (typeof v === "number") return formatNumber(v, lang);
|
||||||
|
if (typeof v === "string" && v) return t(`config.choices.${key}.${v}`, { defaultValue: v });
|
||||||
return fmt(v);
|
return fmt(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A row's `after` as a job-style result (counts), or null when it holds none. */
|
||||||
|
function counts(after: Record<string, unknown>): JobResult | null {
|
||||||
|
const out: JobResult = {};
|
||||||
|
for (const [k, v] of Object.entries(after)) if (typeof v === "number") out[k] = v;
|
||||||
|
return Object.keys(out).length ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** The details of the rows whose server `summary` is a fixed English line (E30), rebuilt from the row's
|
/** The details of the rows whose server `summary` is a fixed English line (E30), rebuilt from the row's
|
||||||
* data in the reader's language: a job run names the job and its result (as the inbox does), a config
|
* data in the reader's language: a job run names the job and its result (as the inbox does), a config
|
||||||
* change names the field and its values, a pause/resume says nothing the action has not. `undefined` =
|
* change names the field and its values, an interval or batch change its new value, a count-bearing
|
||||||
* not one of these; the generic summary + change rendering applies. */
|
* action its counts; a pause/resume says nothing the action has not. `undefined` = not one of these (or
|
||||||
|
* an older row without the data); the generic summary + change rendering applies. */
|
||||||
function localDetails(
|
function localDetails(
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
row: AuditRow,
|
row: AuditRow,
|
||||||
@@ -33,23 +44,40 @@ function localDetails(
|
|||||||
const after = row.after ?? {};
|
const after = row.after ?? {};
|
||||||
const before = row.before ?? {};
|
const before = row.before ?? {};
|
||||||
if (row.action === "job_run" && row.target_type === "job" && row.target_id) {
|
if (row.action === "job_run" && row.target_type === "job" && row.target_id) {
|
||||||
const job = t(`scheduler.jobs.${row.target_id}`, row.target_id);
|
const run = jobRunText(t, { job_id: row.target_id, ...after }, lang);
|
||||||
const failed = after.status === "error";
|
return { head: run.title, sub: run.body };
|
||||||
const summary = typeof after.summary === "string" ? after.summary : null;
|
|
||||||
// A nested phase (a Python dict repr) or an error text does not parse, and shows as it was.
|
|
||||||
const result = failed ? null : parseSummary(summary);
|
|
||||||
return {
|
|
||||||
head: t(failed ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", { job }),
|
|
||||||
sub: result ? jobResultText(t, result, lang) : summary,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if ((row.action === "config_set" || row.action === "config_reset") && row.target_id) {
|
if ((row.action === "config_set" || row.action === "config_reset") && row.target_id) {
|
||||||
const head = t(`config.fields.${row.target_id}.label`, row.target_id);
|
const key = row.target_id;
|
||||||
|
const head = t(`config.fields.${key}.label`, key);
|
||||||
|
const reset = row.action === "config_reset";
|
||||||
// A secret's value is never logged: there is no before/after to show.
|
// A secret's value is never logged: there is no before/after to show.
|
||||||
if (!row.before && !row.after) return { head, sub: t("audit.secretUpdated") };
|
if (!row.before && !row.after)
|
||||||
const to =
|
return { head, sub: t(reset ? "audit.secretReset" : "audit.secretUpdated") };
|
||||||
row.action === "config_reset" ? t("audit.default") : configValue(t, after.value, lang);
|
const to = reset ? t("audit.default") : configValue(t, key, after.value, lang);
|
||||||
return { head, sub: `${configValue(t, before.value, lang)} → ${to}` };
|
return { head, sub: `${configValue(t, key, before.value, lang)} → ${to}` };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
row.action === "scheduler_interval" &&
|
||||||
|
row.target_id &&
|
||||||
|
typeof after.interval_minutes === "number"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
head: t(`scheduler.jobs.${row.target_id}`, row.target_id),
|
||||||
|
sub: t("scheduler.everyMin", { count: after.interval_minutes }),
|
||||||
|
};
|
||||||
|
if (row.action === "maintenance_tune" && typeof after.revalidate_batch === "number")
|
||||||
|
return {
|
||||||
|
head: t("scheduler.maintenance.batchLabel"),
|
||||||
|
sub: formatNumber(after.revalidate_batch, lang),
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
row.action === "audit_clear" ||
|
||||||
|
row.action === "demo_reset" ||
|
||||||
|
row.action === "discovery_purge"
|
||||||
|
) {
|
||||||
|
const result = counts(after);
|
||||||
|
return result ? { head: null, sub: jobResultText(t, result, lang) } : undefined;
|
||||||
}
|
}
|
||||||
if (row.action === "sync_pause" || row.action === "sync_resume") return { head: null, sub: null };
|
if (row.action === "sync_pause" || row.action === "sync_resume") return { head: null, sub: null };
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, Suspense, useLayoutEffect, useRef, useState } from "react";
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -48,6 +48,7 @@ import { useCopyItems } from "./copyItems";
|
|||||||
import { ContextMenu } from "./ui/ContextMenu";
|
import { ContextMenu } from "./ui/ContextMenu";
|
||||||
import { useContextMenu } from "./ui/useContextMenu";
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
|
import { ClampedText } from "./ui/ClampedText";
|
||||||
import { Carousel } from "./ui/Carousel";
|
import { Carousel } from "./ui/Carousel";
|
||||||
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||||
@@ -128,45 +129,6 @@ const BIO_POLL_MS = 1500;
|
|||||||
const KNOWN_FOR_HALF = 4;
|
const KNOWN_FOR_HALF = 4;
|
||||||
const BIO_WAIT_MS = 20_000;
|
const BIO_WAIT_MS = 20_000;
|
||||||
|
|
||||||
/** A person's biography, clamped to a few lines with a More / Less toggle — offered only when the
|
|
||||||
* text is actually cut (measured, so a short bio carries no dead button). */
|
|
||||||
function PersonBio({ text }: { text: string }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const [clipped, setClipped] = useState(false);
|
|
||||||
const ref = useRef<HTMLParagraphElement>(null);
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el || expanded) return;
|
|
||||||
// Re-measured on a resize: the text-size slider and the window both change where the cut falls.
|
|
||||||
const measure = () => setClipped(el.scrollHeight > el.clientHeight + 1);
|
|
||||||
measure();
|
|
||||||
const ro = new ResizeObserver(measure);
|
|
||||||
ro.observe(el);
|
|
||||||
return () => ro.disconnect();
|
|
||||||
}, [text, expanded]);
|
|
||||||
return (
|
|
||||||
<div className="mt-4">
|
|
||||||
<p
|
|
||||||
ref={ref}
|
|
||||||
className={`measure whitespace-pre-line text-sm leading-relaxed text-muted ${expanded ? "" : "line-clamp-8"}`}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</p>
|
|
||||||
{(clipped || expanded) && (
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
aria-expanded={expanded}
|
|
||||||
onClick={() => setExpanded((v) => !v)}
|
|
||||||
className="mt-1 text-xs"
|
|
||||||
>
|
|
||||||
{expanded ? t("plex.person.less") : t("plex.person.more")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Actor detail: a cast/crew member's filmography (movies + shows in one place) ---------------
|
// --- Actor detail: a cast/crew member's filmography (movies + shows in one place) ---------------
|
||||||
// The inverse of the cast-name → library-filter link: opened from a cast row (info/show page), it
|
// The inverse of the cast-name → library-filter link: opened from a cast row (info/show page), it
|
||||||
// shows the person's headshot + name, a "filter the library by them" action, and their titles as two
|
// shows the person's headshot + name, a "filter the library by them" action, and their titles as two
|
||||||
@@ -196,16 +158,23 @@ export function PlexPersonView({
|
|||||||
const personKey = qk.plexPerson(name, lang);
|
const personKey = qk.plexPerson(name, lang);
|
||||||
// A person the people_enrich job hasn't reached yet gets their bio fetched when the page opens (E30
|
// A person the people_enrich job hasn't reached yet gets their bio fetched when the page opens (E30
|
||||||
// S6); the answer says so (`enriching`) and the page asks again until it lands — for a while, not
|
// S6); the answer says so (`enriching`) and the page asks again until it lands — for a while, not
|
||||||
// forever: past BIO_WAIT_MS a TMDB that is down stops being waited on (the next open tries again).
|
// forever: BIO_WAIT_MS after the page opened (by the clock, not by the last answer — a failing ask
|
||||||
const [openedAt] = useState(() => Date.now());
|
// leaves that where it was) a TMDB that is down stops being waited on, and a failing ask ends it at
|
||||||
|
// once (the next open tries again). The page is keyed on the person, so the wait starts per person.
|
||||||
|
const [waitOver, setWaitOver] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setWaitOver(true), BIO_WAIT_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
queryKey: personKey,
|
queryKey: personKey,
|
||||||
queryFn: () => api.plexPerson(name, lang),
|
queryFn: () => api.plexPerson(name, lang),
|
||||||
refetchInterval: (query) =>
|
refetchInterval: (query) =>
|
||||||
query.state.data?.enriching && query.state.dataUpdatedAt - openedAt < BIO_WAIT_MS
|
query.state.data?.enriching && !waitOver && query.state.status !== "error"
|
||||||
? BIO_POLL_MS
|
? BIO_POLL_MS
|
||||||
: false,
|
: false,
|
||||||
});
|
});
|
||||||
|
const bioOnItsWay = !!q.data?.enriching && !waitOver && !q.isError;
|
||||||
// E5 S8: cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls
|
// E5 S8: cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls
|
||||||
// out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control.
|
// out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control.
|
||||||
const { artBg, crossfade } = useDetailPrefs();
|
const { artBg, crossfade } = useDetailPrefs();
|
||||||
@@ -256,7 +225,7 @@ export function PlexPersonView({
|
|||||||
cards.length > 0 ? (
|
cards.length > 0 ? (
|
||||||
<section className="mb-5">
|
<section className="mb-5">
|
||||||
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
|
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
|
||||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(9.375rem,1fr))] gap-3">
|
||||||
{cards.map((c, i) => (
|
{cards.map((c, i) => (
|
||||||
<PlexPosterCard
|
<PlexPosterCard
|
||||||
key={c.id}
|
key={c.id}
|
||||||
@@ -353,10 +322,15 @@ export function PlexPersonView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{d.biography ? (
|
{d.biography ? (
|
||||||
<PersonBio text={d.biography} />
|
<ClampedText
|
||||||
|
text={d.biography}
|
||||||
|
lines={8}
|
||||||
|
moreLabel={t("plex.person.more")}
|
||||||
|
lessLabel={t("plex.person.less")}
|
||||||
|
className="mt-4"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
d.enriching &&
|
bioOnItsWay && (
|
||||||
q.dataUpdatedAt - openedAt < BIO_WAIT_MS && (
|
|
||||||
<LoadingState
|
<LoadingState
|
||||||
shape="text"
|
shape="text"
|
||||||
label={t("plex.person.bioLoading")}
|
label={t("plex.person.bioLoading")}
|
||||||
@@ -694,7 +668,7 @@ export function PlexShowView({
|
|||||||
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||||
>
|
>
|
||||||
<h2 className="text-sm font-semibold mb-3">{t("plex.series.seasons")}</h2>
|
<h2 className="text-sm font-semibold mb-3">{t("plex.series.seasons")}</h2>
|
||||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3">
|
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(8.75rem,1fr))] gap-3">
|
||||||
{d.seasons.map((se, i) => (
|
{d.seasons.map((se, i) => (
|
||||||
<SeasonCard
|
<SeasonCard
|
||||||
key={se.id}
|
key={se.id}
|
||||||
@@ -894,7 +868,7 @@ export function PlexSeasonView({
|
|||||||
</div>
|
</div>
|
||||||
</DetailHero>
|
</DetailHero>
|
||||||
|
|
||||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
|
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(14.375rem,1fr))] gap-x-3 gap-y-4">
|
||||||
{se.episodes.map((ep, i) => (
|
{se.episodes.map((ep, i) => (
|
||||||
<EpisodeCard
|
<EpisodeCard
|
||||||
key={ep.id}
|
key={ep.id}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// A clamped text offers More only when it is actually cut (E30 S6 on the person page, shared with the
|
||||||
|
// collection page in the E30 review, where a one-line summary carried a More that did nothing).
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { ClampedText } from "./ClampedText";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
class Stub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
|
});
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const view = (text: string) => (
|
||||||
|
<ClampedText text={text} lines={3} moreLabel="More" lessLabel="Less" />
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("ClampedText", () => {
|
||||||
|
it("offers no toggle for a text that fits", () => {
|
||||||
|
render(view("Short."));
|
||||||
|
expect(screen.queryByText("More")).toBeNull(); // jsdom: nothing is cut
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers More for a cut text, and Less once open", () => {
|
||||||
|
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
|
||||||
|
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60);
|
||||||
|
try {
|
||||||
|
render(view("A long text.\n\nOn and on."));
|
||||||
|
const p = screen.getByText(/A long text/);
|
||||||
|
expect(p.className).toContain("line-clamp-3");
|
||||||
|
fireEvent.click(screen.getByText("More"));
|
||||||
|
expect(screen.getByText("Less").getAttribute("aria-expanded")).toBe("true");
|
||||||
|
expect(p.className).not.toContain("line-clamp-3");
|
||||||
|
} finally {
|
||||||
|
tall.mockRestore();
|
||||||
|
box.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
|
||||||
|
// Static class names, so Tailwind sees them.
|
||||||
|
const CLAMP = { 3: "line-clamp-3", 8: "line-clamp-8" } as const;
|
||||||
|
|
||||||
|
/** A long text (a biography, a summary) clamped to a few lines, with a More / Less toggle offered only
|
||||||
|
* when the text is actually cut — measured, so a short text carries no dead button. Re-measured on a
|
||||||
|
* resize: the window and the text-size slider both move where the cut falls. */
|
||||||
|
export function ClampedText({
|
||||||
|
text,
|
||||||
|
lines,
|
||||||
|
moreLabel,
|
||||||
|
lessLabel,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
lines: keyof typeof CLAMP;
|
||||||
|
moreLabel: string;
|
||||||
|
lessLabel: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [clipped, setClipped] = useState(false);
|
||||||
|
const ref = useRef<HTMLParagraphElement>(null);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el || expanded) return;
|
||||||
|
const measure = () => setClipped(el.scrollHeight > el.clientHeight + 1);
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [text, expanded]);
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={`measure whitespace-pre-line text-sm leading-relaxed text-muted ${expanded ? "" : CLAMP[lines]}`}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
{(clipped || expanded) && (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="mt-1 text-xs"
|
||||||
|
>
|
||||||
|
{expanded ? lessLabel : moreLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ domain-free.
|
|||||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
|
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
|
||||||
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||||
| data display | `DataTable`, `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy") |
|
| data display | `DataTable`, `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut) |
|
||||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,29 @@ const routeFiles = (dir = "routes"): string[] =>
|
|||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
const routeSources = (): string[] => routeFiles().map(backend);
|
const routeSources = (): string[] => routeFiles().map(backend);
|
||||||
|
// The words a job's result gives for not doing its work → `scheduler.skip.<vocabKey(word)>` (the Scheduler
|
||||||
|
// page, the run notice and the audit row name them; lib/jobResult). Read as the `"skipped": "…"` /
|
||||||
|
// `"reason": "…"` literals of the non-HTTP backend: under routes/ (and main.py) a `reason` is a refusal
|
||||||
|
// code, which `errors.*` names. A word from an exception's text is not a literal and is not covered.
|
||||||
|
const appFiles = (dir: string): string[] =>
|
||||||
|
readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), {
|
||||||
|
withFileTypes: true,
|
||||||
|
}).flatMap((e) =>
|
||||||
|
e.isDirectory()
|
||||||
|
? e.name === "routes" || e.name === "__pycache__"
|
||||||
|
? []
|
||||||
|
: appFiles(`${dir}${e.name}/`)
|
||||||
|
: e.name.endsWith(".py") && `${dir}${e.name}` !== "main.py"
|
||||||
|
? [`${dir}${e.name}`]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const skipWords = [
|
||||||
|
...new Set(
|
||||||
|
appFiles("").flatMap((f) =>
|
||||||
|
[...backend(f).matchAll(/"(?:skipped|reason)": "([^"]+)"/g)].map((m) => m[1]!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
// A target the scan cannot read — `what=SOME_CONSTANT` — is a 404 no locale is checked for (one hid
|
// A target the scan cannot read — `what=SOME_CONSTANT` — is a 404 no locale is checked for (one hid
|
||||||
// `debug_session`). Only the helpers' own pass-through in `_common.py` may name it by variable.
|
// `debug_session`). Only the helpers' own pass-through in `_common.py` may name it by variable.
|
||||||
const opaqueWhats = routeFiles()
|
const opaqueWhats = routeFiles()
|
||||||
@@ -217,6 +240,14 @@ describe("backend ids have their names in both languages", () => {
|
|||||||
expect.arrayContaining(["English", "Gaming", "Health & Fitness", "Hobbies", "Sports"]),
|
expect.arrayContaining(["English", "Gaming", "Health & Fitness", "Hobbies", "Sports"]),
|
||||||
);
|
);
|
||||||
expect(builtinProfiles).toEqual(expect.arrayContaining(["720p (MP4)", "Video only (MP4)"]));
|
expect(builtinProfiles).toEqual(expect.arrayContaining(["720p (MP4)", "Video only (MP4)"]));
|
||||||
|
expect(skipWords).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"disabled",
|
||||||
|
"sync paused",
|
||||||
|
"tmdb_api_key not set",
|
||||||
|
"no service user",
|
||||||
|
]),
|
||||||
|
);
|
||||||
expect(refusedReasons).toEqual(
|
expect(refusedReasons).toEqual(
|
||||||
expect.arrayContaining(["weak_password", "setting_too_low", "plex_write_failed"]),
|
expect.arrayContaining(["weak_password", "setting_too_low", "plex_write_failed"]),
|
||||||
);
|
);
|
||||||
@@ -256,6 +287,10 @@ describe("backend ids have their names in both languages", () => {
|
|||||||
expect([...idsUnder(keys, prefix)].filter((j) => !jobs.includes(j))).toEqual([]);
|
expect([...idsUnder(keys, prefix)].filter((j) => !jobs.includes(j))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(`${lang}: every word a job's result gives for skipping its work`, () => {
|
||||||
|
expect(missing(skipWords.map((w) => `scheduler.skip.${vocabKey(w)}`))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it(`${lang}: every audit and quota action`, () => {
|
it(`${lang}: every audit and quota action`, () => {
|
||||||
expect(auditActions.filter((a) => !idsUnder(keys, "auditActions").has(a))).toEqual([]);
|
expect(auditActions.filter((a) => !idsUnder(keys, "auditActions").has(a))).toEqual([]);
|
||||||
expect(quotaActions.filter((a) => !idsUnder(keys, "quotaActions").has(a))).toEqual([]);
|
expect(quotaActions.filter((a) => !idsUnder(keys, "quotaActions").has(a))).toEqual([]);
|
||||||
|
|||||||
@@ -43,3 +43,32 @@ describe("the copy rule", () => {
|
|||||||
expect(offenders((v) => /(\d|\}\})\s?(m|h|d|s)(?![\p{L}\d/])/u.test(v))).toEqual([]);
|
expect(offenders((v) => /(\d|\}\})\s?(m|h|d|s)(?![\p{L}\d/])/u.test(v))).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("one name per thing", () => {
|
||||||
|
// The options of one choice are told apart by their names alone: two playback classes once shared
|
||||||
|
// "Közvetlen lejátszás" in the HU filter (E30 review). A set = a `…Opt` object, or one choice key's
|
||||||
|
// options under config.choices.
|
||||||
|
const sets = (): [string, Record<string, unknown>][] => {
|
||||||
|
const out: [string, Record<string, unknown>][] = [];
|
||||||
|
const walk = (o: unknown, path: string, parent: string) => {
|
||||||
|
if (!o || typeof o !== "object") return;
|
||||||
|
if (/Opt$/.test(path.split(".").pop() ?? "") || parent.endsWith("/config:.choices"))
|
||||||
|
out.push([path, o as Record<string, unknown>]);
|
||||||
|
for (const [k, v] of Object.entries(o)) walk(v, `${path}.${k}`, path);
|
||||||
|
};
|
||||||
|
for (const [file, json] of Object.entries(files))
|
||||||
|
walk(json, file.replace("./locales/", "").replace(".json", ":"), "");
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("names the options of one choice differently", () => {
|
||||||
|
const found = sets();
|
||||||
|
expect(found.some(([p]) => p.endsWith("playableOpt"))).toBe(true);
|
||||||
|
expect(found.some(([p]) => p.endsWith("choices.log_level"))).toBe(true);
|
||||||
|
const clashes = found.flatMap(([path, opts]) => {
|
||||||
|
const names = Object.values(opts).filter((v): v is string => typeof v === "string");
|
||||||
|
return names.length === new Set(names).size ? [] : [path];
|
||||||
|
});
|
||||||
|
expect(clashes).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"off": "Off",
|
"off": "Off",
|
||||||
"default": "default",
|
"default": "default",
|
||||||
"secretUpdated": "secret updated (the value is never logged)",
|
"secretUpdated": "secret updated (the value is never logged)",
|
||||||
|
"secretReset": "secret reset to its default (the value is never logged)",
|
||||||
"cols": {
|
"cols": {
|
||||||
"when": "When",
|
"when": "When",
|
||||||
"actor": "Who",
|
"actor": "Who",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
},
|
},
|
||||||
"jobDone": {
|
"jobDone": {
|
||||||
"titleOk": "{{job}} finished",
|
"titleOk": "{{job}} finished",
|
||||||
|
"titleSkipped": "{{job}} skipped",
|
||||||
"titleError": "Couldn't run {{job}}"
|
"titleError": "Couldn't run {{job}}"
|
||||||
},
|
},
|
||||||
"emailUndelivered": {
|
"emailUndelivered": {
|
||||||
|
|||||||
@@ -320,7 +320,6 @@
|
|||||||
"removeShow": "Remove whole show",
|
"removeShow": "Remove whole show",
|
||||||
"removeSeason": "Remove whole season",
|
"removeSeason": "Remove whole season",
|
||||||
"episodes": "{{count}} episodes",
|
"episodes": "{{count}} episodes",
|
||||||
"season": "Season {{n}}",
|
|
||||||
"seasonUnknown": "Episodes",
|
"seasonUnknown": "Episodes",
|
||||||
"addShow": "Add whole show to a playlist",
|
"addShow": "Add whole show to a playlist",
|
||||||
"addSeason": "Add season to a playlist",
|
"addSeason": "Add season to a playlist",
|
||||||
|
|||||||
@@ -182,6 +182,9 @@
|
|||||||
"tmdbapikeynotset": "TMDB API key not set",
|
"tmdbapikeynotset": "TMDB API key not set",
|
||||||
"nodemoaccount": "no demo account",
|
"nodemoaccount": "no demo account",
|
||||||
"noserviceuser": "no service user",
|
"noserviceuser": "no service user",
|
||||||
"syncpaused": "sync paused"
|
"syncpaused": "sync paused",
|
||||||
|
"lowquota": "low quota",
|
||||||
|
"noreadscope": "no YouTube read access",
|
||||||
|
"busy": "another sync is running"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"pivotJob": "feladat {{id}}",
|
"pivotJob": "feladat {{id}}",
|
||||||
"clearPivot": "Szűrő törlése",
|
"clearPivot": "Szűrő törlése",
|
||||||
"showRequest": "Csak ennek a kérésnek a sorai",
|
"showRequest": "Csak ennek a kérésnek a sorai",
|
||||||
"showJob": "Csak ennek a jobnak a sorai",
|
"showJob": "Csak ennek a feladatnak a sorai",
|
||||||
"empty": "Nincs a szűrésnek megfelelő sor.",
|
"empty": "Nincs a szűrésnek megfelelő sor.",
|
||||||
"stale": "Nem sikerült frissíteni — amit látsz, elavult lehet",
|
"stale": "Nem sikerült frissíteni — amit látsz, elavult lehet",
|
||||||
"olderFailed": "Nem sikerült betölteni a régebbi sorokat",
|
"olderFailed": "Nem sikerült betölteni a régebbi sorokat",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"off": "Ki",
|
"off": "Ki",
|
||||||
"default": "alapérték",
|
"default": "alapérték",
|
||||||
"secretUpdated": "titok frissítve (az értéke sosem kerül a naplóba)",
|
"secretUpdated": "titok frissítve (az értéke sosem kerül a naplóba)",
|
||||||
|
"secretReset": "titok visszaállítva az alapértékre (az értéke sosem kerül a naplóba)",
|
||||||
"cols": {
|
"cols": {
|
||||||
"when": "Mikor",
|
"when": "Mikor",
|
||||||
"actor": "Ki",
|
"actor": "Ki",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"audit_clear": "Napló ürítve",
|
"audit_clear": "Napló ürítve",
|
||||||
"download_credential_set": "Letöltési cookie-k beállítva",
|
"download_credential_set": "Letöltési cookie-k beállítva",
|
||||||
"download_credential_delete": "Letöltési cookie-k törölve",
|
"download_credential_delete": "Letöltési cookie-k törölve",
|
||||||
"invite_delete": "Whitelista-bejegyzés törölve",
|
"invite_delete": "Fehérlista-bejegyzés törölve",
|
||||||
"invite_send": "Meghívó elküldve",
|
"invite_send": "Meghívó elküldve",
|
||||||
"issue_create": "Hibajegy elküldve"
|
"issue_create": "Hibajegy elküldve"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,9 @@
|
|||||||
"keywords": "Kulcsszavak",
|
"keywords": "Kulcsszavak",
|
||||||
"showInFeed": "Mutasd a Feedemben",
|
"showInFeed": "Mutasd a Feedemben",
|
||||||
"fullHistory": "Teljes előzmény",
|
"fullHistory": "Teljes előzmény",
|
||||||
"fullHistoryLoading": "Előzmény letöltése…",
|
"fullHistoryLoading": "Előzmény behúzása…",
|
||||||
"fullHistoryProgress": "Előzmény letöltése… {{stored}} / {{total}}",
|
"fullHistoryProgress": "Előzmény behúzása… {{stored}} / {{total}}",
|
||||||
"fullHistoryLoadingHint": "A teljes előzmény letöltése folyamatban — a videók feltűnnek, ahogy előkerülnek; egy nagyon nagy csatorna maradékát a háttérben fejezi be.",
|
"fullHistoryLoadingHint": "A teljes előzmény behúzása folyamatban — a videók feltűnnek, ahogy előkerülnek; egy nagyon nagy csatorna maradékát a háttérben fejezi be.",
|
||||||
"fullHistoryQueued": "A csatorna teljes előzményének letöltése elindult — az új videók feltűnnek, ahogy előkerülnek.",
|
"fullHistoryQueued": "A csatorna teljes előzményének behúzása elindult — az új videók feltűnnek, ahogy előkerülnek.",
|
||||||
"fullHistoryFailed": "Nem sikerült elindítani a teljes előzmény letöltését"
|
"fullHistoryFailed": "Nem sikerült elindítani a teljes előzmény behúzását"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"actionsLabel": "Katalógus-műveletek",
|
"actionsLabel": "Katalógus-műveletek",
|
||||||
"syncSubscriptions": "Feliratkozások szinkronizálása a YouTube-ról",
|
"syncSubscriptions": "Feliratkozások szinkronizálása a YouTube-ról",
|
||||||
"syncSubscriptionsHint": "Újraimportálja a feliratkozási listádat a YouTube-ról — hozzáadja az újonnan követett csatornákat, és eltávolítja azokat, amelyekről leiratkoztál. Maguk a videók a háttérben automatikusan tovább szinkronizálódnak; ez nem tölti le őket újra.",
|
"syncSubscriptionsHint": "Újraimportálja a feliratkozási listádat a YouTube-ról — hozzáadja az újonnan követett csatornákat, és eltávolítja azokat, amelyekről leiratkoztál. Maguk a videók a háttérben automatikusan tovább szinkronizálódnak; ez nem tölti le őket újra.",
|
||||||
"backfillEverything": "Minden letöltése",
|
"backfillEverything": "Minden videó behúzása",
|
||||||
"backfillEverythingHint": "Teljes archívum letöltését kéri minden csatornához, amelyre feliratkoztál. A régebbi videók és a keresés a megosztott napi kvóta függvényében válik teljessé — ez eltarthat egy ideig.",
|
"backfillEverythingHint": "A teljes archívum behúzását kéri minden csatornához, amelyre feliratkoztál. A régebbi videók és a keresés a megosztott napi kvóta függvényében válik teljessé — ez eltarthat egy ideig.",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"subscribed": "Feliratkozások",
|
"subscribed": "Feliratkozások",
|
||||||
"discovery": "Felfedezés a lejátszási listákból",
|
"discovery": "Felfedezés a lejátszási listákból",
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
"fullHistory": "Teljes előzmény",
|
"fullHistory": "Teljes előzmény",
|
||||||
"fullHistoryHint": "Csatornák, amelyek teljes archívuma le van töltve, azok közül, amelyekhez teljes előzményt kértél.",
|
"fullHistoryHint": "Csatornák, amelyek teljes archívuma le van töltve, azok közül, amelyekhez teljes előzményt kértél.",
|
||||||
"left": "hátra",
|
"left": "hátra",
|
||||||
"leftHint": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének letöltésére, a megosztott napi kvóta alapján.",
|
"leftHint": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének behúzására, a megosztott napi kvóta alapján.",
|
||||||
"myVideos": "Videóim",
|
"myVideos": "Videóim",
|
||||||
"myVideosHint": "A csatornáidon elérhető videók teljes száma.",
|
"myVideosHint": "A csatornáidon elérhető videók teljes száma.",
|
||||||
"quotaLeft": "Maradék kvóta",
|
"quotaLeft": "Maradék kvóta",
|
||||||
@@ -105,15 +105,15 @@
|
|||||||
"editTags": "Címkék szerkesztése…",
|
"editTags": "Címkék szerkesztése…",
|
||||||
"filterFeedByTag": "Feed szűrése: „{{name}}”",
|
"filterFeedByTag": "Feed szűrése: „{{name}}”",
|
||||||
"priorityHint": "A te rangsorod ehhez a csatornához. Rendezd a Feedet „Csatorna prioritás” szerint, hogy a magasabb prioritású csatornák felülre kerüljenek.",
|
"priorityHint": "A te rangsorod ehhez a csatornához. Rendezd a Feedet „Csatorna prioritás” szerint, hogy a magasabb prioritású csatornák felülre kerüljenek.",
|
||||||
"fullHint": "Teljes előzmény letöltve.",
|
"fullHint": "Teljes előzmény behúzva.",
|
||||||
"fullySynced": "teljesen szinkronban",
|
"fullySynced": "teljesen szinkronban",
|
||||||
"fullySyncedHint": "A legutóbbi feltöltések és a teljes archívum is megvan.",
|
"fullySyncedHint": "A legutóbbi feltöltések és a teljes archívum is megvan.",
|
||||||
"backfillThis": "Csatorna teljes letöltése",
|
"backfillThis": "A csatorna teljes behúzása",
|
||||||
"backfillThisHint": "A csatorna teljes archívumának letöltése (régebbi videók + teljes keresés).",
|
"backfillThisHint": "A csatorna teljes archívumának behúzása (régebbi videók + teljes keresés).",
|
||||||
"resetBackfill": "Csatorna alaphelyzetbe állítása és újraletöltése",
|
"resetBackfill": "Csatorna alaphelyzetbe állítása és újbóli behúzása",
|
||||||
"cancelBackfill": "A teljes letöltés kérésének visszavonása",
|
"cancelBackfill": "A teljes behúzás kérésének visszavonása",
|
||||||
"resetHint": "Admin: a csatorna teljes újraletöltése a nulláról (legutóbbi + teljes archívum), a jelenlegi szinkron-állapottól függetlenül.",
|
"resetHint": "Admin: a csatorna teljes újbóli behúzása a nulláról (legutóbbi + teljes archívum), a jelenlegi szinkron-állapottól függetlenül.",
|
||||||
"queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi.",
|
"queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma bekerül, ahogy a megosztott kvóta engedi.",
|
||||||
"queuedByOtherHint": "Egy másik feliratkozó már kérte ennek a csatornának a teljes előzményét, így a teljes archívuma már úton van mindenkihez — itt nincs teendőd.",
|
"queuedByOtherHint": "Egy másik feliratkozó már kérte ennek a csatornának a teljes előzményét, így a teljes archívuma már úton van mindenkihez — itt nincs teendőd.",
|
||||||
"hideHint": "A csatorna videóinak elrejtése a Feededből. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.",
|
"hideHint": "A csatorna videóinak elrejtése a Feededből. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.",
|
||||||
"hideFromFeed": "Elrejtés a Feedből",
|
"hideFromFeed": "Elrejtés a Feedből",
|
||||||
@@ -134,12 +134,12 @@
|
|||||||
"needYouTube": "Ehhez csatlakoztasd a YouTube-fiókod",
|
"needYouTube": "Ehhez csatlakoztasd a YouTube-fiókod",
|
||||||
"connect": "Csatlakoztatás",
|
"connect": "Csatlakoztatás",
|
||||||
"filteredByTag": "Feed szűrve címkére: {{name}}",
|
"filteredByTag": "Feed szűrve címkére: {{name}}",
|
||||||
"resetDone": "{{name}} alaphelyzetbe állítva — újraletöltés folyamatban",
|
"resetDone": "{{name}} alaphelyzetbe állítva — újbóli behúzás folyamatban",
|
||||||
"resetFailed": "Nem sikerült alaphelyzetbe állítani a csatornát",
|
"resetFailed": "Nem sikerült alaphelyzetbe állítani a csatornát",
|
||||||
"hidden": "„{{name}}” elrejtve a Feedből"
|
"hidden": "„{{name}}” elrejtve a Feedből"
|
||||||
},
|
},
|
||||||
"confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a Feededből szeretnéd eltávolítani, inkább rejtsd el.",
|
"confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a Feededből szeretnéd eltávolítani, inkább rejtsd el.",
|
||||||
"confirmReset": "Újraletöltöd a(z) „{{name}}” csatornát a nulláról? Ez újrafuttatja a videók behúzását (legutóbbi + teljes előzmény), és némi API-kvótát fogyaszt.",
|
"confirmReset": "Újra behúzod a(z) „{{name}}” csatorna videóit a nulláról? Ez újrafuttatja a videók behúzását (legutóbbi + teljes előzmény), és némi API-kvótát fogyaszt.",
|
||||||
"searchPlaceholder": "Csatornák keresése…",
|
"searchPlaceholder": "Csatornák keresése…",
|
||||||
"blocked": {
|
"blocked": {
|
||||||
"title": "Tiltott csatornák",
|
"title": "Tiltott csatornák",
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
},
|
},
|
||||||
"backfill_quota_reserve": {
|
"backfill_quota_reserve": {
|
||||||
"label": "Letöltési kvótatartalék",
|
"label": "Letöltési kvótatartalék",
|
||||||
"hint": "Tartalékban tartott egységek, hogy az ütemezett letöltés ne éheztesse ki az interaktív szinkront / gazdagítást."
|
"hint": "Tartalékban tartott egységek, hogy az ütemezett behúzás ne éheztesse ki az interaktív szinkront / dúsítást."
|
||||||
},
|
},
|
||||||
"search_daily_limit_per_user": {
|
"search_daily_limit_per_user": {
|
||||||
"label": "Élő keresés — napi limit / felhasználó",
|
"label": "Élő keresés — napi limit / felhasználó",
|
||||||
@@ -57,11 +57,11 @@
|
|||||||
"hint": "Honnan jönnek az élő YouTube-keresés találatai. A „Weboldal” a YouTube saját keresővégpontját olvassa, és nem fogyaszt API-kvótát (ajánlott). A „Hivatalos API” a search.list-et használja (100 egység/oldal) — válts rá, ha a weboldalas forrás elromlik vagy letiltják."
|
"hint": "Honnan jönnek az élő YouTube-keresés találatai. A „Weboldal” a YouTube saját keresővégpontját olvassa, és nem fogyaszt API-kvótát (ajánlott). A „Hivatalos API” a search.list-et használja (100 egység/oldal) — válts rá, ha a weboldalas forrás elromlik vagy letiltják."
|
||||||
},
|
},
|
||||||
"backfill_recent_max_videos": {
|
"backfill_recent_max_videos": {
|
||||||
"label": "Friss letöltés — max videó",
|
"label": "Friss behúzás — max videó",
|
||||||
"hint": "Csatornánként az első körben letöltött legújabb videók száma."
|
"hint": "Csatornánként az első körben behúzott legújabb videók száma."
|
||||||
},
|
},
|
||||||
"backfill_recent_max_days": {
|
"backfill_recent_max_days": {
|
||||||
"label": "Friss letöltés — max kor (nap)",
|
"label": "Friss behúzás — max kor (nap)",
|
||||||
"hint": "Az első kör csatornánként meddig nyúl vissza."
|
"hint": "Az első kör csatornánként meddig nyúl vissza."
|
||||||
},
|
},
|
||||||
"rss_fallback_hours": {
|
"rss_fallback_hours": {
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
"hint": "Futásonként ennyi videót osztályozunk."
|
"hint": "Futásonként ennyi videót osztályozunk."
|
||||||
},
|
},
|
||||||
"enrich_batch_size": {
|
"enrich_batch_size": {
|
||||||
"label": "Gazdagítási kötegméret",
|
"label": "Dúsítási kötegméret",
|
||||||
"hint": "videos.list azonosítók hívásonként (a YouTube 50-ben maximálja)."
|
"hint": "videos.list azonosítók hívásonként (a YouTube 50-ben maximálja)."
|
||||||
},
|
},
|
||||||
"autotag_title_sample": {
|
"autotag_title_sample": {
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
},
|
},
|
||||||
"youtube_api_key": {
|
"youtube_api_key": {
|
||||||
"label": "YouTube API-kulcs",
|
"label": "YouTube API-kulcs",
|
||||||
"hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott letöltés ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható."
|
"hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott behúzás ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható."
|
||||||
},
|
},
|
||||||
"youtube_api_proxy": {
|
"youtube_api_proxy": {
|
||||||
"label": "YouTube API kimenő proxy",
|
"label": "YouTube API kimenő proxy",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"link": "Ez a megosztási link már nem létezik",
|
"link": "Ez a megosztási link már nem létezik",
|
||||||
"notification": "Ez az értesítés már nem létezik",
|
"notification": "Ez az értesítés már nem létezik",
|
||||||
"account": "Ez a fiók már nem létezik",
|
"account": "Ez a fiók már nem létezik",
|
||||||
"collection": "Ez a gyűjtemény már nem létezik",
|
"collection": "Ez a kollekció már nem létezik",
|
||||||
"config_key": "Nincs ilyen beállítás",
|
"config_key": "Nincs ilyen beállítás",
|
||||||
"file": "A fájl már nem érhető el",
|
"file": "A fájl már nem érhető el",
|
||||||
"filmstrip": "Ehhez nincs képsor",
|
"filmstrip": "Ehhez nincs képsor",
|
||||||
@@ -128,9 +128,9 @@
|
|||||||
"already_subscribed": "Erre a csatornára már feliratkoztál",
|
"already_subscribed": "Erre a csatornára már feliratkoztál",
|
||||||
"channel_blocked": "Ezt a csatornát letiltottad",
|
"channel_blocked": "Ezt a csatornát letiltottad",
|
||||||
"collection_fields_required": "A címet és egy elemet is meg kell adni",
|
"collection_fields_required": "A címet és egy elemet is meg kell adni",
|
||||||
"collection_pending_sync": "A gyűjtemény a Plexben megváltozott, de a helyi másolat frissítése nem sikerült — a következő szinkron pótolja",
|
"collection_pending_sync": "A kollekció a Plexben megváltozott, de a helyi másolat frissítése nem sikerült — a következő szinkron pótolja",
|
||||||
"collection_read_only": "Ez a gyűjtemény csak olvasható",
|
"collection_read_only": "Ez a kollekció csak olvasható",
|
||||||
"collection_smart": "Az okos és az automatikus listás gyűjtemények nem tehetők szerkeszthetővé",
|
"collection_smart": "Az okos és az automatikus listás kollekciók nem tehetők szerkeszthetővé",
|
||||||
"demo_activate": "A demó fiók nem aktiválható",
|
"demo_activate": "A demó fiók nem aktiválható",
|
||||||
"demo_delete": "A demó fiók nem törölhető",
|
"demo_delete": "A demó fiók nem törölhető",
|
||||||
"demo_role": "A demó fiók szerepköre nem módosítható",
|
"demo_role": "A demó fiók szerepköre nem módosítható",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
"countTooltip": "A feliratkozásaid a teljes megosztott katalógushoz képest.",
|
"countTooltip": "A feliratkozásaid a teljes megosztott katalógushoz képest.",
|
||||||
"paused": "szüneteltetve",
|
"paused": "szüneteltetve",
|
||||||
"syncing": "{{count}} szinkronizálás alatt",
|
"syncing": "{{count}} szinkronizálás alatt",
|
||||||
"backfillingHistory": "előzmény letöltése",
|
"backfillingHistory": "előzmény behúzása",
|
||||||
"working": "szinkronizálás…",
|
"working": "szinkronizálás…",
|
||||||
"recentQueued": "{{count}} sorban",
|
"recentQueued": "{{count}} sorban",
|
||||||
"allSynced": "minden szinkronban",
|
"allSynced": "minden szinkronban",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
},
|
},
|
||||||
"jobDone": {
|
"jobDone": {
|
||||||
"titleOk": "{{job}} kész",
|
"titleOk": "{{job}} kész",
|
||||||
|
"titleSkipped": "{{job}} kihagyva",
|
||||||
"titleError": "Nem sikerült lefuttatni: {{job}}"
|
"titleError": "Nem sikerült lefuttatni: {{job}}"
|
||||||
},
|
},
|
||||||
"emailUndelivered": {
|
"emailUndelivered": {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"nameTaken": "Már létezik ilyen nevű kollekció",
|
"nameTaken": "Már létezik ilyen nevű kollekció",
|
||||||
"newHint": "Válaszd ki az első címet — egy kollekcióhoz legalább egy elem kell.",
|
"newHint": "Válaszd ki az első címet — egy kollekcióhoz legalább egy elem kell.",
|
||||||
"createWith": "Létrehozás",
|
"createWith": "Létrehozás",
|
||||||
"deleteFailed": "Nem sikerült törölni a gyűjteményt"
|
"deleteFailed": "Nem sikerült törölni a kollekciót"
|
||||||
},
|
},
|
||||||
"searchPlaceholder": "Keresés a Plexben…",
|
"searchPlaceholder": "Keresés a Plexben…",
|
||||||
"searchField": {
|
"searchField": {
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
"movie": "Filmek",
|
"movie": "Filmek",
|
||||||
"show": "Sorozatok",
|
"show": "Sorozatok",
|
||||||
"playlists": "Listák",
|
"playlists": "Listák",
|
||||||
"collections": "Gyűjtemények"
|
"collections": "Kollekciók"
|
||||||
},
|
},
|
||||||
"show": "Megjelenítés",
|
"show": "Megjelenítés",
|
||||||
"showOpt": {
|
"showOpt": {
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
"playable": "Lejátszás",
|
"playable": "Lejátszás",
|
||||||
"playableOpt": {
|
"playableOpt": {
|
||||||
"direct": "Közvetlen lejátszás",
|
"direct": "Közvetlen lejátszás",
|
||||||
"remux": "Közvetlen lejátszás",
|
"remux": "Közvetlen közvetítés",
|
||||||
"transcode_hw": "Átkódolás · HW",
|
"transcode_hw": "Átkódolás · HW",
|
||||||
"transcode_sw": "Átkódolás · SW"
|
"transcode_sw": "Átkódolás · SW"
|
||||||
}
|
}
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
"subtitles": "Felirat",
|
"subtitles": "Felirat",
|
||||||
"subOff": "Ki",
|
"subOff": "Ki",
|
||||||
"errNotFound": "Ez a fájl nem játszható le — a média nem érhető el a szerveren (hiányzik a Plex media-mount, vagy hibás az útvonal-leképezés). Kérd meg az adminisztrátort, hogy ellenőrizze a Plex beállításait.",
|
"errNotFound": "Ez a fájl nem játszható le — a média nem érhető el a szerveren (hiányzik a Plex media-mount, vagy hibás az útvonal-leképezés). Kérd meg az adminisztrátort, hogy ellenőrizze a Plex beállításait.",
|
||||||
"errTranscode": "Ennek a fájlnak a formátuma transzkódolást igényel, ami még nem támogatott.",
|
"errTranscode": "Ennek a fájlnak a formátuma átkódolást igényel, ami még nem támogatott.",
|
||||||
"errGeneric": "Nem sikerült elindítani a lejátszást. Próbáld újra.",
|
"errGeneric": "Nem sikerült elindítani a lejátszást. Próbáld újra.",
|
||||||
"help": "Billentyűparancsok",
|
"help": "Billentyűparancsok",
|
||||||
"infoBtn": "Média infó",
|
"infoBtn": "Média infó",
|
||||||
@@ -288,7 +288,7 @@
|
|||||||
},
|
},
|
||||||
"playable": {
|
"playable": {
|
||||||
"direct": "Közvetlenül játszható a böngészőben",
|
"direct": "Közvetlenül játszható a böngészőben",
|
||||||
"remux": "Közvetlen lejátszás — könnyű remux (nincs videó-újrakódolás)",
|
"remux": "Közvetlen közvetítés — könnyű remux (nincs videó-újrakódolás)",
|
||||||
"transcode_hw": "Hardveres kódolóval átkódolva",
|
"transcode_hw": "Hardveres kódolóval átkódolva",
|
||||||
"transcode_sw": "Szoftveresen átkódolva (legnehezebb)"
|
"transcode_sw": "Szoftveresen átkódolva (legnehezebb)"
|
||||||
},
|
},
|
||||||
@@ -320,7 +320,6 @@
|
|||||||
"removeShow": "Egész sorozat eltávolítása",
|
"removeShow": "Egész sorozat eltávolítása",
|
||||||
"removeSeason": "Egész évad eltávolítása",
|
"removeSeason": "Egész évad eltávolítása",
|
||||||
"episodes": "{{count}} epizód",
|
"episodes": "{{count}} epizód",
|
||||||
"season": "{{n}}. évad",
|
|
||||||
"seasonUnknown": "Epizódok",
|
"seasonUnknown": "Epizódok",
|
||||||
"addShow": "Egész sorozat listához adása",
|
"addShow": "Egész sorozat listához adása",
|
||||||
"addSeason": "Évad listához adása",
|
"addSeason": "Évad listához adása",
|
||||||
|
|||||||
@@ -151,10 +151,10 @@
|
|||||||
"shows": "sorozat",
|
"shows": "sorozat",
|
||||||
"seasons": "évad",
|
"seasons": "évad",
|
||||||
"episodes": "epizód",
|
"episodes": "epizód",
|
||||||
"collections": "gyűjtemény",
|
"collections": "kollekció",
|
||||||
"overlaid": "magyar átfedés",
|
"overlaid": "magyar átfedés",
|
||||||
"pruned": "eltávolítva",
|
"pruned": "eltávolítva",
|
||||||
"people_enriched": "kiegészített személy",
|
"people_enriched": "dúsított személy",
|
||||||
"media_probed": "vizsgált média",
|
"media_probed": "vizsgált média",
|
||||||
"people_pruned": "eltávolított személy",
|
"people_pruned": "eltávolított személy",
|
||||||
"error": "hiba",
|
"error": "hiba",
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
"applied": "alkalmazva",
|
"applied": "alkalmazva",
|
||||||
"cleared": "törölve",
|
"cleared": "törölve",
|
||||||
"scanned": "átvizsgálva",
|
"scanned": "átvizsgálva",
|
||||||
"enriched": "kiegészítve",
|
"enriched": "dúsítva",
|
||||||
"countries_coded": "országkód pótolva",
|
"countries_coded": "országkód pótolva",
|
||||||
"sessions_closed": "lezárt munkamenet",
|
"sessions_closed": "lezárt munkamenet",
|
||||||
"sessions_reaped": "eltakarított munkamenet",
|
"sessions_reaped": "eltakarított munkamenet",
|
||||||
@@ -182,6 +182,9 @@
|
|||||||
"tmdbapikeynotset": "nincs TMDB API-kulcs",
|
"tmdbapikeynotset": "nincs TMDB API-kulcs",
|
||||||
"nodemoaccount": "nincs demófiók",
|
"nodemoaccount": "nincs demófiók",
|
||||||
"noserviceuser": "nincs szolgáltatásfiók",
|
"noserviceuser": "nincs szolgáltatásfiók",
|
||||||
"syncpaused": "a szinkron szünetel"
|
"syncpaused": "a szinkron szünetel",
|
||||||
|
"lowquota": "kevés a kvóta",
|
||||||
|
"noreadscope": "nincs YouTube-olvasási jogosultság",
|
||||||
|
"busy": "egy másik szinkron fut"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,10 +185,10 @@
|
|||||||
"delete": "Törlés",
|
"delete": "Törlés",
|
||||||
"deleteLabel": "{{email}} törlése a fehérlistáról",
|
"deleteLabel": "{{email}} törlése a fehérlistáról",
|
||||||
"deleteHint": "A cím elfelejtése. Egy későbbi kérelme új kérelemként jelenik meg.",
|
"deleteHint": "A cím elfelejtése. Egy későbbi kérelme új kérelemként jelenik meg.",
|
||||||
"deleteTitle": "Törlöd a whitelista-bejegyzést?",
|
"deleteTitle": "Törlöd a fehérlista-bejegyzést?",
|
||||||
"deleteConfirm": "Elfelejted {{email}} címet? A Siftlode eldobja a bejegyzést, így az adott címről újra kérelmezni kell.",
|
"deleteConfirm": "Elfelejted {{email}} címet? A Siftlode eldobja a bejegyzést, így az adott címről újra kérelmezni kell.",
|
||||||
"deleteHasAccount": "Erre a címre már regisztrált valaki — a Felhasználók és szerepek fülön kezelhető, ahol a fiók törlése ezt a bejegyzést is eltávolítja.",
|
"deleteHasAccount": "Erre a címre már regisztrált valaki — a Felhasználók és szerepek fülön kezelhető, ahol a fiók törlése ezt a bejegyzést is eltávolítja.",
|
||||||
"deleted": "Whitelista-bejegyzés törölve",
|
"deleted": "Fehérlista-bejegyzés törölve",
|
||||||
"deleteFailed": "Nem sikerült törölni a bejegyzést",
|
"deleteFailed": "Nem sikerült törölni a bejegyzést",
|
||||||
"emailOff": "Az e-mail-küldés nincs beállítva, így senki nem kap megerősítő linket, és e-mailben egy kérelem sem jut el hozzád. Ezt a listát és az Értesítéseket figyeld helyette, a javítás pedig a Konfiguráció → E-mail / SMTP fülön van.",
|
"emailOff": "Az e-mail-küldés nincs beállítva, így senki nem kap megerősítő linket, és e-mailben egy kérelem sem jut el hozzád. Ezt a listát és az Értesítéseket figyeld helyette, a javítás pedig a Konfiguráció → E-mail / SMTP fülön van.",
|
||||||
"deletedStillAllowed": "A bejegyzés törölve, de ezt a címet a szerver konfigurációja továbbra is engedélyezi, tehát megmarad a hozzáférése. Vedd ki az ALLOWED_EMAILS / ADMIN_EMAILS listából, ha ezen változtatnál.",
|
"deletedStillAllowed": "A bejegyzés törölve, de ezt a címet a szerver konfigurációja továbbra is engedélyezi, tehát megmarad a hozzáférése. Vedd ki az ALLOWED_EMAILS / ADMIN_EMAILS listából, ha ezen változtatnál.",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"rangeLabel": "Időtartomány",
|
"rangeLabel": "Időtartomány",
|
||||||
"introBefore": "A YouTube Data API-egységek aszerint, hogy ki váltotta ki a felhasználást. A ",
|
"introBefore": "A YouTube Data API-egységek aszerint, hogy ki váltotta ki a felhasználást. A ",
|
||||||
"introSystem": "Rendszer",
|
"introSystem": "Rendszer",
|
||||||
"introAfter": " a megosztott háttérmunka (ütemezett letöltés, gazdagítás, újraszinkronizálás), amely nem köthető egy személyhez.",
|
"introAfter": " a megosztott háttérmunka (ütemezett behúzás, dúsítás, újraszinkronizálás), amely nem köthető egy személyhez.",
|
||||||
"loading": "Betöltés…",
|
"loading": "Betöltés…",
|
||||||
"noData": "Nincs adat.",
|
"noData": "Nincs adat.",
|
||||||
"dailyTotal": "Napi összesen ({{d}} · {{units}} egység)",
|
"dailyTotal": "Napi összesen ({{d}} · {{units}} egység)",
|
||||||
@@ -22,13 +22,13 @@
|
|||||||
"recentSynced": "Friss szinkronizálva",
|
"recentSynced": "Friss szinkronizálva",
|
||||||
"recentSyncedHint": "Azok a csatornák, amelyek legutóbbi feltöltései (~az elmúlt év) már a helyi adatbázisban vannak, így megjelennek a Feededben.",
|
"recentSyncedHint": "Azok a csatornák, amelyek legutóbbi feltöltései (~az elmúlt év) már a helyi adatbázisban vannak, így megjelennek a Feededben.",
|
||||||
"fullHistory": "Teljes előzmény",
|
"fullHistory": "Teljes előzmény",
|
||||||
"fullHistoryHint": "Azok a csatornák, amelyek teljes archívuma letöltődött, azokból, amelyekhez teljes előzményt kértél (csatornánként választható a Csatornák oldalon).",
|
"fullHistoryHint": "Azok a csatornák, amelyek teljes archívuma be van húzva, azokból, amelyekhez teljes előzményt kértél (csatornánként választható a Csatornák oldalon).",
|
||||||
"fullHistoryEta": "Teljes előzmény becsült ideje",
|
"fullHistoryEta": "Teljes előzmény becsült ideje",
|
||||||
"fullHistoryEtaHint": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének letöltéséhez, a megosztott napi kvóta alapján.",
|
"fullHistoryEtaHint": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének behúzásához, a megosztott napi kvóta alapján.",
|
||||||
"myVideos": "Saját videók",
|
"myVideos": "Saját videók",
|
||||||
"myVideosHint": "A számodra elérhető videók összesen a feliratkozott csatornáidon.",
|
"myVideosHint": "A számodra elérhető videók összesen a feliratkozott csatornáidon.",
|
||||||
"quotaLeft": "Ma maradt kvóta",
|
"quotaLeft": "Ma maradt kvóta",
|
||||||
"quotaLeftHint": "A ma fennmaradó megosztott YouTube API-keret (éjfélkor nullázódik, USA csendes-óceáni idő szerint). A letöltés ennek alárendelődik.",
|
"quotaLeftHint": "A ma fennmaradó megosztott YouTube API-keret (éjfélkor nullázódik, USA csendes-óceáni idő szerint). A behúzás ennek alárendelődik.",
|
||||||
"loading": "Betöltés…",
|
"loading": "Betöltés…",
|
||||||
"apiUsage": "Saját API-használat",
|
"apiUsage": "Saját API-használat",
|
||||||
"today": "Ma",
|
"today": "Ma",
|
||||||
@@ -41,8 +41,8 @@
|
|||||||
"actions": "Műveletek",
|
"actions": "Műveletek",
|
||||||
"syncSubscriptions": "Feliratkozások szinkronizálása a YouTube-ról",
|
"syncSubscriptions": "Feliratkozások szinkronizálása a YouTube-ról",
|
||||||
"syncSubscriptionsHint": "Újraimportálja a YouTube-feliratkozásaidat és behúzza az újonnan követett csatornákat.",
|
"syncSubscriptionsHint": "Újraimportálja a YouTube-feliratkozásaidat és behúzza az újonnan követett csatornákat.",
|
||||||
"backfillEverything": "Minden letöltése",
|
"backfillEverything": "Minden videó behúzása",
|
||||||
"backfillEverythingHint": "Teljes archívum letöltésének kérése minden csatornához, amelyre fel vagy iratkozva. A régebbi videók és a teljes keresés annyira töltődnek fel, amennyit a megosztott napi kvóta enged.",
|
"backfillEverythingHint": "A teljes archívum behúzásának kérése minden csatornához, amelyre fel vagy iratkozva. A régebbi videók és a teljes keresés annyira töltődnek fel, amennyit a megosztott napi kvóta enged.",
|
||||||
"synced": "{{count}} feliratkozás szinkronizálva",
|
"synced": "{{count}} feliratkozás szinkronizálva",
|
||||||
"syncFailed": "Nem sikerült szinkronizálni a feliratkozásokat",
|
"syncFailed": "Nem sikerült szinkronizálni a feliratkozásokat",
|
||||||
"fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához",
|
"fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához",
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
"lastLogin": "Utolsó belépés",
|
"lastLogin": "Utolsó belépés",
|
||||||
"lastLoginNone": "még nincs rögzítve",
|
"lastLoginNone": "még nincs rögzítve",
|
||||||
"lastLoginDemo": "a demó fióknál nem rögzítjük",
|
"lastLoginDemo": "a demó fióknál nem rögzítjük",
|
||||||
"inviteMissing": "ez a cím nincs az engedélyezési listán",
|
"inviteMissing": "ez a cím nincs a fehérlistán",
|
||||||
"linkNote": "24 óráig érvényes, egyszer használható. Aki megnyitja, beállíthat egy jelszót, és ezzel kilép mindenki, aki eddig használta a fiókot. A címet nem erősíti meg.",
|
"linkNote": "24 óráig érvényes, egyszer használható. Aki megnyitja, beállíthat egy jelszót, és ezzel kilép mindenki, aki eddig használta a fiókot. A címet nem erősíti meg.",
|
||||||
"mailOff": "nincs beállítva levelezés",
|
"mailOff": "nincs beállítva levelezés",
|
||||||
"open": "Fiókpanel megnyitása",
|
"open": "Fiókpanel megnyitása",
|
||||||
|
|||||||
@@ -8,39 +8,44 @@ describe("placeByPriority", () => {
|
|||||||
const list = [row("a", 10), row("b", 5), row("c", 5), row("d", 0), row("e", 0)];
|
const list = [row("a", 10), row("b", 5), row("c", 5), row("d", 0), row("e", 0)];
|
||||||
|
|
||||||
it("bubbles a raised channel up to the first row it now outranks", () => {
|
it("bubbles a raised channel up to the first row it now outranks", () => {
|
||||||
expect(ids(placeByPriority([...list.slice(0, 4), { ...list[4]!, priority: 5 }], "e"))).toEqual([
|
expect(
|
||||||
"a",
|
ids(placeByPriority([...list.slice(0, 4), { ...list[4]!, priority: 5 }], "e", "en")),
|
||||||
"b",
|
).toEqual(["a", "b", "c", "e", "d"]);
|
||||||
"c",
|
|
||||||
"e",
|
|
||||||
"d",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sinks a lowered channel below every row it no longer outranks", () => {
|
it("sinks a lowered channel below every row it no longer outranks", () => {
|
||||||
expect(ids(placeByPriority([{ ...list[0]!, priority: 0 }, ...list.slice(1)], "a"))).toEqual([
|
expect(
|
||||||
"b",
|
ids(placeByPriority([{ ...list[0]!, priority: 0 }, ...list.slice(1)], "a", "en")),
|
||||||
"c",
|
).toEqual(["b", "c", "a", "d", "e"]);
|
||||||
"a",
|
|
||||||
"d",
|
|
||||||
"e",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("orders equal priorities by title, case-folded, a missing title last", () => {
|
it("orders equal priorities by title, case-folded, a missing title last", () => {
|
||||||
const rows = [row("x", 3, "beta"), row("y", 3, null), row("z", 3, "Alpha")];
|
const rows = [row("x", 3, "beta"), row("y", 3, null), row("z", 3, "Alpha")];
|
||||||
expect(ids(placeByPriority(rows, "z"))).toEqual(["z", "x", "y"]);
|
expect(ids(placeByPriority(rows, "z", "en"))).toEqual(["z", "x", "y"]);
|
||||||
expect(ids(placeByPriority([row("y", 3, null), row("x", 3, "beta")], "y"))).toEqual(["x", "y"]);
|
expect(ids(placeByPriority([row("y", 3, null), row("x", 3, "beta")], "y", "en"))).toEqual([
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves every other row where it was, even ones out of the server's order", () => {
|
it("leaves every other row where it was, even ones out of the server's order", () => {
|
||||||
const rows = [row("p", 0), row("q", 9), row("r", 2)];
|
const rows = [row("p", 0), row("q", 9), row("r", 2)];
|
||||||
// r lands before the first row it outranks; p and q keep their (unsorted) order.
|
// r lands before the first row it outranks; p and q keep their (unsorted) order.
|
||||||
expect(ids(placeByPriority(rows, "r"))).toEqual(["r", "p", "q"]);
|
expect(ids(placeByPriority(rows, "r", "en"))).toEqual(["r", "p", "q"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an unknown id's list unchanged", () => {
|
it("returns an unknown id's list unchanged", () => {
|
||||||
expect(ids(placeByPriority(list, "nope"))).toEqual(ids(list));
|
expect(ids(placeByPriority(list, "nope", "en"))).toEqual(ids(list));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places a title in the reader's alphabet, as the server's collation does (E30 review)", () => {
|
||||||
|
const rows = [row("e", 0, "Ecset"), row("f", 0, "Fa"), row("m", 5, "Életrajz")];
|
||||||
|
const moved = [...rows.slice(0, 2), { ...rows[2]!, priority: 0 }];
|
||||||
|
expect(ids(placeByPriority(moved, "m", "hu"))).toEqual(["e", "m", "f"]);
|
||||||
|
const cs = [row("u", 0, "Cukor"), row("x", 0, "Dob"), row("c", 5, "Csiga")];
|
||||||
|
expect(
|
||||||
|
ids(placeByPriority([...cs.slice(0, 2), { ...cs[2]!, priority: 0 }], "c", "hu")),
|
||||||
|
).toEqual(["u", "c", "x"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { compareText } from "./collate";
|
||||||
|
|
||||||
// The channel manager's list order, and the priority steps its menu offers (E28 S3e3b).
|
// The channel manager's list order, and the priority steps its menu offers (E28 S3e3b).
|
||||||
|
|
||||||
/** The steps a channel's menu offers (owner, 2026-09-26): fixed levels, so bubbling a channel up
|
/** The steps a channel's menu offers (owner, 2026-09-26): fixed levels, so bubbling a channel up
|
||||||
@@ -19,23 +21,27 @@ interface Ordered {
|
|||||||
title?: string | null;
|
title?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The server's order (`routes/channels.py`: priority desc, then the title case-folded, a missing
|
/** The server's order (`routes/channels.py`: priority desc, then the title in the reader's alphabet —
|
||||||
* title last). */
|
* the ICU collation of `app/collation.py`, which `compareText` shares — a missing title last). */
|
||||||
function before(a: Ordered, b: Ordered): boolean {
|
function before(a: Ordered, b: Ordered, lang: string): boolean {
|
||||||
if (a.priority !== b.priority) return a.priority > b.priority;
|
if (a.priority !== b.priority) return a.priority > b.priority;
|
||||||
if (a.title == null) return false;
|
if (a.title == null) return false;
|
||||||
if (b.title == null) return true;
|
if (b.title == null) return true;
|
||||||
return a.title.toLowerCase() < b.title.toLowerCase();
|
return compareText(a.title, b.title, lang) < 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `rows` with the channel `id` taken out and put back where its priority now places it, every
|
/** `rows` with the channel `id` taken out and put back where its priority now places it, every
|
||||||
* other row left exactly where it was — the server's order, without a refetch that would reorder
|
* other row left exactly where it was — the server's order, without a refetch that would reorder
|
||||||
* anything the reader did not touch. */
|
* anything the reader did not touch. */
|
||||||
export function placeByPriority<T extends Ordered>(rows: readonly T[], id: string): T[] {
|
export function placeByPriority<T extends Ordered>(
|
||||||
|
rows: readonly T[],
|
||||||
|
id: string,
|
||||||
|
lang: string,
|
||||||
|
): T[] {
|
||||||
const moved = rows.find((r) => r.id === id);
|
const moved = rows.find((r) => r.id === id);
|
||||||
if (!moved) return [...rows];
|
if (!moved) return [...rows];
|
||||||
const rest = rows.filter((r) => r !== moved);
|
const rest = rows.filter((r) => r !== moved);
|
||||||
const at = rest.findIndex((r) => before(moved, r));
|
const at = rest.findIndex((r) => before(moved, r, lang));
|
||||||
rest.splice(at < 0 ? rest.length : at, 0, moved);
|
rest.splice(at < 0 ? rest.length : at, 0, moved);
|
||||||
return rest;
|
return rest;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ describe("a job's result", () => {
|
|||||||
it("names a skip reason; an upstream error stays its own text", () => {
|
it("names a skip reason; an upstream error stays its own text", () => {
|
||||||
const t = i18n.getFixedT("hu");
|
const t = i18n.getFixedT("hu");
|
||||||
expect(jobResultText(t, { enriched: 0, skipped: "tmdb_api_key not set" }, "hu")).toBe(
|
expect(jobResultText(t, { enriched: 0, skipped: "tmdb_api_key not set" }, "hu")).toBe(
|
||||||
"kiegészítve: 0 · kihagyva: nincs TMDB API-kulcs",
|
"dúsítva: 0 · kihagyva: nincs TMDB API-kulcs",
|
||||||
);
|
);
|
||||||
expect(jobResultText(t, { error: "Plex returned 500" }, "hu")).toBe("hiba: Plex returned 500");
|
expect(jobResultText(t, { error: "Plex returned 500" }, "hu")).toBe("hiba: Plex returned 500");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,3 +37,34 @@ export function jobResultText(t: TFunction, result: JobResult, lang: string): st
|
|||||||
})
|
})
|
||||||
.join(" · ");
|
.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a job run said, as the inbox notice and the audit row both show it (E30): a title naming the job
|
||||||
|
* and how the run ended, and its result in the reader's language — from the data when it rode along,
|
||||||
|
* else read back from the English one-liner. A run skipped for a pause names why; a failed run keeps the
|
||||||
|
* upstream's own error text (it is diagnostic). */
|
||||||
|
export function jobRunText(
|
||||||
|
t: TFunction,
|
||||||
|
run: { job_id: string; status?: unknown; summary?: unknown; result?: unknown },
|
||||||
|
lang: string,
|
||||||
|
): { title: string; body: string | null } {
|
||||||
|
const job = t(`scheduler.jobs.${run.job_id}`, run.job_id);
|
||||||
|
const summary = typeof run.summary === "string" && run.summary ? run.summary : null;
|
||||||
|
const titleKey =
|
||||||
|
run.status === "error"
|
||||||
|
? "inbox.jobDone.titleError"
|
||||||
|
: run.status === "skipped"
|
||||||
|
? "inbox.jobDone.titleSkipped"
|
||||||
|
: "inbox.jobDone.titleOk";
|
||||||
|
let result: JobResult | null = null;
|
||||||
|
if (run.status !== "error") {
|
||||||
|
result =
|
||||||
|
run.result && typeof run.result === "object"
|
||||||
|
? (run.result as JobResult)
|
||||||
|
: (parseSummary(summary) ??
|
||||||
|
(run.status === "skipped" && summary ? { skipped: summary } : null));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: t(titleKey, { job }),
|
||||||
|
body: result ? jobResultText(t, result, lang) : summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ describe("a px-frozen box utility", () => {
|
|||||||
expect(font?.messages.some((m) => m.message.startsWith("px-frozen font size"))).toBe(true);
|
expect(font?.messages.some((m) => m.message.startsWith("px-frozen font size"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("is refused as a grid column's minimum (E30 review)", async () => {
|
||||||
|
const mm = (n: string) => `grid-cols-[repeat(auto-fill,minmax(${n},1fr))]`;
|
||||||
|
expect(await refused(`export const g = "grid ${mm("150px")}";\n`)).toBe(1);
|
||||||
|
expect(await refused(`export const h = "grid ${mm("9.375rem")}";\n`)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves the spacing scale, a rem arbitrary and a grid track alone", async () => {
|
it("leaves the spacing scale, a rem arbitrary and a grid track alone", async () => {
|
||||||
expect(await refused('export const c = "w-37.5 max-w-60 text-[0.75rem]";\n')).toBe(0);
|
expect(await refused('export const c = "w-37.5 max-w-60 text-[0.75rem]";\n')).toBe(0);
|
||||||
expect(await refused('export const d = "grid-cols-[1fr_240px]";\n')).toBe(0);
|
expect(await refused('export const d = "grid-cols-[1fr_240px]";\n')).toBe(0);
|
||||||
|
|||||||
@@ -161,3 +161,17 @@ export const qk = {
|
|||||||
plexPerson: (name: string, lang?: string) =>
|
plexPerson: (name: string, lang?: string) =>
|
||||||
["plex-person", name, ...(lang ? [lang] : [])] as const,
|
["plex-person", name, ...(lang ? [lang] : [])] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The lists the SERVER puts in the reader's alphabet from the language stored in their preferences
|
||||||
|
* (backend `lang_for_user` → `app/collation.py`), so their keys carry no language. Once a language
|
||||||
|
* switch has been saved they are asked for again (App's `changeLanguage`): they re-sort, and a
|
||||||
|
* title-sorted feed never reads its next page under the other alphabet's cursor (E30 review). */
|
||||||
|
export const LANGUAGE_ORDERED = [
|
||||||
|
qk.feed(),
|
||||||
|
qk.channels(),
|
||||||
|
qk.discoveredChannels(),
|
||||||
|
qk.blockedChannels(),
|
||||||
|
qk.shareRecipients(),
|
||||||
|
qk.messageUsers(),
|
||||||
|
qk.plexCollections(),
|
||||||
|
] as const;
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// E30 review: the server orders some lists by the reader's STORED language (their keys carry none), so a
|
||||||
|
// switch must re-ask for them — after the save lands, not before (the server would sort in the old one).
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "./api";
|
||||||
|
import { LANGUAGE_ORDERED, qk } from "./queryKeys";
|
||||||
|
import { saveLanguage } from "./saveLanguage";
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
describe("saveLanguage", () => {
|
||||||
|
it("asks again for the server-ordered lists once the save has landed", async () => {
|
||||||
|
let land!: () => void;
|
||||||
|
vi.spyOn(api, "savePrefs").mockReturnValue(
|
||||||
|
new Promise<never>((r) => (land = r as () => void)) as never,
|
||||||
|
);
|
||||||
|
const qc = new QueryClient();
|
||||||
|
const asked = vi.spyOn(qc, "invalidateQueries");
|
||||||
|
const done = saveLanguage(qc, "hu");
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(asked).not.toHaveBeenCalled(); // the server still sorts in the old language
|
||||||
|
land();
|
||||||
|
await done;
|
||||||
|
const keys = asked.mock.calls.map((c) => c[0]?.queryKey);
|
||||||
|
expect(keys).toEqual([...LANGUAGE_ORDERED]);
|
||||||
|
expect(keys).toContainEqual(qk.feed());
|
||||||
|
expect(keys).toContainEqual(qk.channels());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the lists alone when the save fails", async () => {
|
||||||
|
vi.spyOn(api, "savePrefs").mockRejectedValue(new Error("offline"));
|
||||||
|
const qc = new QueryClient();
|
||||||
|
const asked = vi.spyOn(qc, "invalidateQueries");
|
||||||
|
await expect(saveLanguage(qc, "hu")).rejects.toThrow("offline");
|
||||||
|
expect(asked).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "./api";
|
||||||
|
import { LANGUAGE_ORDERED } from "./queryKeys";
|
||||||
|
|
||||||
|
/** Store the reader's language choice on the server, then ask again for the lists the server orders
|
||||||
|
* by it (`LANGUAGE_ORDERED`): only once the save has landed does the server sort in the new alphabet.
|
||||||
|
* Rejects with the save's error; the lists are left alone then. */
|
||||||
|
export async function saveLanguage(qc: QueryClient, code: string): Promise<void> {
|
||||||
|
await api.savePrefs({ language: code });
|
||||||
|
for (const queryKey of LANGUAGE_ORDERED) void qc.invalidateQueries({ queryKey });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user