feat: Settings → Diagnostics, a consented log level and the tab's own log

A user can raise their diagnostics level to Részletes (DEBUG) or Teljes (TRACE) for 24 hours from a
new Settings tab, after a consent box that says what is recorded, that the server keeps it and for
how long (the instance's own retention), and that it lapses. Alap is the base and needs no consent.

- users.diagnostics_level/_until (migration 0087), written only by PUT /api/me/diagnostics, which
  computes the expiry itself; the demo account cannot raise it. /api/me reports the level in force.
- The server logs an elevated user's requests at that level and stores every line of them
  (logs.LevelGate on the stdout handler and the sink; the root is lowered only while an elevation
  is active). Teljes adds the redacted query string to the request line.
- POST /api/diagnostics/events stores the browser's log as source=web rows: account from the
  session, body capped while streaming, events/attrs capped, re-redacted, events under the
  consented level dropped, timestamps clamped, 30 batches a minute, 409 once the level lapsed.
- lib/diagnostics.ts applies the level to the tab and streams only what happens after consent, in
  batches, never its own request lines, with a keepalive flush on pagehide; stops on lapse (its
  own timer), on withdrawal and on 409.
- Detail hooks at DEBUG: which control was clicked (test id, icon, app copy in English — never user
  text, input values or a link's query), visibility/bfcache/resize, filter changes
  (lib/filterLog.ts; typed search only at TRACE), player events (lib/mediaLog.ts; media errors are
  warnings at every level). Offline/online at the base level.
- "Saját napló" viewer: live, newest first, level groups with counts, search, row details, copy
  the filtered events as JSON lines, clear.
- Privacy policy and Terms exist in Hungarian and English (a contract test holds both languages to
  the same sections); the policy now describes the server logs and diagnostic logging, self-service
  deletion and password sign-in.

Fixed on the way:
- A sync auth dependency ran logs.set_user on a copied context, so the account never reached a
  handler's own log lines (measured); a per-request mutable scope carries it now.
- The Settings tab rail left a phone no room (clipped labels, buttons over text): below `sm` it is
  a scrolling row above the content.
- PUT /api/me/preferences took a bare dict body; it is a Pydantic model now.
This commit is contained in:
2026-09-17 02:42:12 +02:00
parent a210992bfc
commit 5883ea509d
49 changed files with 2790 additions and 122 deletions
@@ -0,0 +1,130 @@
// @vitest-environment jsdom
//
// E22 S5: Settings → Diagnosztika. Raising the level is a consent, so choosing Részletes or Teljes
// must change NOTHING until the consent box's own button is pressed; going back to Alap needs no
// consent. i18n is a key-passthrough (with the count, so the retention days are visible); the level
// endpoint is stubbed, and `me` lives in a real query cache so a saved level re-renders like in the app.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DiagnosticsLevel, DiagnosticsState, Me } from "../lib/api";
import { qk } from "../lib/queryKeys";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: { count?: number }) =>
opts?.count != null ? `${key}:${opts.count}` : key,
i18n: { language: "en" },
}),
};
});
const setLevel = vi.fn(
(level: DiagnosticsLevel): Promise<{ ok: true; diagnostics: DiagnosticsState }> =>
Promise.resolve({
ok: true,
diagnostics: {
level,
until: level === "INFO" ? null : new Date(Date.now() + 86_400_000).toISOString(),
retention_days: 7,
},
}),
);
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: { ...actual.api, setDiagnosticsLevel: (l: DiagnosticsLevel) => setLevel(l) },
};
});
import DiagnosticsSettings from "./DiagnosticsSettings";
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
function mount(me: Partial<Me>) {
const qc = new QueryClient();
qc.setQueryData(qk.me(), {
id: 1,
is_demo: false,
preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7 },
...me,
});
render(
<QueryClientProvider client={qc}>
<DiagnosticsSettings />
</QueryClientProvider>,
);
}
const levelButton = (name: string) =>
within(screen.getByRole("group", { name: "settings.diagnostics.level" })).getByRole("button", {
name,
});
describe("DiagnosticsSettings", () => {
it("choosing a detailed level asks first and changes nothing until confirmed", async () => {
mount({});
fireEvent.click(levelButton("settings.diagnostics.levels.DEBUG"));
const consent = screen.getByTestId("diagnostics-consent");
expect(consent.textContent).toContain("settings.diagnostics.consent.what.DEBUG");
expect(consent.textContent).toContain("settings.diagnostics.consent.server:7");
expect(setLevel).not.toHaveBeenCalled();
fireEvent.click(
within(consent).getByRole("button", { name: "settings.diagnostics.consent.cancel" }),
);
expect(screen.queryByTestId("diagnostics-consent")).toBeNull();
expect(levelButton("settings.diagnostics.levels.INFO").getAttribute("aria-pressed")).toBe(
"true",
);
expect(setLevel).not.toHaveBeenCalled();
});
it("the consent button raises the level, and the status line offers the way back", async () => {
mount({});
fireEvent.click(levelButton("settings.diagnostics.levels.TRACE"));
fireEvent.click(screen.getByTestId("diagnostics-consent-confirm"));
await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
expect(setLevel).toHaveBeenCalledWith("TRACE");
expect(screen.queryByTestId("diagnostics-consent")).toBeNull();
expect(levelButton("settings.diagnostics.levels.TRACE").getAttribute("aria-pressed")).toBe(
"true",
);
fireEvent.click(screen.getByRole("button", { name: "settings.diagnostics.revert" }));
await waitFor(() => expect(screen.queryByTestId("diagnostics-status")).toBeNull());
expect(setLevel).toHaveBeenLastCalledWith("INFO");
});
it("going back to the base level needs no consent", async () => {
const until = new Date(Date.now() + 3_600_000).toISOString();
mount({ diagnostics: { level: "DEBUG", until, retention_days: 7 } });
fireEvent.click(levelButton("settings.diagnostics.levels.INFO"));
expect(screen.queryByTestId("diagnostics-consent")).toBeNull();
await waitFor(() => expect(setLevel).toHaveBeenCalledWith("INFO"));
});
it("a lapsed level shows as the base level", () => {
const until = new Date(Date.now() - 60_000).toISOString();
mount({ diagnostics: { level: "TRACE", until, retention_days: 7 } });
expect(levelButton("settings.diagnostics.levels.INFO").getAttribute("aria-pressed")).toBe(
"true",
);
expect(screen.queryByTestId("diagnostics-status")).toBeNull();
});
it("the shared demo account gets an explanation instead of the picker", () => {
mount({ is_demo: true });
expect(screen.queryByRole("group", { name: "settings.diagnostics.level" })).toBeNull();
expect(screen.getByText("settings.diagnostics.demo")).toBeTruthy();
});
});
@@ -0,0 +1,159 @@
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type DiagnosticsLevel, type Me } from "../lib/api";
import { levelInForce } from "../lib/diagnostics";
import { qk } from "../lib/queryKeys";
import { useMe } from "../lib/useMe";
import { Button } from "./ui/Button";
import InlineNotice from "./ui/InlineNotice";
import SegmentedToggle from "./ui/SegmentedToggle";
import { Section, SettingRow } from "./ui/form";
import MyLogViewer from "./MyLogViewer";
const LEVELS: readonly DiagnosticsLevel[] = ["INFO", "DEBUG", "TRACE"];
/** Hours and minutes until `until`, refreshed every 30 s — the status line counts down. */
function useRemaining(until: string | null): { h: number; m: number } | null {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!until) return;
const id = window.setInterval(() => setNow(Date.now()), 30_000);
return () => window.clearInterval(id);
}, [until]);
if (!until) return null;
const minutes = Math.max(0, Math.ceil((Date.parse(until) - now) / 60_000));
return { h: Math.floor(minutes / 60), m: minutes % 60 };
}
// Settings → Diagnosztika (E22 S5): the consented diagnostics level and the tab's own log.
//
// There is no separate on/off switch: Alap IS off. Choosing Részletes or Teljes does not change
// anything yet — it opens the consent box under the picker, which says what will be recorded, that
// the server keeps it and for how long, and that it lapses; only its button raises the level.
// Returning to Alap needs no consent and happens at once.
export default function DiagnosticsSettings() {
const { t } = useTranslation();
const me = useMe();
const qc = useQueryClient();
const state = me.diagnostics;
const current = levelInForce(state);
const [pending, setPending] = useState<DiagnosticsLevel | null>(null);
const remaining = useRemaining(current === "INFO" ? null : (state?.until ?? null));
const change = useMutation({
mutationFn: api.setDiagnosticsLevel,
meta: { failTitle: "settings.diagnostics.failed" },
onSuccess: (result) => {
qc.setQueryData<Me | null>(qk.me(), (prev) =>
prev ? { ...prev, diagnostics: result.diagnostics } : prev,
);
setPending(null);
},
});
const choose = (level: DiagnosticsLevel) => {
if (level === "INFO") {
setPending(null);
if (current !== "INFO") change.mutate("INFO");
} else {
setPending(level === current ? null : level);
}
};
const levelName = (level: DiagnosticsLevel) => t(`settings.diagnostics.levels.${level}`);
return (
<>
<Section title={t("settings.diagnostics.title")}>
{me.is_demo ? (
<InlineNotice>{t("settings.diagnostics.demo")}</InlineNotice>
) : (
<SettingRow
label={t("settings.diagnostics.level")}
hint={t("settings.diagnostics.levelHint")}
>
<SegmentedToggle
size="md"
ariaLabel={t("settings.diagnostics.level")}
value={pending ?? current}
onChange={choose}
options={LEVELS.map((level) => ({ value: level, label: levelName(level) }))}
/>
</SettingRow>
)}
{pending && (
<div
data-testid="diagnostics-consent"
className="mt-2 rounded-xl border border-border bg-accent/5 p-3 text-sm"
>
<p className="font-semibold">{t(`settings.diagnostics.consent.title.${pending}`)}</p>
<ul className="mt-1.5 mb-3 list-disc space-y-1 pl-5 text-muted">
<li>{t(`settings.diagnostics.consent.what.${pending}`)}</li>
<li>
{t("settings.diagnostics.consent.server", {
count: state?.retention_days ?? 7,
})}
</li>
<li>{t("settings.diagnostics.consent.never")}</li>
<li>
{t("settings.diagnostics.consent.lapse")}{" "}
<a
href="/privacy#diagnostics"
target="_blank"
rel="noreferrer"
className="text-accent hover:underline"
>
{t("settings.diagnostics.consent.policy")}
</a>
</li>
</ul>
<div className="flex flex-wrap justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setPending(null)}>
{t("settings.diagnostics.consent.cancel")}
</Button>
<Button
size="sm"
data-testid="diagnostics-consent-confirm"
loading={change.isPending}
onClick={() => change.mutate(pending)}
>
{t("settings.diagnostics.consent.confirm")}
</Button>
</div>
</div>
)}
{current !== "INFO" && !pending && remaining && (
<div
data-testid="diagnostics-status"
role="status"
className="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm"
>
<span className="inline-flex items-center gap-2">
<span className="h-2 w-2 shrink-0 rounded-full bg-warn" aria-hidden />
<span>
{t("settings.diagnostics.status", {
level: levelName(current),
time:
remaining.h > 0
? t("settings.diagnostics.hoursMinutes", remaining)
: t("settings.diagnostics.minutes", remaining),
})}
</span>
</span>
<Button variant="link" loading={change.isPending} onClick={() => change.mutate("INFO")}>
{t("settings.diagnostics.revert")}
</Button>
</div>
)}
</Section>
<Section title={t("settings.myLog.title")}>
<MyLogViewer />
</Section>
</>
);
}
@@ -20,6 +20,7 @@ import {
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
import { useUrlMirror } from "../lib/useUrlMirror";
import { accountKey, LS, readJSON, writeJSON } from "../lib/storage";
import { logFilterChange } from "../lib/filterLog";
import { useNavigation } from "./NavigationProvider";
// Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App
@@ -123,6 +124,7 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
const setFilters = useCallback((next: FeedFilters) => {
setFiltersState(next);
logFilterChange("feed", next, next.q);
// Persist under THIS tab's active account so filters don't bleed between accounts.
const key = accountKey(FILTERS_KEY, getActiveAccount());
if (key) writeJSON(key, next);
+12 -3
View File
@@ -1,9 +1,10 @@
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./ui/Modal";
import { api, type DownloadJob } from "../lib/api";
import { jobTitle } from "./downloadRow";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { useMediaLogRef } from "../lib/mediaLog";
// E7 S4b — in-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
@@ -30,6 +31,14 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
// Our self-hosted poster (always same-origin, never expires) is the primary; the source thumbnail is
// the fallback. Same precedence as the library-row thumbnail.
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
const logMedia = useMediaLogRef("library");
const videoRef = useCallback(
(el: HTMLVideoElement | null) => {
exposeVideoToBackgroundPlay(el);
logMedia(el);
},
[logMedia],
);
return (
<Modal title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl">
@@ -42,13 +51,13 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
className="w-40 h-40 rounded-xl object-cover border border-border"
/>
) : null}
<audio src={fileUrl} controls autoPlay className="w-full">
<audio ref={logMedia} src={fileUrl} controls autoPlay className="w-full">
{t("downloads.player.unsupported")}
</audio>
</div>
) : (
<video
ref={exposeVideoToBackgroundPlay}
ref={videoRef}
src={fileUrl}
poster={poster}
controls
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
//
// E22 S5: "Saját napló" shows this tab's own log live, newest first, filters it by level group and
// text, and copies exactly what is filtered (oldest first, one JSON event per line).
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { clear, log, setLevel } from "../lib/log";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: { count?: number }) =>
opts?.count != null ? `${key}:${opts.count}` : key,
}),
};
});
const copied: string[] = [];
vi.mock("../lib/clipboard", () => ({
copyText: (text: string) => {
copied.push(text);
return Promise.resolve(true);
},
}));
import MyLogViewer from "./MyLogViewer";
beforeEach(() => {
setLevel("DEBUG");
log.info("nav.view", "push feed", { page: "feed" });
log.warn("http.request", "GET /api/feed 503", { status: 503 });
log.debug("ui.click", "click feed-refresh", { control: "feed-refresh" });
});
afterEach(() => {
cleanup();
clear();
setLevel("INFO");
copied.length = 0;
});
const rows = () =>
within(screen.getByTestId("my-log-lines"))
.queryAllByRole("button")
.map((b) => b.textContent);
describe("MyLogViewer", () => {
it("lists the tab's events newest first and follows new ones", async () => {
render(<MyLogViewer />);
expect(rows().map((r) => r!.includes("ui.click"))).toEqual([true, false, false]);
await act(async () => {
log.error("app.uncaught", "TypeError: x is undefined");
await new Promise((r) => requestAnimationFrame(() => r(null)));
});
expect(rows()[0]).toContain("app.uncaught");
});
it("filters by level group and by text, and copies only what is shown", async () => {
render(<MyLogViewer />);
fireEvent.click(screen.getByRole("button", { name: /settings\.myLog\.filters\.warning/ }));
expect(rows()).toHaveLength(1);
expect(rows()[0]).toContain("GET /api/feed 503");
fireEvent.click(screen.getByRole("button", { name: /settings\.myLog\.filters\.all/ }));
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "FEED-REFRESH" } });
expect(rows()).toHaveLength(1);
const copy = async () => {
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "settings.myLog.copy" }));
});
return copied[copied.length - 1]!.split("\n").map((l) => JSON.parse(l).event);
};
expect(await copy()).toEqual(["ui.click"]); // only what the search left
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "" } });
expect(await copy()).toEqual(["nav.view", "http.request", "ui.click"]); // oldest first
});
it("opens a row to its details and clears the log", async () => {
render(<MyLogViewer />);
const first = within(screen.getByTestId("my-log-lines")).getAllByRole("button")[0]!;
fireEvent.click(first);
expect(first.getAttribute("aria-expanded")).toBe("true");
expect(screen.getByTestId("my-log-lines").textContent).toContain('"control": "feed-refresh"');
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "settings.myLog.clear" }));
await new Promise((r) => requestAnimationFrame(() => r(null)));
});
expect(rows()).toEqual([]);
expect(screen.getByText("settings.myLog.emptyAll")).toBeTruthy();
});
});
+229
View File
@@ -0,0 +1,229 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Copy, Search } from "lucide-react";
import { copyText } from "../lib/clipboard";
import { clear, events, subscribe, type Level, type LogEvent } from "../lib/log";
import { notify } from "../lib/notifications";
import { Button } from "./ui/Button";
import FilterChipGroup from "./ui/FilterChipGroup";
type Filter = "all" | "error" | "warning" | "info" | "detail";
const FILTER_LEVELS: Record<Exclude<Filter, "all">, readonly Level[]> = {
error: ["ERROR", "CRITICAL"],
warning: ["WARNING"],
info: ["INFO"],
detail: ["DEBUG", "TRACE"],
};
// Rows rendered at once. The ring holds up to 2000 events; drawing all of them on every new event
// would make the settings page the slowest thing in the app while it is watching the app.
const ROWS_SHOWN = 500;
const LEVEL_TAG: Record<Level, { text: string; className: string }> = {
TRACE: { text: "TRACE", className: "text-muted opacity-80" },
DEBUG: { text: "DEBUG", className: "text-muted" },
INFO: { text: "INFO", className: "text-info" },
WARNING: { text: "WARN", className: "text-warn" },
ERROR: { text: "ERROR", className: "text-danger" },
CRITICAL: { text: "FATAL", className: "text-danger" },
};
/** The ring, live: re-read at most once per animation frame however fast events arrive. */
function useLogEvents(): LogEvent[] {
const [list, setList] = useState(events);
useEffect(() => {
let frame: number | null = null;
const unsubscribe = subscribe(() => {
if (frame !== null) return;
frame = window.requestAnimationFrame(() => {
frame = null;
setList(events());
});
});
setList(events()); // whatever arrived between the first render and the subscription
return () => {
unsubscribe();
if (frame !== null) window.cancelAnimationFrame(frame);
};
}, []);
return list;
}
const matches = (event: LogEvent, needle: string) =>
!needle ||
[event.event, event.msg, event.request_id ?? "", event.attrs ? JSON.stringify(event.attrs) : ""]
.join(" ")
.toLowerCase()
.includes(needle);
const clock = (ts: string) => {
const d = new Date(ts);
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
};
// "Saját napló" — this tab's own client log (lib/log.ts), exactly what it would send while a raised
// diagnostics level is on: newest first, filtered by level group and free text, a row opens to its
// correlation id and attributes, and "Copy" puts the FILTERED events on the clipboard as JSON lines
// (one event per line) for a bug report.
export default function MyLogViewer() {
const { t } = useTranslation();
const all = useLogEvents();
const [filter, setFilter] = useState<Filter>("all");
const [query, setQuery] = useState("");
const [open, setOpen] = useState<LogEvent | null>(null);
const counts = useMemo(() => {
const out: Record<Filter, number> = {
all: all.length,
error: 0,
warning: 0,
info: 0,
detail: 0,
};
for (const e of all) {
for (const [group, levels] of Object.entries(FILTER_LEVELS)) {
if (levels.includes(e.level)) out[group as Filter] += 1;
}
}
return out;
}, [all]);
const shown = useMemo(() => {
const needle = query.trim().toLowerCase();
const levels = filter === "all" ? null : FILTER_LEVELS[filter];
const out: LogEvent[] = [];
for (let i = all.length - 1; i >= 0; i--) {
const e = all[i]!;
if ((!levels || levels.includes(e.level)) && matches(e, needle)) out.push(e);
}
return out;
}, [all, filter, query]);
const copy = async () => {
// Oldest first, as a log reads — the list shows newest first only for watching it live.
const text = [...shown]
.reverse()
.map((e) => JSON.stringify(e))
.join("\n");
const ok = await copyText(text);
notify(
ok
? { level: "success", message: t("settings.myLog.copied", { count: shown.length }) }
: { level: "error", message: t("settings.myLog.copyFailed") },
);
};
const filters: readonly Filter[] = ["all", "error", "warning", "info", "detail"];
return (
<div data-testid="my-log" className="overflow-hidden rounded-xl border border-border bg-bg">
<p className="px-3 pt-2 text-xs text-muted">{t("settings.myLog.hint")}</p>
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<label className="flex min-w-[10rem] flex-1 items-center gap-1.5 rounded-lg border border-border bg-card px-2 py-1 text-sm focus-within:border-accent">
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("settings.myLog.search")}
aria-label={t("settings.myLog.search")}
className="min-w-0 flex-1 bg-transparent outline-none"
/>
</label>
<FilterChipGroup
ariaLabel={t("settings.myLog.filterLabel")}
value={filter}
onChange={setFilter}
options={filters.map((f) => ({
value: f,
label: t(`settings.myLog.filters.${f}`),
count: counts[f],
}))}
/>
</div>
<div className="max-h-96 overflow-auto font-mono text-2xs" data-testid="my-log-lines">
{shown.length === 0 ? (
<p className="px-3 py-6 text-center font-sans text-sm text-muted">
{all.length === 0 ? t("settings.myLog.emptyAll") : t("settings.myLog.empty")}
</p>
) : (
shown.slice(0, ROWS_SHOWN).map((e, i) => {
const tag = LEVEL_TAG[e.level];
const isOpen = open === e;
return (
<div key={`${e.ts}-${i}`} className="border-b border-border/50 last:border-b-0">
<button
type="button"
aria-expanded={isOpen}
onClick={() => setOpen(isOpen ? null : e)}
className={`grid w-full grid-cols-[6.5em_3.5em_minmax(0,1fr)] items-baseline sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] gap-2 px-3 py-1 text-left hover:bg-card ${
isOpen ? "bg-accent/10" : ""
}`}
>
<span className="tabular-nums text-muted">{clock(e.ts)}</span>
<span className={`font-semibold ${tag.className}`}>{tag.text}</span>
{/* A phone has no room for a fourth column: the code leads the message there. */}
<span className="hidden truncate sm:block">{e.event}</span>
<span className="truncate">
<span className="sm:hidden">{e.event} · </span>
{e.msg}
</span>
</button>
{isOpen && (
<pre className="overflow-x-auto whitespace-pre bg-accent/5 px-3 py-2 text-muted">
{JSON.stringify(
{
event: e.event,
msg: e.msg,
request_id: e.request_id,
ts: e.ts,
tab_id: e.tab_id,
attrs: e.attrs,
},
null,
2,
)}
</pre>
)}
</div>
);
})
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-1.5 text-xs text-muted">
<span>
{t("settings.myLog.count", { count: all.length })}
{shown.length > ROWS_SHOWN &&
` · ${t("settings.myLog.capped", { shown: ROWS_SHOWN, count: shown.length })}`}
</span>
<span className="flex gap-1.5">
<Button
variant="outline"
size="xs"
icon={<Copy className="h-3.5 w-3.5" />}
disabled={shown.length === 0}
onClick={() => void copy()}
>
{t("settings.myLog.copy")}
</Button>
<Button
variant="outline"
size="xs"
disabled={all.length === 0}
onClick={() => {
clear();
setOpen(null);
}}
>
{t("settings.myLog.clear")}
</Button>
</span>
</div>
</div>
);
}
+20
View File
@@ -44,6 +44,8 @@ import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls";
import { PlexPlayerSettings } from "./PlexPlayerSettings";
import { PlexStatsOverlay } from "./PlexStatsOverlay";
import { createFragMeter } from "../lib/plexStats";
import { enabled, log } from "../lib/log";
import { logMediaElement } from "../lib/mediaLog";
import { UpNextCard } from "./UpNextCard";
import Toaster, { useOverlayOrder } from "./ui/Toaster";
import {
@@ -432,6 +434,22 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// that never became an error (deep-review round 5). A couple of reclaims is what a genuine
// reap needs; past that the honest answer is that playback failed.
hls.on(Hls.Events.ERROR, (_e, data) => {
// Fatal ones are what the user saw fail; the recoverable ones hls.js retries by itself
// only matter to a detailed diagnosis.
if (data.fatal || enabled("DEBUG")) {
log.at(
data.fatal ? "WARNING" : "DEBUG",
"player.hls_error",
`plex hls ${data.details}`,
{
player: "plex",
fatal: data.fatal,
type: data.type,
details: data.details,
status: data.response?.code ?? null,
},
);
}
if (!data.fatal || !current()) return;
if (parsed && data.type === Hls.ErrorTypes.NETWORK_ERROR && data.response?.code === 404) {
const now = Date.now();
@@ -611,6 +629,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// deliberate pause isn't a stall, and would otherwise leave the spinner stuck).
const onWaiting = () => setBuffering(true);
const onResumed = () => setBuffering(false);
const detachLog = logMediaElement(video, "plex");
video.addEventListener("timeupdate", onTime);
video.addEventListener("play", onPlay);
video.addEventListener("pause", onPause);
@@ -625,6 +644,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
video.addEventListener("seeked", onResumed);
video.addEventListener("pause", onResumed);
return () => {
detachLog();
video.removeEventListener("timeupdate", onTime);
video.removeEventListener("play", onPlay);
video.removeEventListener("pause", onPause);
+7
View File
@@ -18,6 +18,7 @@ import {
type PlexUrlState,
} from "../lib/plexUrlState";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { logFilterChange } from "../lib/filterLog";
import { useUrlMirror } from "../lib/useUrlMirror";
import { useNavigation } from "./NavigationProvider";
@@ -219,27 +220,33 @@ export function PlexProvider({ children }: { children: ReactNode }) {
}, [urlSeed]);
const setScope = useCallback((v: string) => {
logFilterChange("plex", { scope: v });
setScopeState(v);
setAccountRaw(LS.plexLibrary, v);
}, []);
const setShow = useCallback((v: string) => {
logFilterChange("plex", { show: v });
setShowState(v);
setAccountRaw(LS.plexShow, v);
}, []);
const setSort = useCallback((v: SortState) => {
logFilterChange("plex", { sort: v });
setSortState(v);
setAccountRaw(LS.plexSort, JSON.stringify(v));
}, []);
const setFilters = useCallback((f: PlexFilters) => {
logFilterChange("plex", { filters: f });
const s = JSON.stringify(f);
setFiltersRaw(s);
setAccountRaw(LS.plexFilters, s);
}, []);
const setQ = useCallback((v: string) => {
logFilterChange("plex", {}, v);
setQState(v);
setAccountRaw(LS.plexQ, v);
}, []);
const setField = useCallback((v: PlexSearchField) => {
logFilterChange("plex", { field: v });
setFieldState(v);
setAccountRaw(LS.plexField, v);
}, []);
+9 -5
View File
@@ -3,7 +3,8 @@ import { withActiveAccount } from "../lib/activeAccount";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
import { Activity, Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
import DiagnosticsSettings from "./DiagnosticsSettings";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, HttpError, type Me } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
@@ -39,11 +40,12 @@ export interface PrefsController {
saveState: PrefsSaveState;
}
type TabId = "appearance" | "notifications" | "account";
type TabId = "appearance" | "notifications" | "account" | "diagnostics";
const TABS: { id: TabId; icon: typeof Monitor }[] = [
{ id: "appearance", icon: Monitor },
{ id: "notifications", icon: Bell },
{ id: "account", icon: User },
{ id: "diagnostics", icon: Activity },
];
// Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps
@@ -65,11 +67,12 @@ export default function SettingsPanel() {
return (
<div className="p-4 max-w-3xl w-full mx-auto">
<div className="glass rounded-2xl overflow-hidden">
<div className="flex">
{/* Vertical tab rail — never wraps; active is a clear accent pill. */}
<div className="flex flex-col sm:flex-row">
{/* Tab rail: a scrolling row above the content on a phone, the side rail from `sm` up (see
Tabs). Active is a clear accent pill. */}
<Tabs
orientation="vertical"
className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1"
className="flex gap-1 overflow-x-auto border-b border-border/60 p-2 sm:w-36 sm:shrink-0 sm:flex-col sm:border-b-0 sm:border-r"
active={tab}
onChange={setTab}
tabs={TABS.map((tabItem) => ({
@@ -87,6 +90,7 @@ export default function SettingsPanel() {
{tab === "appearance" && <Appearance prefs={prefs} />}
{tab === "notifications" && <Notifications prefs={prefs} />}
{tab === "account" && <Account me={me} onOpenWizard={openWizard} />}
{tab === "diagnostics" && <DiagnosticsSettings />}
</div>
</div>
+56 -21
View File
@@ -2,10 +2,19 @@
// reachable unauthenticated — Google's OAuth consent screen links to them and reviewers
// fetch them directly — so they render outside the authenticated App tree (see main.tsx),
// with no react-query provider: the operator contact is fetched with a bare `send` below.
import { useEffect, useState } from "react";
//
// Both pages exist in Hungarian and English, like the rest of the UI. The prose is a document, not
// a set of UI strings, so each language's body is its own component (see PrivacyPolicy/Terms) and
// `legalBodies.contract.dom.test.tsx` holds the two to the same sections; the chrome around them is i18n.
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { LANGUAGES, setLanguage } from "../../i18n";
import { formatDate } from "../../lib/format";
import { send } from "../../lib/http";
const LAST_UPDATED = "June 14, 2026";
// The day the text last changed materially — bump it with every edit of either body.
const LAST_UPDATED = "2026-09-17";
// Contact shown on the legal pages: the instance operator's configured admin email, from the
// public /auth/config. One shared fetch across the page; null when the operator set none.
@@ -34,8 +43,9 @@ function useOperatorContact(): string | null {
// The operator's contact rendered as a mailto link, or a neutral fallback phrase when unset.
export function ContactEmail() {
const { t } = useTranslation();
const email = useOperatorContact();
if (!email) return <>your instance&apos;s administrator</>;
if (!email) return <>{t("legal.operatorFallback")}</>;
return (
<a href={`mailto:${email}`} className="text-accent hover:underline">
{email}
@@ -43,7 +53,7 @@ export function ContactEmail() {
);
}
export function LegalLink({ href, children }: { href: string; children: React.ReactNode }) {
export function LegalLink({ href, children }: { href: string; children: ReactNode }) {
return (
<a href={href} target="_blank" rel="noreferrer" className="text-accent hover:underline">
{children}
@@ -51,38 +61,57 @@ export function LegalLink({ href, children }: { href: string; children: React.Re
);
}
export default function LegalLayout({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
export default function LegalLayout({ title, children }: { title: string; children: ReactNode }) {
const { t, i18n } = useTranslation();
const contact = useOperatorContact();
// A link to a section (`/privacy#diagnostics`): the browser tries to scroll to the fragment before
// this lazily loaded page has rendered it, so jump there once the section exists.
useEffect(() => {
const id = decodeURIComponent(window.location.hash.slice(1));
if (id) document.getElementById(id)?.scrollIntoView();
}, []);
return (
<div className="min-h-screen bg-bg text-fg">
<div className="mx-auto w-[min(92vw,720px)] py-12">
<a href="/" className="inline-block text-2xl font-bold tracking-tight mb-8">
Sift<span className="text-accent">lode</span>
</a>
<div className="mb-8 flex items-center justify-between gap-4">
<a href="/" className="inline-block text-2xl font-bold tracking-tight">
Sift<span className="text-accent">lode</span>
</a>
<div role="group" aria-label={t("legal.languageLabel")} className="flex gap-1 text-xs">
{LANGUAGES.map((lang) => (
<button
key={lang.code}
type="button"
lang={lang.code}
aria-pressed={i18n.language?.startsWith(lang.code) ?? false}
onClick={() => setLanguage(lang.code)}
className="rounded-md px-2 py-1 text-muted hover:text-fg aria-pressed:bg-card aria-pressed:text-fg"
>
{lang.label}
</button>
))}
</div>
</div>
<div className="glass rounded-2xl p-8">
<h1 className="text-2xl font-semibold">{title}</h1>
<p className="text-xs text-muted mt-1 mb-6">Last updated: {LAST_UPDATED}</p>
<p className="text-xs text-muted mt-1 mb-6">
{t("legal.lastUpdated", { date: formatDate(LAST_UPDATED, i18n.language) })}
</p>
<div className="space-y-5 text-sm leading-relaxed text-fg/90">{children}</div>
</div>
<footer className="mt-6 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
<a href="/" className="hover:text-fg transition">
Home
{t("legal.home")}
</a>
<a href="/privacy" className="hover:text-fg transition">
Privacy Policy
{t("common.privacyPolicy")}
</a>
<a href="/terms" className="hover:text-fg transition">
Terms of Service
{t("common.termsOfService")}
</a>
{contact && (
<a href={`mailto:${contact}`} className="hover:text-fg transition">
Contact
{t("legal.contact")}
</a>
)}
</footer>
@@ -91,6 +120,12 @@ export default function LegalLayout({
);
}
export function H2({ children }: { children: React.ReactNode }) {
return <h2 className="text-base font-semibold text-fg pt-2">{children}</h2>;
/** A section heading. `id` is required: it is the anchor a link can point at (Settings →
* Diagnosztika links `/privacy#diagnostics`), and what holds the two languages to the same sections. */
export function H2({ id, children }: { id: string; children: ReactNode }) {
return (
<h2 id={id} className="text-base font-semibold text-fg pt-2 scroll-mt-6">
{children}
</h2>
);
}
+229 -26
View File
@@ -1,8 +1,26 @@
import { useTranslation } from "react-i18next";
import LegalLayout, { ContactEmail, H2, LegalLink } from "./LegalLayout";
import { useHungarian } from "./useHungarian";
// Bold lead-in for a list item.
const Lead = ({ children }: { children: React.ReactNode }) => (
<span className="text-fg font-medium">{children}</span>
);
export default function PrivacyPolicy() {
const { t } = useTranslation();
const hu = useHungarian();
return (
<LegalLayout title="Privacy Policy">
<LegalLayout title={t("common.privacyPolicy")}>
{hu ? <PrivacyHu /> : <PrivacyEn />}
</LegalLayout>
);
}
export function PrivacyEn() {
return (
<>
<p>
Siftlode is a personal, non-commercial project operated by an individual in Hungary ("we",
"us"). It lets you view and organize your own YouTube subscriptions as a filterable feed.
@@ -10,22 +28,23 @@ export default function PrivacyPolicy() {
By using Siftlode you agree to this policy.
</p>
<H2>What we access</H2>
<p>Siftlode only accesses data you explicitly authorize, in two separate steps:</p>
<H2 id="access">What we access</H2>
<p>Siftlode only accesses data you explicitly authorize:</p>
<ul className="list-disc pl-5 space-y-1">
<li>
<span className="text-fg font-medium">Sign-in (always):</span> your basic Google profile —
name, email address, profile picture, and Google account identifier — used solely to
create and identify your account.
<Lead>Sign-in (always):</Lead> your email address — and, when you sign in with Google,
your basic Google profile (name, profile picture, Google account identifier) — used solely
to create and identify your account. Signing in with an email and password stores only a
one-way hash of the password.
</li>
<li>
<span className="text-fg font-medium">YouTube read (optional, opt-in):</span> with the{" "}
<Lead>YouTube read (optional, opt-in):</Lead> with the{" "}
<code className="text-xs">youtube.readonly</code> scope, your list of YouTube
subscriptions and the public metadata of those channels and their videos (titles,
thumbnails, durations, view counts, publish dates), used to build your feed.
</li>
<li>
<span className="text-fg font-medium">YouTube manage (optional, opt-in):</span> with the{" "}
<Lead>YouTube manage (optional, opt-in):</Lead> with the{" "}
<code className="text-xs">youtube</code> scope, the ability to unsubscribe from a channel
on YouTube — performed only when you click to do so.
</li>
@@ -35,7 +54,7 @@ export default function PrivacyPolicy() {
independently.
</p>
<H2>How we use it</H2>
<H2 id="use">How we use it</H2>
<p>
Your data is used exclusively to provide Siftlode's features to you: building and displaying
your personalized subscription feed, and carrying out actions (such as unsubscribing) that
@@ -43,7 +62,42 @@ export default function PrivacyPolicy() {
app.
</p>
<H2>Limited Use disclosure</H2>
<H2 id="logs">Server logs</H2>
<p>
To keep the service running and to find faults, the server records each request it answers:
which part of the app was called (for example <code className="text-xs">/api/feed</code>,
never the search terms or other values in the address), whether it succeeded, how long it
took, and which account made it. Warnings and errors are recorded too. Your IP address is
not stored in these records. They are kept for the retention period the operator sets — 7
days by default, at most 90 — and then deleted automatically.
</p>
<H2 id="diagnostics">Diagnostic logging (optional, with your consent)</H2>
<p>
In Settings → Diagnostics you can see the log your own browser tab keeps. By default it
stays in that tab and is gone when the page reloads. If you choose the <Lead>Detailed</Lead>{" "}
or <Lead>Full</Lead> level and confirm, for the next 24 hours:
</p>
<ul className="list-disc pl-5 space-y-1">
<li>
your browser also records which buttons, filters and player controls you use and the
values in the address bar — and, at Full, the text you type into search;
</li>
<li>
that log is sent to the server and stored with your account alongside the server's more
detailed record of your requests, for the same retention period as the server logs;
</li>
<li>
passwords, tokens, email addresses and the content of your messages are never included, at
any level.
</li>
</ul>
<p>
After 24 hours the level returns to the default by itself, and you can return to it earlier
at any time. It is used only to diagnose problems with Siftlode.
</p>
<H2 id="limited-use">Limited Use disclosure</H2>
<p>
Siftlode's use and transfer of information received from Google APIs to any other app will
adhere to the{" "}
@@ -55,32 +109,35 @@ export default function PrivacyPolicy() {
control), and do not use it for advertising.
</p>
<H2>Storage &amp; security</H2>
<H2 id="storage">Storage &amp; security</H2>
<p>
Data is stored in a private PostgreSQL database on a server we control, accessible only to
the operator. Google OAuth refresh tokens are encrypted at rest. Access is restricted to the
emails explicitly invited to the app. No system is perfectly secure, but we take reasonable
measures to protect your data.
the operator. Google OAuth refresh tokens are encrypted at rest. Private messages between
users are end-to-end encrypted in the browser: the server stores only the encrypted text and
cannot read it. Access is restricted to the email addresses admitted to the app. No system
is perfectly secure, but we take reasonable measures to protect your data.
</p>
<H2>Retention &amp; deletion</H2>
<H2 id="retention">Retention &amp; deletion</H2>
<p>
We keep your data while your account is active. You can revoke Siftlode's access to your
Google data at any time at{" "}
We keep your data while your account is active. You can delete your account and its data
yourself at any time in Settings → Account, which also removes the stored log records of
your account. You can revoke Siftlode's access to your Google data at{" "}
<LegalLink href="https://myaccount.google.com/permissions">
myaccount.google.com/permissions
</LegalLink>
; once revoked, the stored tokens can no longer be used. To have your account and associated
data deleted, contact <ContactEmail />.
; once revoked, the stored tokens can no longer be used. For anything else, contact{" "}
<ContactEmail />.
</p>
<H2>Cookies</H2>
<H2 id="cookies">Cookies &amp; browser storage</H2>
<p>
Siftlode sets a single, signed session cookie to keep you logged in. It is not used for
tracking or advertising, and there are no third-party analytics cookies.
Siftlode sets a single, signed session cookie to keep you logged in, and keeps your display
preferences in your browser's local storage. Neither is used for tracking or advertising,
and there are no third-party analytics cookies.
</p>
<H2>YouTube &amp; Google</H2>
<H2 id="google">YouTube &amp; Google</H2>
<p>
Siftlode uses YouTube API Services. By using Siftlode you also agree to the{" "}
<LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>, and
@@ -88,16 +145,162 @@ export default function PrivacyPolicy() {
<LegalLink href="https://policies.google.com/privacy">Google Privacy Policy</LegalLink>.
</p>
<H2>Changes</H2>
<H2 id="changes">Changes</H2>
<p>
We may update this policy; material changes will be reflected by the "Last updated" date
above. Continued use after a change constitutes acceptance.
</p>
<H2>Contact</H2>
<H2 id="contact">Contact</H2>
<p>
Questions about this policy or your data: contact <ContactEmail />.
</p>
</LegalLayout>
</>
);
}
export function PrivacyHu() {
return (
<>
<p>
A Siftlode egy magánszemély által üzemeltetett, nem kereskedelmi célú személyes projekt
Magyarországon („mi”). Lehetővé teszi, hogy a saját YouTube-feliratkozásaidat szűrhető
hírfolyamként nézd és rendszerezd. Ez a tájékoztató leírja, milyen adatokhoz fér hozzá a
Siftlode, mire használja őket, és milyen lehetőségeid vannak. A Siftlode használatával
elfogadod ezt a tájékoztatót.
</p>
<H2 id="access">Mihez férünk hozzá</H2>
<p>A Siftlode csak azokhoz az adatokhoz fér hozzá, amelyekhez kifejezetten hozzájárulsz:</p>
<ul className="list-disc pl-5 space-y-1">
<li>
<Lead>Bejelentkezés (mindig):</Lead> az e-mail-címed, Google-bejelentkezésnél pedig az
alapvető Google-profilod is (név, profilkép, Google-fiókazonosító), kizárólag a fiókod
létrehozásához és azonosításához. E-mail-címmel és jelszóval bejelentkezve csak a jelszó
egyirányú lenyomatát tároljuk.
</li>
<li>
<Lead>YouTube olvasás (nem kötelező, külön engedélyezed):</Lead> a{" "}
<code className="text-xs">youtube.readonly</code> jogosultsággal a YouTube-feliratkozásaid
listája, valamint ezeknek a csatornáknak és videóiknak a nyilvános adatai (cím, bélyegkép,
hossz, megtekintésszám, megjelenés dátuma) a hírfolyamod felépítéséhez.
</li>
<li>
<Lead>YouTube kezelés (nem kötelező, külön engedélyezed):</Lead> a{" "}
<code className="text-xs">youtube</code> jogosultsággal leiratkozhatsz egy csatornáról a
YouTube-on — csak akkor történik meg, ha erre kattintasz.
</li>
</ul>
<p>
A Siftlode puszta bejelentkezéssel is használható, és a YouTube-jogosultságokat egymástól
függetlenül adhatod meg vagy utasíthatod el.
</p>
<H2 id="use">Mire használjuk</H2>
<p>
Az adataidat kizárólag a Siftlode funkcióinak nyújtására használjuk: a személyre szabott
hírfolyamod felépítésére és megjelenítésére, valamint az általad kezdeményezett műveletek
(például leiratkozás) végrehajtására. Nem használjuk hirdetésre, profilalkotásra vagy az
alkalmazástól független célra.
</p>
<H2 id="logs">Szervernapló</H2>
<p>
A szolgáltatás működtetéséhez és a hibák felderítéséhez a szerver minden megválaszolt
kérésről feljegyzi: az alkalmazás melyik részét hívták (például{" "}
<code className="text-xs">/api/feed</code>, soha nem a keresőszót vagy más értéket a
címből), sikerült-e, mennyi ideig tartott, és melyik fiók indította. A figyelmeztetések és a
hibák is bekerülnek. Az IP-címedet ezek a feljegyzések nem tárolják. Az üzemeltető által
beállított ideig őrizzük őket — alapból 7 napig, legfeljebb 90 napig —, utána automatikusan
törlődnek.
</p>
<H2 id="diagnostics">Diagnosztikai naplózás (nem kötelező, a beleegyezéseddel)</H2>
<p>
A Beállítások → Diagnosztika fülön láthatod a saját böngészőlapod naplóját. Alapból ez csak
abban a lapban marad, és az oldal újratöltésekor törlődik. Ha a <Lead>Részletes</Lead> vagy
a <Lead>Teljes</Lead> szintet választod és megerősíted, a következő 24 órában:
</p>
<ul className="list-disc pl-5 space-y-1">
<li>
a böngésződ azt is rögzíti, melyik gombot, szűrőt és lejátszóvezérlőt használod, és a
címsor paramétereinek értékét — a Teljes szinten a keresőbe beírt szöveget is;
</li>
<li>
ezt a naplót elküldi a szervernek, amely a fiókodhoz tárolja, a kéréseid részletesebb
szerveroldali naplójával együtt, a szervernapló megőrzési idejéig;
</li>
<li>jelszó, token, e-mail-cím és az üzeneteid tartalma egyik szinten sem kerül bele.</li>
</ul>
<p>
24 óra múlva a szint magától visszaáll az alapra, és bármikor korábban is visszaállíthatod.
Kizárólag a Siftlode hibáinak felderítésére használjuk.
</p>
<H2 id="limited-use">Korlátozott felhasználás (Limited Use)</H2>
<p>
A Siftlode a Google API-kból kapott adatok felhasználása és más alkalmazásnak történő
átadása során betartja a{" "}
<LegalLink href="https://developers.google.com/terms/api-services-user-data-policy">
Google API Services User Data Policy
</LegalLink>{" "}
előírásait, beleértve a Limited Use követelményeit. Nem adjuk el az adataidat, nem adjuk át
harmadik félnek (kivéve, ami az alkalmazás működtetéséhez szükséges, például az általunk
kezelt tárhely-infrastruktúra), és nem használjuk hirdetésre.
</p>
<H2 id="storage">Tárolás és biztonság</H2>
<p>
Az adatokat egy általunk kezelt szerveren, privát PostgreSQL-adatbázisban tároljuk, amelyhez
csak az üzemeltető fér hozzá. A Google OAuth frissítő tokeneket titkosítva tároljuk. A
felhasználók közötti privát üzenetek végponttól végpontig titkosítottak a böngészőben: a
szerver csak a titkosított szöveget tárolja, és nem tudja elolvasni. Csak az alkalmazásba
beengedett e-mail-címek férnek hozzá. Egyetlen rendszer sem tökéletesen biztonságos, de
észszerű intézkedésekkel védjük az adataidat.
</p>
<H2 id="retention">Megőrzés és törlés</H2>
<p>
Az adataidat addig őrizzük, amíg a fiókod aktív. A fiókodat és az adataidat bármikor magad
is törölheted a Beállítások → Fiók fülön; ez a fiókodhoz tárolt naplófeljegyzéseket is
törli. A Siftlode hozzáférését a Google-adataidhoz a{" "}
<LegalLink href="https://myaccount.google.com/permissions">
myaccount.google.com/permissions
</LegalLink>{" "}
oldalon vonhatod vissza; ezután a tárolt tokenek már nem használhatók. Minden más ügyben
fordulj ide: <ContactEmail />.
</p>
<H2 id="cookies">Sütik és böngészőtár</H2>
<p>
A Siftlode egyetlen aláírt munkamenet-sütit használ, hogy bejelentkezve maradj, a
megjelenítési beállításaidat pedig a böngésződ helyi tárában tartja. Egyiket sem használjuk
követésre vagy hirdetésre, és nincsenek harmadik féltől származó analitikai sütik.
</p>
<H2 id="google">YouTube és Google</H2>
<p>
A Siftlode a YouTube API szolgáltatásait használja. A Siftlode használatával elfogadod a{" "}
<LegalLink href="https://www.youtube.com/t/terms">
YouTube Általános Szerződési Feltételeit
</LegalLink>
, a Google-adataid kezelésére pedig a{" "}
<LegalLink href="https://policies.google.com/privacy">
Google Adatvédelmi irányelvei
</LegalLink>{" "}
vonatkoznak.
</p>
<H2 id="changes">Változások</H2>
<p>
Ezt a tájékoztatót módosíthatjuk; a lényeges változásokat a fenti „Utoljára frissítve” dátum
jelzi. Ha a változás után is használod a szolgáltatást, azzal elfogadod.
</p>
<H2 id="contact">Kapcsolat</H2>
<p>
Kérdés a tájékoztatóval vagy az adataiddal kapcsolatban: <ContactEmail />.
</p>
</>
);
}
+96 -13
View File
@@ -1,30 +1,41 @@
import { useTranslation } from "react-i18next";
import LegalLayout, { ContactEmail, H2, LegalLink } from "./LegalLayout";
import { useHungarian } from "./useHungarian";
export default function Terms() {
const { t } = useTranslation();
const hu = useHungarian();
return (
<LegalLayout title="Terms of Service">
<LegalLayout title={t("common.termsOfService")}>{hu ? <TermsHu /> : <TermsEn />}</LegalLayout>
);
}
export function TermsEn() {
return (
<>
<p>
Siftlode is a personal, non-commercial project operated by an individual in Hungary. It
provides a filterable view of your own YouTube subscriptions. By accessing or using
Siftlode, you agree to these Terms. If you do not agree, do not use the service.
</p>
<H2>The service</H2>
<H2 id="service">The service</H2>
<p>
Siftlode is offered on an invite-only basis, free of charge, and "as is", without warranties
of any kind. It is a hobby project: features may change, and the service may be slowed,
interrupted, or discontinued at any time without notice.
</p>
<H2>Your account &amp; acceptable use</H2>
<H2 id="account">Your account &amp; acceptable use</H2>
<p>
You sign in with your Google account and may only access Siftlode with an email that has
been invited. You agree to use the service lawfully, not to abuse, disrupt, probe, or
overload it, and not to attempt to access other users' data. We may suspend or revoke access
at our discretion, for example in case of misuse.
You sign in with your Google account or with an email address and password, and may only
access Siftlode with an email that has been admitted. You agree to use the service lawfully,
not to abuse, disrupt, probe, or overload it, and not to attempt to access other users'
data. We may suspend or revoke access at our discretion, for example in case of misuse.
</p>
<H2>Third-party services</H2>
<H2 id="third-party">Third-party services</H2>
<p>
Siftlode uses YouTube API Services. By using Siftlode you agree to be bound by the{" "}
<LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>. Your
@@ -36,7 +47,7 @@ export default function Terms() {
<LegalLink href="https://policies.google.com/privacy">Google Privacy Policy</LegalLink>.
</p>
<H2>No warranty &amp; limitation of liability</H2>
<H2 id="liability">No warranty &amp; limitation of liability</H2>
<p>
To the maximum extent permitted by law, the service is provided without warranty, and the
operator is not liable for any indirect, incidental, or consequential damages, or for any
@@ -44,22 +55,94 @@ export default function Terms() {
to stop using the service and revoke its access to your account.
</p>
<H2>Governing law</H2>
<H2 id="law">Governing law</H2>
<p>
These Terms are governed by the laws of Hungary and applicable European Union law, without
regard to conflict-of-law rules.
</p>
<H2>Changes</H2>
<H2 id="changes">Changes</H2>
<p>
We may update these Terms; the "Last updated" date above reflects the latest version.
Continued use after a change constitutes acceptance.
</p>
<H2>Contact</H2>
<H2 id="contact">Contact</H2>
<p>
Questions: contact <ContactEmail />.
</p>
</LegalLayout>
</>
);
}
export function TermsHu() {
return (
<>
<p>
A Siftlode egy magánszemély által üzemeltetett, nem kereskedelmi célú személyes projekt
Magyarországon. A saját YouTube-feliratkozásaid szűrhető nézetét nyújtja. A Siftlode
elérésével vagy használatával elfogadod ezeket a feltételeket. Ha nem fogadod el őket, ne
használd a szolgáltatást.
</p>
<H2 id="service">A szolgáltatás</H2>
<p>
A Siftlode csak meghívással, ingyenesen és „ahogy van” alapon érhető el, mindenféle
szavatosság nélkül. Hobbiprojekt: a funkciói változhatnak, és a szolgáltatás bármikor,
előzetes értesítés nélkül lelassulhat, szünetelhet vagy megszűnhet.
</p>
<H2 id="account">A fiókod és az elfogadható használat</H2>
<p>
Google-fiókkal vagy e-mail-címmel és jelszóval jelentkezel be, és csak beengedett
e-mail-címmel használhatod a Siftlode-ot. Vállalod, hogy a szolgáltatást jogszerűen
használod, nem éled vissza, nem zavarod meg, nem próbálod ki a gyenge pontjait és nem
terheled túl, és nem próbálsz más felhasználók adataihoz hozzáférni. A hozzáférést saját
belátásunk szerint felfüggeszthetjük vagy visszavonhatjuk, például visszaélés esetén.
</p>
<H2 id="third-party">Harmadik fél szolgáltatásai</H2>
<p>
A Siftlode a YouTube API szolgáltatásait használja. A Siftlode használatával elfogadod a{" "}
<LegalLink href="https://www.youtube.com/t/terms">
YouTube Általános Szerződési Feltételeit
</LegalLink>
. Az adataidat az{" "}
<a href="/privacy" className="text-accent hover:underline">
Adatvédelmi irányelvekben
</a>{" "}
leírtak szerint kezeljük, a Google-adatokra pedig a{" "}
<LegalLink href="https://policies.google.com/privacy">
Google Adatvédelmi irányelvei
</LegalLink>{" "}
is vonatkoznak.
</p>
<H2 id="liability">Szavatosság kizárása és a felelősség korlátozása</H2>
<p>
A jogszabályok által megengedett legteljesebb mértékben a szolgáltatás szavatosság nélkül
érhető el, és az üzemeltető nem felel a Siftlode használatából eredő közvetett, járulékos
vagy következményes károkért, sem adatvesztésért. Ha elégedetlen vagy, az egyetlen
lehetőséged, hogy abbahagyod a szolgáltatás használatát, és visszavonod a hozzáférését a
fiókodhoz.
</p>
<H2 id="law">Irányadó jog</H2>
<p>
Ezekre a feltételekre Magyarország jogszabályai és az alkalmazandó európai uniós jog
irányadók, a kollíziós szabályok figyelmen kívül hagyásával.
</p>
<H2 id="changes">Változások</H2>
<p>
Ezeket a feltételeket módosíthatjuk; a fenti „Utoljára frissítve” dátum a legfrissebb
változatot jelzi. Ha a változás után is használod a szolgáltatást, azzal elfogadod.
</p>
<H2 id="contact">Kapcsolat</H2>
<p>
Kérdés esetén: <ContactEmail />.
</p>
</>
);
}
@@ -0,0 +1,36 @@
// @vitest-environment jsdom
//
// The legal pages are written out once per language (a document, not UI strings — see LegalLayout).
// What keeps the two from drifting apart is this: both languages must have the SAME sections, in the
// same order, by anchor — so a section added to one (E22 S5 added the log sections) cannot be
// missing from the other, and a link to `/privacy#diagnostics` lands in either language.
import { describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import type { ComponentType } from "react";
vi.mock("../../lib/http", () => ({ send: () => Promise.resolve({ ok: false }) }));
import { PrivacyEn, PrivacyHu } from "./PrivacyPolicy";
import { TermsEn, TermsHu } from "./Terms";
const sections = (Body: ComponentType) => {
const { container } = render(<Body />);
const ids = [...container.querySelectorAll("h2")].map((h) => h.id);
cleanup();
return ids;
};
describe("legal pages", () => {
it.each([
["privacy", PrivacyEn, PrivacyHu],
["terms", TermsEn, TermsHu],
])("%s has the same sections in Hungarian and English", (_name, En, Hu) => {
const en = sections(En);
expect(en.every(Boolean)).toBe(true);
expect(sections(Hu)).toEqual(en);
});
it("the privacy policy has the section the diagnostics consent links to", () => {
expect(sections(PrivacyEn)).toContain("diagnostics");
});
});
@@ -0,0 +1,7 @@
import { useTranslation } from "react-i18next";
/** Whether a legal page renders its Hungarian body (see LegalLayout). */
export function useHungarian(): boolean {
const { i18n } = useTranslation();
return i18n.language?.startsWith("hu") ?? false;
}
+9 -4
View File
@@ -4,8 +4,11 @@ import CountBadge from "./CountBadge";
// Reusable pill tab-rail for in-page sub-navigation (Configuration, Users & roles, the Settings
// sections, the Stats overview/system split…). Horizontal by default; `orientation="vertical"` gives
// the Settings-style side rail (full-width, left-aligned, icon + label). The active tab is persisted
// to localStorage so a reload keeps the user where they were.
// the Settings-style side rail (full-width, left-aligned, icon + label) — from the `sm` breakpoint up.
// Below it a side rail leaves a phone too little width for the content beside it (measured at 390 px:
// the rail clipped its own labels and Settings → Account pushed its buttons over their text), so there
// it is a horizontal row that scrolls. The active tab is persisted to localStorage so a reload keeps
// the user where they were.
export interface TabDef {
id: string;
@@ -36,7 +39,9 @@ export default function Tabs({
className?: string;
}) {
const vertical = orientation === "vertical";
const wrapCls = className ?? (vertical ? "flex flex-col gap-1" : "flex flex-wrap gap-1 mb-4");
const wrapCls =
className ??
(vertical ? "flex gap-1 overflow-x-auto sm:flex-col" : "flex flex-wrap gap-1 mb-4");
return (
<div className={wrapCls}>
{tabs.map((tb) => {
@@ -50,7 +55,7 @@ export default function Tabs({
aria-pressed={on}
className={`pill items-center rounded-lg px-3 text-sm ${
vertical
? "flex w-full justify-start gap-2 py-2 text-left"
? "inline-flex shrink-0 gap-1.5 py-1.5 sm:flex sm:w-full sm:justify-start sm:gap-2 sm:py-2 sm:text-left"
: "inline-flex gap-1.5 py-1.5"
} ${on ? "font-medium shadow-md shadow-accent/20" : "hover:bg-card/60"}`}
>