Release v0.1.0: watch progress, status bar, version/release-notes, i18n (HU/EN/DE)
Some checks failed
CI / frontend (push) Failing after 37s
CI / backend (push) Failing after 45s

This commit is contained in:
npeter83 2026-06-15 02:09:22 +02:00
commit 9c639c3a92
85 changed files with 2465 additions and 448 deletions

View file

@ -1,5 +1,15 @@
# Build/version info, injected via --build-arg (defaults keep un-built/dev runs working).
ARG APP_VERSION=dev
ARG GIT_SHA=unknown
ARG BUILD_DATE=
# Stage 1: build the frontend SPA
FROM node:20-alpine AS frontend
ARG APP_VERSION
ARG GIT_SHA
ARG BUILD_DATE
# Vite inlines VITE_*-prefixed env into the bundle at build time.
ENV VITE_APP_VERSION=$APP_VERSION VITE_GIT_SHA=$GIT_SHA VITE_BUILD_DATE=$BUILD_DATE
WORKDIR /fe
COPY frontend/package.json ./
RUN npm install
@ -8,10 +18,16 @@ RUN npm run build
# Stage 2: backend runtime, serving the built SPA
FROM python:3.12-slim
ARG APP_VERSION
ARG GIT_SHA
ARG BUILD_DATE
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
PIP_DISABLE_PIP_VERSION_CHECK=1 \
APP_VERSION=$APP_VERSION \
GIT_SHA=$GIT_SHA \
BUILD_DATE=$BUILD_DATE
WORKDIR /app

1
VERSION Normal file
View file

@ -0,0 +1 @@
0.1.0

View file

@ -151,6 +151,13 @@ async def callback(
user.display_name = userinfo.get("name")
user.avatar_url = userinfo.get("picture")
user.role = "admin" if email in settings.admin_email_set else "user"
# Default UI language from the Google-reported locale on first login (if the user hasn't
# picked one yet); unsupported locales fall back to English.
prefs = dict(user.preferences or {})
if not prefs.get("language"):
loc = (userinfo.get("locale") or "").split("-")[0].lower()
prefs["language"] = loc if loc in {"en", "hu", "de"} else "en"
user.preferences = prefs
db.flush()
tok = user.token or OAuthToken(user=user)

View file

@ -9,6 +9,11 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_name: str = "Siftlode"
# Build/version info, injected at image build time (Docker build-args -> env). Defaults
# apply for un-built/dev runs; the About dialog and version banner surface these.
app_version: str = "dev"
git_sha: str = "unknown"
build_date: str = ""
database_url: str = "postgresql+psycopg://subfeed:subfeed@db:5432/subfeed"

View file

@ -26,7 +26,7 @@ from starlette.middleware.sessions import SessionMiddleware
from app import auth
from app.config import settings
from app.routes import admin, channels, feed, health, me, quota, sync, tags
from app.routes import admin, channels, feed, health, me, quota, sync, tags, version
from app.scheduler import shutdown_scheduler, start_scheduler
@ -68,6 +68,7 @@ app.include_router(me.router)
app.include_router(channels.router)
app.include_router(admin.router)
app.include_router(quota.router)
app.include_router(version.router)
# The built SPA (populated by the Docker frontend build stage).
STATIC_DIR = Path(__file__).parent / "static_spa"

View file

@ -0,0 +1,23 @@
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.config import settings
from app.db import get_db
router = APIRouter(prefix="/api", tags=["version"])
@router.get("/version")
def get_version(db: Session = Depends(get_db)) -> dict:
"""Build + schema version, for the About dialog and the 'new version' banner.
Intentionally public (no auth) and cheap: just env-baked build info plus the current
Alembic migration head (the 'database version'). app_version/git_sha/build_date are
injected at image build time (see the Dockerfile build-args)."""
db_revision = db.execute(text("SELECT version_num FROM alembic_version")).scalar()
return {
"app_version": settings.app_version,
"git_sha": settings.git_sha,
"build_date": settings.build_date or None,
"db_revision": db_revision,
}

View file

@ -15,6 +15,11 @@ git fetch --quiet origin
git checkout --quiet main
git pull --ff-only --quiet
# Stamp the image with the version/commit being built (read by /api/version + the SPA).
export APP_VERSION="$(cat VERSION 2>/dev/null || echo dev)"
export GIT_SHA="$(git rev-parse --short HEAD)"
export BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
docker compose -f docker-compose.prod.yml --env-file "$ROOT/.env" up -d --build
docker image prune -f >/dev/null || true
docker compose -f docker-compose.prod.yml --env-file "$ROOT/.env" ps

View file

@ -21,6 +21,10 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
APP_VERSION: ${APP_VERSION:-dev}
GIT_SHA: ${GIT_SHA:-unknown}
BUILD_DATE: ${BUILD_DATE:-}
env_file: .env
environment:
# Exactly one scheduler may write to the shared DB; the server owns it.

View file

@ -37,6 +37,10 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
APP_VERSION: ${APP_VERSION:-dev}
GIT_SHA: ${GIT_SHA:-unknown}
BUILD_DATE: ${BUILD_DATE:-}
image: siftlode-api:local
env_file:
- /srv/siftlode/.env

View file

@ -41,6 +41,10 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
APP_VERSION: ${APP_VERSION:-dev}
GIT_SHA: ${GIT_SHA:-unknown}
BUILD_DATE: ${BUILD_DATE:-}
env_file: .env
environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-subfeed}:${POSTGRES_PASSWORD:-subfeed}@db:5432/${POSTGRES_DB:-subfeed}

View file

@ -18,6 +18,10 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
APP_VERSION: ${APP_VERSION:-dev}
GIT_SHA: ${GIT_SHA:-unknown}
BUILD_DATE: ${BUILD_DATE:-}
env_file: .env
environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-subfeed}:${POSTGRES_PASSWORD:-subfeed}@db:5432/${POSTGRES_DB:-subfeed}

View file

@ -1,19 +1,21 @@
{
"name": "subfeed-frontend",
"name": "siftlode-frontend",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "subfeed-frontend",
"name": "siftlode-frontend",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.51.0",
"clsx": "^2.1.1",
"i18next": "^23.11.5",
"lucide-react": "^0.408.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"react-i18next": "^14.1.2"
},
"devDependencies": {
"@types/react": "^18.3.3",
@ -273,6 +275,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@ -952,9 +963,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -969,9 +977,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -986,9 +991,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1003,9 +1005,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1020,9 +1019,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1037,9 +1033,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1054,9 +1047,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1071,9 +1061,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1088,9 +1075,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1105,9 +1089,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1122,9 +1103,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1139,9 +1117,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1156,9 +1131,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1855,6 +1827,38 @@
"node": ">= 0.4"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "23.16.8",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz",
"integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@ -2368,6 +2372,28 @@
"react": "^18.3.1"
}
},
"node_modules/react-i18next": {
"version": "14.1.3",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.3.tgz",
"integrity": "sha512-wZnpfunU6UIAiJ+bxwOiTmBOAaB14ha97MjOEnLGac2RJ+h/maIYXZuTHlmyqQVX1UVHmU1YDTQ5vxLmwfXTjw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.9",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.2.3",
"react": ">= 16.8.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@ -2815,6 +2841,15 @@
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View file

@ -13,9 +13,11 @@
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.51.0",
"clsx": "^2.1.1",
"i18next": "^23.11.5",
"lucide-react": "^0.408.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"react-i18next": "^14.1.2"
},
"devDependencies": {
"@types/react": "^18.3.3",

View file

@ -1,6 +1,8 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { api, HttpError, type FeedFilters } from "./lib/api";
import { setLanguage, isSupported, type LangCode } from "./i18n";
import {
applyTheme,
DEFAULT_THEME,
@ -27,6 +29,10 @@ import SettingsPanel from "./components/SettingsPanel";
import OnboardingWizard from "./components/OnboardingWizard";
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
import Toaster from "./components/Toaster";
import About from "./components/About";
import ReleaseNotes from "./components/ReleaseNotes";
import VersionBanner from "./components/VersionBanner";
import { CURRENT_VERSION } from "./lib/releaseNotes";
const DEFAULT_FILTERS: FeedFilters = {
tags: [],
@ -57,6 +63,7 @@ function loadInitialFilters(): FeedFilters {
}
export default function App() {
const { t } = useTranslation();
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
const [view, setView] = useState<"grid" | "list">("grid");
@ -65,6 +72,14 @@ export default function App() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false);
const [channelFilter, setChannelFilter] = useState<ChannelStatusFilter>("all");
const [aboutOpen, setAboutOpen] = useState(false);
const [notesOpen, setNotesOpen] = useState(false);
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
function openReleaseNotes(highlight?: string) {
setNotesHighlight(highlight);
setNotesOpen(true);
}
function setFilters(next: FeedFilters) {
setFiltersState(next);
@ -112,6 +127,7 @@ export default function App() {
setSidebarLayoutState(l);
saveLayoutLocal(l);
}
if (isSupported(prefs.language)) setLanguage(prefs.language);
if (prefs.notifications) configureNotifications(prefs.notifications);
if (typeof prefs.hints === "boolean") setHintsEnabled(prefs.hints);
if (typeof prefs.performanceMode === "boolean")
@ -121,8 +137,8 @@ export default function App() {
if (meQuery.data?.role === "admin" && pending > 0) {
notify({
level: "warning",
title: "Access requests",
message: `${pending} pending — review in Settings → Account.`,
title: t("common.accessRequestsTitle"),
message: t("common.accessRequestsMessage", { count: pending }),
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -137,15 +153,21 @@ export default function App() {
setView(v);
api.savePrefs({ view: v }).catch(() => {});
}
function changeLanguage(code: LangCode) {
setLanguage(code);
api.savePrefs({ language: code }).catch(() => {});
}
if (meQuery.isLoading)
return <div className="min-h-screen grid place-items-center text-muted">Loading</div>;
return (
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
);
if (meQuery.error instanceof HttpError && meQuery.error.status === 401)
return <Login />;
if (meQuery.error)
return (
<div className="min-h-screen grid place-items-center text-muted">
Something went wrong.
{t("common.somethingWrong")}
</div>
);
@ -158,11 +180,14 @@ export default function App() {
page={page}
setPage={setPage}
onOpenSettings={() => setSettingsOpen(true)}
onOpenAbout={() => setAboutOpen(true)}
onChangeLanguage={changeLanguage}
onGoToFullHistory={() => {
setChannelFilter("needs_full");
setPage("channels");
}}
/>
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
<div className="flex flex-1 min-h-0">
{page === "feed" && (
<Sidebar
@ -178,6 +203,7 @@ export default function App() {
canWrite={meQuery.data!.can_write}
statusFilter={channelFilter}
setStatusFilter={setChannelFilter}
onOpenWizard={() => setWizardOpen(true)}
onViewChannel={(id, name) => {
setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
setPage("feed");
@ -213,6 +239,18 @@ export default function App() {
{wizardOpen && meQuery.data && (
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
)}
{aboutOpen && (
<About
onClose={() => setAboutOpen(false)}
onOpenReleaseNotes={() => {
setAboutOpen(false);
openReleaseNotes();
}}
/>
)}
{notesOpen && (
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
)}
<Toaster />
</div>
);

View file

@ -0,0 +1,73 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Info, Sparkles } from "lucide-react";
import { api } from "../lib/api";
import { FRONTEND_VERSION, FRONTEND_SHA, FRONTEND_BUILD_DATE } from "../lib/version";
import Modal from "./Modal";
function fmtDate(d?: string | null): string {
if (!d) return "—";
const t = new Date(d);
return isNaN(t.getTime()) ? d : t.toLocaleString();
}
// About dialog: app + build + schema versions (frontend/backend/database), plus a
// shortcut into the release notes.
export default function About({
onClose,
onOpenReleaseNotes,
}: {
onClose: () => void;
onOpenReleaseNotes: () => void;
}) {
const { t } = useTranslation();
const { data } = useQuery({ queryKey: ["version"], queryFn: api.version, staleTime: 5 * 60_000 });
const rows: [string, string][] = [
[t("about.frontend"), FRONTEND_VERSION + (FRONTEND_SHA !== "unknown" ? ` · ${FRONTEND_SHA}` : "")],
[
t("about.backend"),
data ? data.app_version + (data.git_sha !== "unknown" ? ` · ${data.git_sha}` : "") : "…",
],
[t("about.database"), data?.db_revision ?? "…"],
[t("about.buildDate"), fmtDate(data?.build_date ?? FRONTEND_BUILD_DATE)],
];
return (
<Modal
title={
<span className="flex items-center gap-2">
<Info className="w-5 h-5 text-accent" /> {t("about.title")}
</span>
}
onClose={onClose}
>
<div className="flex items-baseline gap-2">
<div className="text-2xl font-bold tracking-tight">
Sift<span className="text-accent">lode</span>
</div>
<div className="text-sm text-muted">v{FRONTEND_VERSION}</div>
</div>
<p className="text-sm text-muted mt-2">{t("about.tagline")}</p>
<div className="mt-4 rounded-xl border border-border overflow-hidden text-sm">
{rows.map(([k, v], i) => (
<div
key={k}
className={`flex justify-between gap-4 px-3 py-2 ${i % 2 ? "bg-surface/40" : ""}`}
>
<span className="text-muted">{k}</span>
<span className="font-medium text-right break-all">{v}</span>
</div>
))}
</div>
<button
onClick={onOpenReleaseNotes}
className="mt-4 w-full inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
<Sparkles className="w-4 h-4" /> {t("about.whatsNew")}
</button>
</Modal>
);
}

View file

@ -1,4 +1,5 @@
import { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowDown,
@ -12,7 +13,7 @@ import {
UserMinus,
X,
} from "lucide-react";
import { api, type ManagedChannel, type Tag } from "../lib/api";
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
import { formatEta } from "../lib/format";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
@ -20,11 +21,11 @@ import Avatar from "./Avatar";
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
const STATUS_FILTERS: { id: ChannelStatusFilter; label: string }[] = [
{ id: "all", label: "All" },
{ id: "needs_full", label: "Needs full history" },
{ id: "fully_synced", label: "Fully synced" },
{ id: "hidden", label: "Hidden" },
const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
{ id: "all", labelKey: "channels.filters.all" },
{ id: "needs_full", labelKey: "channels.filters.needsFull" },
{ id: "fully_synced", labelKey: "channels.filters.fullySynced" },
{ id: "hidden", labelKey: "channels.filters.hidden" },
];
export default function Channels({
@ -32,13 +33,30 @@ export default function Channels({
onViewChannel,
statusFilter,
setStatusFilter,
onOpenWizard,
}: {
canWrite: boolean;
onViewChannel: (id: string, name: string) => void;
statusFilter: ChannelStatusFilter;
setStatusFilter: (f: ChannelStatusFilter) => void;
onOpenWizard: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
// A YouTube-gated action (sync, backfill, unsubscribe) that 403s means the user hasn't
// granted the needed scope — surface that with a "Connect" action instead of a vague fail.
const notifyActionError = (err: unknown, fallbackKey: string) => {
if (err instanceof HttpError && err.status === 403) {
notify({
level: "error",
message: t("channels.notify.needYouTube"),
action: { label: t("channels.notify.connect"), onClick: onOpenWizard },
});
} else {
notify({ level: "error", message: t(fallbackKey) });
}
};
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
@ -92,9 +110,9 @@ export default function Channels({
mutationFn: () => api.syncSubscriptions(),
onSuccess: (r: { subscriptions?: number }) => {
invalidate();
notify({ level: "success", message: `Synced ${r.subscriptions ?? 0} subscriptions` });
notify({ level: "success", message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }) });
},
onError: () => notify({ level: "error", message: "Subscription sync failed" }),
onError: (e) => notifyActionError(e, "channels.notify.syncFailed"),
});
const createTag = useMutation({
mutationFn: (name: string) => api.createTag({ name, category: "other" }),
@ -112,9 +130,9 @@ export default function Channels({
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["my-status"] });
notify({ level: "success", message: "Unsubscribed on YouTube" });
notify({ level: "success", message: t("channels.notify.unsubscribed") });
},
onError: () => notify({ level: "error", message: "Unsubscribe failed" }),
onError: (e) => notifyActionError(e, "channels.notify.unsubscribeFailed"),
});
const deepAll = useMutation({
mutationFn: () => api.deepAll(true),
@ -123,10 +141,10 @@ export default function Channels({
qc.invalidateQueries({ queryKey: ["my-status"] });
notify({
level: "success",
message: `Full history requested for ${r.updated ?? 0} channels`,
message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
});
},
onError: () => notify({ level: "error", message: "Couldn't request full history" }),
onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"),
});
const channels = (channelsQuery.data ?? [])
@ -147,29 +165,29 @@ export default function Channels({
{/* Per-user sync status */}
{s && (
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-muted mb-4">
<Stat label="Channels" value={s.channels_total} hint="Channels you're subscribed to." />
<Stat label={t("channels.stats.channels")} value={s.channels_total} hint={t("channels.stats.channelsHint")} />
<Stat
label="Recent synced"
label={t("channels.stats.recentSynced")}
value={`${s.channels_recent_synced}/${s.channels_total}`}
hint="Channels whose recent uploads are in the local DB and show in your feed."
hint={t("channels.stats.recentSyncedHint")}
/>
<Stat
label="Full history"
label={t("channels.stats.fullHistory")}
value={`${s.channels_deep_done}/${s.channels_deep_requested}`}
hint="Channels whose entire back-catalog is fetched, out of the ones you've requested full history for."
hint={t("channels.stats.fullHistoryHint")}
/>
{s.deep_pending_count > 0 && (
<Stat
label="left"
label={t("channels.stats.left")}
value={formatEta(s.deep_eta_seconds)}
hint={`Rough estimate to finish full-history backfill of ${s.deep_pending_count} pending channel(s), based on the shared daily quota.`}
hint={t("channels.stats.leftHint", { count: s.deep_pending_count })}
/>
)}
<Stat label="My videos" value={s.my_videos.toLocaleString()} hint="Total videos available across your channels." />
<Stat label={t("channels.stats.myVideos")} value={s.my_videos.toLocaleString()} hint={t("channels.stats.myVideosHint")} />
<Stat
label="Quota left"
label={t("channels.stats.quotaLeft")}
value={s.quota_remaining_today.toLocaleString()}
hint="Shared YouTube API budget left today (resets midnight US Pacific)."
hint={t("channels.stats.quotaLeftHint")}
/>
</div>
)}
@ -181,13 +199,13 @@ export default function Channels({
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Filter channels…"
placeholder={t("channels.filterPlaceholder")}
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
/>
</div>
<Tooltip
side="bottom"
hint="Re-import your subscription list from YouTube — adds channels you've newly followed and drops ones you've unfollowed. The videos themselves keep syncing automatically in the background; this does not re-fetch them."
hint={t("channels.syncSubscriptionsHint")}
>
<button
onClick={() => syncSubs.mutate()}
@ -195,12 +213,12 @@ export default function Channels({
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
Sync subscriptions
{t("channels.syncSubscriptions")}
</button>
</Tooltip>
<Tooltip
side="bottom"
hint="Request full back-catalog backfill for every channel you're subscribed to. Older videos and search become complete as the shared daily quota allows — this can take a while."
hint={t("channels.backfillEverythingHint")}
>
<button
onClick={() => deepAll.mutate()}
@ -208,7 +226,7 @@ export default function Channels({
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
Backfill everything
{t("channels.backfillEverything")}
</button>
</Tooltip>
</div>
@ -225,22 +243,27 @@ export default function Channels({
: "bg-card border-border text-muted hover:border-accent"
}`}
>
{f.label}
{t(f.labelKey)}
</button>
))}
</div>
<p className="text-xs text-muted mb-4 leading-relaxed">
Set a channel's <b className="text-fg/80">priority</b> to push its videos up when you sort
by Channel priority, attach your own <b className="text-fg/80">tags</b> to filter the feed,
or <b className="text-fg/80">hide</b> a channel to drop it from the feed without unsubscribing.
<Trans
i18nKey="channels.intro"
components={[
<b className="text-fg/80" />,
<b className="text-fg/80" />,
<b className="text-fg/80" />,
]}
/>
</p>
{/* Your tags */}
<div className="flex flex-wrap items-center gap-1.5 mb-4">
<Tooltip hint="Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)">
<Tooltip hint={t("channels.tags.yourTagsHint")}>
<span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4">
Your tags
{t("channels.tags.yourTags")}
</span>
</Tooltip>
{userTags.map((t) => (
@ -264,10 +287,10 @@ export default function Channels({
<input
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="new tag"
placeholder={t("channels.tags.newTag")}
className="w-24 bg-card border border-border rounded-full px-2.5 py-1 text-xs outline-none focus:border-accent"
/>
<button type="submit" className="text-muted hover:text-accent" title="Create tag">
<button type="submit" className="text-muted hover:text-accent" title={t("channels.tags.createTag")}>
<Plus className="w-4 h-4" />
</button>
</form>
@ -275,9 +298,9 @@ export default function Channels({
{/* Channel list */}
{channelsQuery.isLoading ? (
<div className="text-muted py-8">Loading channels</div>
<div className="text-muted py-8">{t("channels.loading")}</div>
) : channels.length === 0 ? (
<div className="text-muted py-8">No channels.</div>
<div className="text-muted py-8">{t("channels.empty")}</div>
) : (
<div className="flex flex-col gap-1.5">
{channels.map((c) => (
@ -289,12 +312,12 @@ export default function Channels({
onUnsubscribe={() => {
if (
window.confirm(
`Unsubscribe from "${c.title ?? c.id}" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead.`
t("channels.confirmUnsubscribe", { name: c.title ?? c.id })
)
)
unsubscribe.mutate(c.id);
}}
onView={() => onViewChannel(c.id, c.title ?? "This channel")}
onView={() => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
onPriority={(d) => bumpPriority(c.id, c.priority, d)}
onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })}
onDeep={() =>
@ -366,19 +389,20 @@ function ChannelRow({
onDeep: () => void;
onToggleTag: (tagId: number) => void;
}) {
const { t } = useTranslation();
return (
<div
className={`glass-card glass-hover flex items-center gap-3 p-2.5 rounded-xl transition ${
c.hidden ? "opacity-60" : ""
}`}
>
<Tooltip hint="Your ranking for this channel. Sort the feed by “Channel priority” to bring higher-priority channels to the top.">
<Tooltip hint={t("channels.row.priorityHint")}>
<div className="flex flex-col items-center cursor-help">
<button onClick={() => onPriority(1)} className="text-muted hover:text-accent" aria-label="Raise priority">
<button onClick={() => onPriority(1)} className="text-muted hover:text-accent" aria-label={t("channels.row.raisePriority")}>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<span className="text-xs text-muted tabular-nums">{c.priority}</span>
<button onClick={() => onPriority(-1)} className="text-muted hover:text-accent" aria-label="Lower priority">
<button onClick={() => onPriority(-1)} className="text-muted hover:text-accent" aria-label={t("channels.row.lowerPriority")}>
<ArrowDown className="w-3.5 h-3.5" />
</button>
</div>
@ -395,42 +419,42 @@ function ChannelRow({
{c.title ?? c.id}
</button>
<div className="flex items-center gap-2 text-[11px] text-muted">
<span>{c.stored_videos.toLocaleString()} stored</span>
{c.subscriber_count != null && <span>· {c.subscriber_count.toLocaleString()} subs</span>}
<span>{t("channels.row.stored", { count: c.stored_videos, formatted: c.stored_videos.toLocaleString() })}</span>
{c.subscriber_count != null && <span>· {t("channels.row.subs", { count: c.subscriber_count, formatted: c.subscriber_count.toLocaleString() })}</span>}
</div>
<div className="flex flex-wrap items-center gap-1 mt-1">
<SyncBadge
ok={c.recent_synced}
label="recent"
hint={c.recent_synced ? "Recent uploads synced." : "Recent uploads not fetched yet."}
label={t("channels.row.recent")}
hint={c.recent_synced ? t("channels.row.recentSyncedHint") : t("channels.row.recentNotSyncedHint")}
/>
{c.backfill_done ? (
<SyncBadge ok label="full" hint="Full history fetched." />
<SyncBadge ok label={t("channels.row.full")} hint={t("channels.row.fullHint")} />
) : c.deep_requested ? (
<Tooltip hint="Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.">
<Tooltip hint={t("channels.row.queuedRequestedHint")}>
<button
onClick={onDeep}
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full border border-accent bg-accent text-accent-fg transition"
>
<History className="w-3 h-3" />
full history queued
{t("channels.row.fullHistoryQueued")}
</button>
</Tooltip>
) : c.deep_in_queue ? (
<Tooltip hint="Another subscriber already requested this channel's full history, so its whole back-catalog is on its way to everyone — nothing to do here.">
<Tooltip hint={t("channels.row.queuedByOtherHint")}>
<span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full border border-accent/40 text-accent">
<History className="w-3 h-3" />
full history queued
{t("channels.row.fullHistoryQueued")}
</span>
</Tooltip>
) : (
<Tooltip hint="Only recent uploads so far. Click to request this channel's full back-catalog (older videos + complete search).">
<Tooltip hint={t("channels.row.getFullHistoryHint")}>
<button
onClick={onDeep}
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full border bg-card border-border text-muted hover:border-accent transition"
>
<History className="w-3 h-3" />
get full history
{t("channels.row.getFullHistory")}
</button>
</Tooltip>
)}
@ -454,23 +478,19 @@ function ChannelRow({
</div>
<Tooltip
hint={
c.hidden
? "Hidden — this channel's videos are kept out of your feed. Click to show them again."
: "Hide this channel's videos from your feed. It stays subscribed; this doesn't unsubscribe on YouTube."
}
hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}
>
<button onClick={onHide} className="text-muted hover:text-fg shrink-0" aria-label={c.hidden ? "Unhide" : "Hide from feed"}>
<button onClick={onHide} className="text-muted hover:text-fg shrink-0" aria-label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}>
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</Tooltip>
{canWrite && (
<Tooltip hint="Unsubscribe from this channel on YouTube (changes your real account). Read-only mode hides this — use Hide instead.">
<Tooltip hint={t("channels.row.unsubscribeHint")}>
<button
onClick={onUnsubscribe}
className="text-muted hover:text-red-400 shrink-0"
aria-label="Unsubscribe on YouTube"
aria-label={t("channels.row.unsubscribeOnYoutube")}
>
<UserMinus className="w-4 h-4" />
</button>

View file

@ -1,4 +1,5 @@
import { Component, type ReactNode } from "react";
import i18n from "../i18n";
import { notify } from "../lib/notifications";
interface Props {
@ -20,8 +21,8 @@ export default class ErrorBoundary extends Component<Props, State> {
componentDidCatch(error: Error) {
notify({
level: "fatal",
title: "Something broke",
message: error.message || "Unexpected error",
title: i18n.t("errors.boundary.notifTitle"),
message: error.message || i18n.t("errors.boundary.notifMessage"),
requiresInteraction: true,
});
}
@ -31,13 +32,13 @@ export default class ErrorBoundary extends Component<Props, State> {
return (
<div className="min-h-screen grid place-items-center text-muted p-6 text-center">
<div>
<div className="text-lg font-semibold text-fg mb-1">Something went wrong.</div>
<div className="text-sm mb-3">The app hit an unexpected error.</div>
<div className="text-lg font-semibold text-fg mb-1">{i18n.t("errors.boundary.title")}</div>
<div className="text-sm mb-3">{i18n.t("errors.boundary.subtitle")}</div>
<button
onClick={() => location.reload()}
className="text-accent hover:underline text-sm font-semibold"
>
Reload
{i18n.t("errors.boundary.reload")}
</button>
</div>
</div>

View file

@ -1,6 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type FeedFilters, type Video } from "../lib/api";
import i18n from "../i18n";
import { notify } from "../lib/notifications";
import VideoCard from "./VideoCard";
import PlayerModal from "./PlayerModal";
@ -38,6 +40,7 @@ export default function Feed({
canRead: boolean;
onOpenWizard: () => void;
}) {
const { t } = useTranslation();
const [overrides, setOverrides] = useState<Record<string, string>>({});
// The open player: which video and where to start (null = resume from saved position).
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
@ -103,8 +106,10 @@ export default function Feed({
if (status === "hidden") {
const v = loadedRef.current.find((x) => x.id === id);
notify({
message: v?.title ? `Hidden “${v.title}` : "Video hidden",
action: { label: "Undo", onClick: () => onState(id, "new") },
message: v?.title
? i18n.t("feed.hiddenNamed", { title: v.title })
: i18n.t("feed.hidden"),
action: { label: i18n.t("feed.undo"), onClick: () => onState(id, "new") },
meta: {
kind: "video-hidden",
videoId: id,
@ -116,8 +121,10 @@ export default function Feed({
} else if (status === "watched") {
const v = loadedRef.current.find((x) => x.id === id);
notify({
message: v?.title ? `Marked watched “${v.title}` : "Marked watched",
action: { label: "Unwatch", onClick: () => onState(id, "new") },
message: v?.title
? i18n.t("feed.markedWatchedNamed", { title: v.title })
: i18n.t("feed.markedWatched"),
action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") },
meta: { kind: "video-watched", videoId: id, title: v?.title ?? "this video" },
});
}
@ -138,34 +145,35 @@ export default function Feed({
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
.filter((v) => matchesView(v.status, filters.show));
if (query.isLoading) return <div className="p-8 text-muted">Loading feed</div>;
if (query.isError) return <div className="p-8 text-muted">Couldn't load the feed.</div>;
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
if (query.isError) return <div className="p-8 text-muted">{t("feed.loadError")}</div>;
if (items.length === 0) {
if (!canRead)
return (
<div className="p-8 grid place-items-center text-center">
<div className="max-w-sm">
<h2 className="text-lg font-semibold">Your feed is empty</h2>
<p className="text-sm text-muted mt-2 mb-4">
Connect your YouTube account to import your subscriptions and build your feed.
</p>
<h2 className="text-lg font-semibold">{t("feed.emptyTitle")}</h2>
<p className="text-sm text-muted mt-2 mb-4">{t("feed.emptyBody")}</p>
<button
onClick={onOpenWizard}
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
Set up my feed
{t("feed.setUp")}
</button>
</div>
</div>
);
return <div className="p-8 text-muted">No videos match these filters.</div>;
return <div className="p-8 text-muted">{t("feed.noMatches")}</div>;
}
return (
<div className="p-4">
<div className="pb-3 text-sm text-muted">
{countQuery.data
? `${countQuery.data.count.toLocaleString()} video${countQuery.data.count === 1 ? "" : "s"}`
? t("feed.videoCount", {
count: countQuery.data.count,
formattedCount: countQuery.data.count.toLocaleString(),
})
: " "}
</div>
{view === "grid" ? (
@ -205,7 +213,7 @@ export default function Feed({
)}
<div ref={sentinel} className="h-10" />
{isFetchingNextPage && (
<div className="text-center text-muted py-4">Loading more</div>
<div className="text-center text-muted py-4">{t("feed.loadingMore")}</div>
)}
</div>
);

View file

@ -1,9 +1,12 @@
import { useRef, useState } from "react";
import { BarChart3, Home, LogOut, Search, Settings, Shield, Tv } from "lucide-react";
import { useTranslation } from "react-i18next";
import { BarChart3, Home, Info, LogOut, Search, Settings, Shield, Tv } from "lucide-react";
import type { FeedFilters, Me } from "../lib/api";
import type { Page } from "../lib/urlState";
import { type LangCode } from "../i18n";
import SyncStatus from "./SyncStatus";
import NotificationCenter from "./NotificationCenter";
import LanguageSwitcher from "./LanguageSwitcher";
import AvatarImg from "./Avatar";
export default function Header({
@ -13,6 +16,8 @@ export default function Header({
page,
setPage,
onOpenSettings,
onOpenAbout,
onChangeLanguage,
onGoToFullHistory,
}: {
me: Me;
@ -21,8 +26,12 @@ export default function Header({
page: Page;
setPage: (p: Page) => void;
onOpenSettings: () => void;
onOpenAbout: () => void;
onChangeLanguage: (code: LangCode) => void;
onGoToFullHistory: () => void;
}) {
const { t, i18n } = useTranslation();
async function logout() {
await fetch("/auth/logout", { method: "POST", credentials: "include" });
location.reload();
@ -33,7 +42,7 @@ export default function Header({
<button
onClick={() => setPage("feed")}
className="text-xl font-bold tracking-tight select-none"
title="Feed"
title={t("header.feed")}
>
Sift<span className="text-accent">lode</span>
</button>
@ -46,17 +55,18 @@ export default function Header({
<input
value={filters.q}
onChange={(e) => setFilters({ ...filters, q: e.target.value })}
placeholder="Search your subscriptions…"
placeholder={t("header.searchPlaceholder")}
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
/>
</div>
) : (
<div className="flex-1 text-center text-sm font-semibold">
{page === "stats" ? "Usage & stats" : "Channel manager"}
{page === "stats" ? t("header.usageStats") : t("header.channelManager")}
</div>
)}
<div className="flex items-center gap-1">
<LanguageSwitcher value={i18n.language as LangCode} onChange={onChangeLanguage} />
<NotificationCenter filters={filters} setFilters={setFilters} />
<div className="pl-1">
<AccountMenu
@ -65,6 +75,7 @@ export default function Header({
page={page}
setPage={setPage}
onOpenSettings={onOpenSettings}
onOpenAbout={onOpenAbout}
/>
</div>
</div>
@ -88,13 +99,16 @@ function AccountMenu({
page,
setPage,
onOpenSettings,
onOpenAbout,
}: {
me: Me;
logout: () => void;
page: Page;
setPage: (p: Page) => void;
onOpenSettings: () => void;
onOpenAbout: () => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -134,7 +148,7 @@ function AccountMenu({
{me.role === "admin" && (
<div className="flex items-center gap-1.5 mt-2 text-[11px] font-medium text-accent">
<Shield className="w-3.5 h-3.5" />
Admin
{t("header.account.admin")}
</div>
)}
@ -142,28 +156,32 @@ function AccountMenu({
{page !== "feed" && (
<button onClick={() => { setPage("feed"); setOpen(false); }} className={itemClass}>
<Home className="w-4 h-4" />
Feed
{t("header.account.feed")}
</button>
)}
{page !== "channels" && (
<button onClick={() => { setPage("channels"); setOpen(false); }} className={itemClass}>
<Tv className="w-4 h-4" />
Channels
{t("header.account.channels")}
</button>
)}
{me.role === "admin" && page !== "stats" && (
<button onClick={() => { setPage("stats"); setOpen(false); }} className={itemClass}>
<BarChart3 className="w-4 h-4" />
Stats
{t("header.account.stats")}
</button>
)}
<button onClick={() => { onOpenSettings(); setOpen(false); }} className={itemClass}>
<Settings className="w-4 h-4" />
Settings
{t("header.account.settings")}
</button>
<button onClick={() => { onOpenAbout(); setOpen(false); }} className={itemClass}>
<Info className="w-4 h-4" />
{t("header.account.about")}
</button>
<button onClick={logout} className={itemClass}>
<LogOut className="w-4 h-4" />
Sign out
{t("header.account.signOut")}
</button>
</div>
</div>

View file

@ -0,0 +1,62 @@
import { useRef, useState } from "react";
import { Check, Globe } from "lucide-react";
import { LANGUAGES, type LangCode } from "../i18n";
// Compact language picker (globe + current code). Presentational: the parent decides what
// changing the language does (set it; persist server-side when signed in).
export default function LanguageSwitcher({
value,
onChange,
align = "right",
}: {
value: LangCode;
onChange: (code: LangCode) => void;
align?: "left" | "right";
}) {
const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const current = LANGUAGES.find((l) => l.code === value) ?? LANGUAGES[0];
function openNow() {
if (closeTimer.current) clearTimeout(closeTimer.current);
setOpen(true);
}
function closeSoon() {
closeTimer.current = setTimeout(() => setOpen(false), 200);
}
return (
<div className="relative" onMouseEnter={openNow} onMouseLeave={closeSoon}>
<button
onClick={() => setOpen((o) => !o)}
title={current.label}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-card transition"
>
<Globe className="w-4 h-4" />
<span className="text-xs font-semibold uppercase">{current.code}</span>
</button>
{open && (
<div
className={`glass absolute ${
align === "right" ? "right-0" : "left-0"
} mt-1 w-40 rounded-xl p-1.5 z-40 animate-[popIn_0.16s_ease]`}
>
{LANGUAGES.map((l) => (
<button
key={l.code}
onClick={() => {
onChange(l.code);
setOpen(false);
}}
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<span>{l.label}</span>
{l.code === value && <Check className="w-4 h-4 text-accent" />}
</button>
))}
</div>
)}
</div>
);
}

View file

@ -1,9 +1,14 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../lib/api";
import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher";
type Phase = "idle" | "sending" | "requested" | "approved" | "error";
export default function Login() {
const { t, i18n } = useTranslation();
// A denied Google login bounces back here with ?access=requested (we recorded it) or
// ?access=denied (no usable email). Surface that so the user isn't left on a raw error.
const params = new URLSearchParams(window.location.search);
@ -21,7 +26,7 @@ export default function Login() {
const r = await api.requestAccess(email.trim());
setPhase(r.status === "approved" ? "approved" : "requested");
} catch {
setError("Couldn't submit — check the address and try again.");
setError(t("login.submitError"));
setPhase("error");
}
}
@ -30,40 +35,33 @@ export default function Login() {
return (
<div className="min-h-screen grid place-items-center bg-bg text-fg">
<div className="absolute top-4 right-4">
<LanguageSwitcher value={i18n.language as LangCode} onChange={setLanguage} />
</div>
<div className="glass w-[min(92vw,420px)] rounded-2xl p-10 text-center">
<div className="text-3xl font-bold tracking-tight">
Sift<span className="text-accent">lode</span>
</div>
<p className="text-muted my-5 leading-relaxed">
A self-hosted, filterable feed of your YouTube subscriptions sort, tag, and
tune out the noise. Sign in with Google to get started; YouTube access is an
optional, separate step you control.
</p>
<p className="text-muted my-5 leading-relaxed">{t("login.tagline")}</p>
<a
href="/auth/login"
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
Sign in with Google
{t("login.signIn")}
</a>
<div className="mt-7 pt-6 border-t border-border text-left">
{phase === "approved" ? (
<p className="text-sm text-fg/80">
You're already approved just sign in with Google above.
</p>
<p className="text-sm text-fg/80">{t("login.approved")}</p>
) : done ? (
<p className="text-sm text-fg/80">
Thanks your request is in. We'll email you when it's approved.
</p>
<p className="text-sm text-fg/80">{t("login.requested")}</p>
) : (
<>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
No access yet?
{t("login.noAccessYet")}
</div>
{bounced === "denied" && (
<p className="text-xs text-muted mb-2">
That Google account isn't approved. Request access below.
</p>
<p className="text-xs text-muted mb-2">{t("login.denied")}</p>
)}
<form onSubmit={submit} className="flex items-center gap-2">
<input
@ -71,7 +69,7 @@ export default function Login() {
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@gmail.com"
placeholder={t("login.emailPlaceholder")}
className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
/>
<button
@ -79,7 +77,7 @@ export default function Login() {
disabled={phase === "sending"}
className="shrink-0 px-3 py-2 rounded-xl text-sm font-semibold bg-card border border-border hover:border-accent disabled:opacity-50 transition"
>
{phase === "sending" ? "Sending…" : "Request access"}
{phase === "sending" ? t("login.sending") : t("login.requestAccess")}
</button>
</form>
{error && <p className="text-xs text-red-400 mt-2">{error}</p>}
@ -88,8 +86,12 @@ export default function Login() {
</div>
</div>
<footer className="mt-5 flex gap-4 text-xs text-muted">
<a href="/privacy" className="hover:text-fg transition">Privacy Policy</a>
<a href="/terms" className="hover:text-fg transition">Terms of Service</a>
<a href="/privacy" className="hover:text-fg transition">
{t("common.privacyPolicy")}
</a>
<a href="/terms" className="hover:text-fg transition">
{t("common.termsOfService")}
</a>
</footer>
</div>
);

View file

@ -0,0 +1,56 @@
import { useEffect, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
// Small centered modal shell (portaled to <body>): backdrop + ESC + scroll-lock close.
export default function Modal({
title,
onClose,
children,
maxWidth = "max-w-lg",
}: {
title: ReactNode;
onClose: () => void;
children: ReactNode;
maxWidth?: string;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [onClose]);
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/70 backdrop-blur-sm"
onClick={onClose}
role="dialog"
aria-modal="true"
>
<div
className={`glass-card relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl shadow-2xl`}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-3 p-5 pb-3">
<h2 className="text-lg font-semibold leading-snug">{title}</h2>
<button
onClick={onClose}
title="Close"
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="px-5 pb-5">{children}</div>
</div>
</div>,
document.body
);
}

View file

@ -1,4 +1,6 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Bell, Eye, RotateCcw, Search, Trash2 } from "lucide-react";
import { api, type FeedFilters } from "../lib/api";
@ -15,15 +17,15 @@ import {
} from "../lib/notifications";
import { LEVEL_STYLE } from "./Toaster";
function relTime(ts: number): string {
function relTime(ts: number, t: TFunction): string {
const s = Math.round((Date.now() - ts) / 1000);
if (s < 60) return "just now";
if (s < 60) return t("notifications.time.justNow");
const m = Math.round(s / 60);
if (m < 60) return `${m}m ago`;
if (m < 60) return t("notifications.time.minutes", { count: m });
const h = Math.round(m / 60);
if (h < 24) return `${h}h ago`;
if (h < 24) return t("notifications.time.hours", { count: h });
const d = Math.round(h / 24);
return `${d}d ago`;
return t("notifications.time.days", { count: d });
}
export default function NotificationCenter({
@ -33,6 +35,7 @@ export default function NotificationCenter({
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
}) {
const { t } = useTranslation();
const notifications = useSyncExternalStore(subscribe, getNotifications, getNotifications);
const unread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
const [open, setOpen] = useState(false);
@ -64,13 +67,16 @@ export default function NotificationCenter({
setOpen(false);
}
function revertState(meta: VideoHiddenMeta | VideoWatchedMeta, verb: string) {
function revertState(
meta: VideoHiddenMeta | VideoWatchedMeta,
messageKey: "notifications.unhidden" | "notifications.unwatched"
) {
api
.setState(meta.videoId, "new")
.then(() => {
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["feed-count"] });
notify({ level: "success", message: `${verb}${meta.title}` });
notify({ level: "success", message: t(messageKey, { title: meta.title }) });
})
.catch(() => {});
}
@ -79,7 +85,7 @@ export default function NotificationCenter({
<div className="relative" ref={wrap}>
<button
onClick={() => setOpen((o) => !o)}
title="Notifications"
title={t("notifications.title")}
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Bell className="w-5 h-5" />
@ -93,22 +99,22 @@ export default function NotificationCenter({
{open && (
<div className="glass absolute right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] rounded-xl z-30 flex flex-col max-h-[70vh] animate-[popIn_0.16s_ease]">
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<div className="text-sm font-semibold">Notifications</div>
<div className="text-sm font-semibold">{t("notifications.title")}</div>
{notifications.length > 0 && (
<button
onClick={clearAll}
className="flex items-center gap-1 text-xs text-muted hover:text-accent transition"
title="Clear all"
title={t("notifications.clearAll")}
>
<Trash2 className="w-3.5 h-3.5" />
Clear
{t("notifications.clear")}
</button>
)}
</div>
<div className="overflow-y-auto">
{notifications.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-muted">No notifications yet.</div>
<div className="px-3 py-8 text-center text-sm text-muted">{t("notifications.empty")}</div>
) : (
notifications.map((n) => {
const { icon: Icon, color } = LEVEL_STYLE[n.level];
@ -127,10 +133,10 @@ export default function NotificationCenter({
{n.title && <div className="text-sm font-semibold">{n.title}</div>}
<div className="text-sm break-words">{n.message}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[11px] text-muted">{relTime(n.ts)}</span>
<span className="text-[11px] text-muted">{relTime(n.ts, t)}</span>
{needsAction && (
<span className="text-[10px] uppercase tracking-wide font-semibold text-accent">
Action needed
{t("notifications.actionNeeded")}
</span>
)}
</div>
@ -142,24 +148,24 @@ export default function NotificationCenter({
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Search className="w-3.5 h-3.5" />
Find in feed
{t("notifications.findInFeed")}
</button>
<button
onClick={() => revertState(hidden, "Unhidden")}
onClick={() => revertState(hidden, "notifications.unhidden")}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Eye className="w-3.5 h-3.5" />
Unhide
{t("notifications.unhide")}
</button>
</div>
) : watched ? (
<div className="flex items-center gap-3 mt-1">
<button
onClick={() => revertState(watched, "Unwatched")}
onClick={() => revertState(watched, "notifications.unwatched")}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<RotateCcw className="w-3.5 h-3.5" />
Unwatch
{t("notifications.unwatch")}
</button>
</div>
) : (

View file

@ -1,4 +1,5 @@
import { useEffect, useRef } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
import { api, type Me } from "../lib/api";
@ -20,20 +21,20 @@ function ConsentHeadsUp() {
<div className="flex gap-2.5 rounded-xl border border-amber-400/30 bg-amber-400/5 p-3 text-left">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-amber-400" />
<p className="text-xs leading-relaxed text-fg/80">
Google will show a <span className="font-medium">"Google hasn't verified this app"</span>{" "}
screen that's expected, because Siftlode hasn't gone through Google's full review.
It's safe to continue: click <span className="font-medium">Advanced</span> {" "}
<span className="font-medium">Go to Siftlode</span>. You can revoke access anytime from
your{" "}
<a
href="https://myaccount.google.com/permissions"
target="_blank"
rel="noreferrer"
className="text-accent hover:underline"
>
Google Account
</a>
.
<Trans
i18nKey="onboarding.consent"
components={[
<span className="font-medium" />,
<span className="font-medium" />,
<span className="font-medium" />,
<a
href="https://myaccount.google.com/permissions"
target="_blank"
rel="noreferrer"
className="text-accent hover:underline"
/>,
]}
/>
</p>
</div>
);
@ -55,6 +56,7 @@ function Dots({ index, total }: { index: number; total: number }) {
}
export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const importTriggered = useRef(false);
@ -126,7 +128,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
<button
onClick={close}
className="absolute top-3 right-3 p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
aria-label="Close"
aria-label={t("onboarding.close")}
>
<X className="w-4 h-4" />
</button>
@ -136,11 +138,12 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Youtube className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold">Connect your YouTube subscriptions</h2>
<h2 className="text-lg font-semibold">{t("onboarding.read.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
You're signed in. To build your feed, Siftlode needs{" "}
<span className="text-fg/90">read-only</span> access to the channels you're
subscribed to on YouTube. It never posts, deletes, or changes anything with this.
<Trans
i18nKey="onboarding.read.body"
components={[<span className="text-fg/90" />]}
/>
</p>
<ConsentHeadsUp />
<div className="mt-5 flex flex-col gap-2">
@ -148,13 +151,13 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
onClick={() => beginGrant("read")}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
<Youtube className="w-4 h-4" /> Connect (read-only)
<Youtube className="w-4 h-4" /> {t("onboarding.read.connect")}
</button>
<button
onClick={close}
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
>
Skip for now
{t("onboarding.read.skip")}
</button>
</div>
</>
@ -166,10 +169,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
<h2 className="text-lg font-semibold">Building your feed</h2>
<h2 className="text-lg font-semibold">{t("onboarding.importing.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-2">
Importing your YouTube subscriptions. Channels already in Siftlode show up
instantly; any new ones fill in automatically in the background.
{t("onboarding.importing.body")}
</p>
</>
)}
@ -179,24 +181,24 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Check className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold">You're connected</h2>
<h2 className="text-lg font-semibold">{t("onboarding.write.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
{importSubs.isError ? (
<>Your YouTube account is connected, but importing subscriptions failed you
can retry from Settings Sync. </>
<>{t("onboarding.write.importFailed")}</>
) : (
<>
Your feed is ready
{myStatus.data ? ` (${myStatus.data.channels_total} channels)` : ""}. Optionally,
let Siftlode{" "}
{t("onboarding.write.feedReady", {
channels: myStatus.data
? t("onboarding.write.feedReadyChannels", { count: myStatus.data.channels_total })
: "",
})}
</>
)}
{!importSubs.isError && (
<>
<span className="text-fg/90">unsubscribe</span> from channels on YouTube for you
this needs an extra write permission. Skip it to stay read-only; you can
always enable it later in Settings.
</>
<Trans
i18nKey="onboarding.write.rationale"
components={[<span className="text-fg/90" />]}
/>
)}
</p>
{importSubs.isError && (
@ -204,7 +206,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
onClick={() => importSubs.mutate()}
className="mb-3 inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl text-sm bg-card border border-border hover:border-accent transition"
>
<RefreshCw className="w-4 h-4" /> Retry import
<RefreshCw className="w-4 h-4" /> {t("onboarding.write.retryImport")}
</button>
)}
<ConsentHeadsUp />
@ -213,7 +215,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
onClick={() => beginGrant("write")}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
<ShieldCheck className="w-4 h-4" /> Enable unsubscribe
<ShieldCheck className="w-4 h-4" /> {t("onboarding.write.enableUnsubscribe")}
</button>
<button
onClick={() => {
@ -222,7 +224,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
}}
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
>
No thanks keep it read-only
{t("onboarding.write.keepReadOnly")}
</button>
</div>
</>
@ -233,10 +235,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Check className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold">All set</h2>
<h2 className="text-lg font-semibold">{t("onboarding.done.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-5">
Read and unsubscribe access are both granted. You can review or revoke either one
anytime in Settings Account, or from your Google Account.
{t("onboarding.done.body")}
</p>
<button
onClick={() => {
@ -245,7 +246,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
}}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition w-full"
>
Done
{t("onboarding.done.done")}
</button>
</>
)}

View file

@ -1,4 +1,6 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Check, CheckCheck, X } from "lucide-react";
@ -65,8 +67,10 @@ function renderDescription(
currentId: string;
onSeek: (seconds: number) => void;
onLoadVideo: (id: string, start: number | null) => void;
t: TFunction;
}
): ReactNode[] {
const { t } = opts;
const out: ReactNode[] = [];
let key = 0;
const linkCls = "text-accent hover:underline break-all";
@ -86,7 +90,7 @@ function renderDescription(
sameVideo ? opts.onSeek(yt.start ?? 0) : opts.onLoadVideo(yt.id, yt.start)
}
className={linkCls}
title={sameVideo ? "Jump to this time" : "Play in the in-app player"}
title={sameVideo ? t("player.jumpToTime") : t("player.playInApp")}
>
{href}
</button>
@ -189,6 +193,7 @@ export default function PlayerModal({
onClose: () => void;
onState: (id: string, status: string) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
// Resume point for the video we opened with.
const resumeAt = startAt != null ? startAt : video.position_seconds || 0;
@ -395,17 +400,17 @@ export default function PlayerModal({
onMouseEnter={openDesc}
onMouseLeave={scheduleCloseDesc}
>
{navigated ? liveData?.title ?? "Loading…" : video.title}
{navigated ? liveData?.title ?? t("player.loading") : video.title}
</span>
</h2>
{navigated && (
<button
onClick={() => loadVideo(video.id, null)}
title="Back to the original video"
title={t("player.backToOriginal")}
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
>
<ArrowLeft className="w-4 h-4" />
Back
{t("player.back")}
</button>
)}
{showDesc &&
@ -422,30 +427,31 @@ export default function PlayerModal({
onMouseLeave={scheduleCloseDesc}
>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
Description
{t("player.description")}
</div>
{detail.isLoading ? (
<div className="text-sm text-muted">Loading</div>
<div className="text-sm text-muted">{t("player.loading")}</div>
) : detail.data?.description ? (
<div className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto leading-relaxed">
{renderDescription(detail.data.description, {
currentId: currentVideoId,
onSeek: seekTo,
onLoadVideo: loadVideo,
t,
})}
</div>
) : (
<div className="text-sm text-muted">No description.</div>
<div className="text-sm text-muted">{t("player.noDescription")}</div>
)}
</div>,
document.body
)}
<button
onClick={onClose}
title="Close (Esc)"
title={t("player.closeEsc")}
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
>
Close
{t("player.close")}
<X className="w-4 h-4" />
</button>
</div>
@ -469,7 +475,7 @@ export default function PlayerModal({
rel="noreferrer"
className="font-medium hover:text-accent shrink-0"
>
{liveData?.author ?? detail.data.channel_title ?? "Channel"}
{liveData?.author ?? detail.data.channel_title ?? t("player.channel")}
</a>
) : (
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
@ -486,14 +492,16 @@ export default function PlayerModal({
)}
{!navigated ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
{video.view_count != null && <span>· {formatViews(video.view_count)} views</span>}
{video.view_count != null && (
<span>· {t("player.views", { count: video.view_count, formattedCount: formatViews(video.view_count) })}</span>
)}
<span>· {relativeTime(video.published_at)}</span>
{video.duration_seconds != null && (
<span>· {formatDuration(video.duration_seconds)}</span>
)}
{video.live_status === "was_live" && (
<span className="bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
stream
{t("player.stream")}
</span>
)}
</div>
@ -501,7 +509,7 @@ export default function PlayerModal({
// Linked video's stats come from the (already-fetched) video detail.
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
{detail.data?.view_count != null && (
<span>· {formatViews(detail.data.view_count)} views</span>
<span>· {t("player.views", { count: detail.data.view_count, formattedCount: formatViews(detail.data.view_count) })}</span>
)}
{detail.data?.published_at && <span>· {relativeTime(detail.data.published_at)}</span>}
{detail.data?.duration_seconds != null && (
@ -513,7 +521,7 @@ export default function PlayerModal({
{!navigated && (
<button
onClick={() => setWatched(!watched)}
title={watched ? "Watched — click to unmark" : "Mark watched"}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
className={
"ml-auto shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
(watched
@ -522,7 +530,7 @@ export default function PlayerModal({
}
>
{watched ? <CheckCheck className="w-4 h-4" /> : <Check className="w-4 h-4" />}
{watched ? "Watched" : "Mark watched"}
{watched ? t("player.watched") : t("player.markWatched")}
</button>
)}
</div>

View file

@ -0,0 +1,78 @@
import { useTranslation } from "react-i18next";
import { Bug, Sparkles } from "lucide-react";
import { RELEASE_NOTES } from "../lib/releaseNotes";
import Modal from "./Modal";
function fmtDate(d: string): string {
const t = new Date(d);
return isNaN(t.getTime())
? d
: t.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
// Public release notes (chores are intentionally omitted here). `highlight` rings the
// version the user just updated to when opened from the new-version banner.
export default function ReleaseNotes({
onClose,
highlight,
}: {
onClose: () => void;
highlight?: string;
}) {
const { t } = useTranslation();
return (
<Modal title={t("about.releaseNotes")} onClose={onClose} maxWidth="max-w-2xl">
<div className="flex flex-col gap-6">
{RELEASE_NOTES.map((r) => (
<section
key={r.version}
className={highlight === r.version ? "rounded-xl ring-1 ring-accent/40 p-3 -m-3" : ""}
>
<div className="flex items-baseline gap-2 flex-wrap">
<h3 className="text-base font-semibold">v{r.version}</h3>
<span className="text-xs text-muted">{fmtDate(r.date)}</span>
{r.sha && (
<span className="text-[11px] font-mono text-muted bg-surface px-1.5 py-0.5 rounded">
{r.sha}
</span>
)}
</div>
{r.summary && <p className="text-sm text-muted mt-1">{r.summary}</p>}
{r.features?.length ? (
<div className="mt-3">
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-accent">
<Sparkles className="w-3.5 h-3.5" /> {t("about.new")}
</div>
<ul className="mt-1.5 space-y-1.5 text-sm">
{r.features.map((f, i) => (
<li key={i} className="flex gap-2">
<span className="text-accent"></span>
<span>{f}</span>
</li>
))}
</ul>
</div>
) : null}
{r.fixes?.length ? (
<div className="mt-3">
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted">
<Bug className="w-3.5 h-3.5" /> {t("about.fixes")}
</div>
<ul className="mt-1.5 space-y-1.5 text-sm">
{r.fixes.map((f, i) => (
<li key={i} className="flex gap-2">
<span className="text-muted"></span>
<span>{f}</span>
</li>
))}
</ul>
</div>
) : null}
</section>
))}
</div>
</Modal>
);
}

View file

@ -1,4 +1,5 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, User, UserPlus, X } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
@ -15,11 +16,11 @@ import { hintsEnabled, setHintsEnabled, subscribeHints } from "../lib/hints";
import Tooltip from "./Tooltip";
type TabId = "appearance" | "notifications" | "sync" | "account";
const TABS: { id: TabId; label: string; icon: typeof Monitor }[] = [
{ id: "appearance", label: "Appearance", icon: Monitor },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "sync", label: "Sync", icon: RefreshCw },
{ id: "account", label: "Account", icon: User },
const TABS: { id: TabId; icon: typeof Monitor }[] = [
{ id: "appearance", icon: Monitor },
{ id: "notifications", icon: Bell },
{ id: "sync", icon: RefreshCw },
{ id: "account", icon: User },
];
export default function SettingsPanel({
@ -39,6 +40,7 @@ export default function SettingsPanel({
onClose: () => void;
onOpenWizard: () => void;
}) {
const { t } = useTranslation();
const [tab, setTab] = useState<TabId>("appearance");
const [closing, setClosing] = useState(false);
@ -71,7 +73,7 @@ export default function SettingsPanel({
}`}
>
<div className="flex items-center justify-between px-4 h-14 border-b border-border/60 shrink-0">
<div className="text-base font-semibold">Settings</div>
<div className="text-base font-semibold">{t("settings.title")}</div>
<button
onClick={close}
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
@ -83,20 +85,20 @@ export default function SettingsPanel({
<div className="flex flex-1 min-h-0">
{/* Vertical tab rail — never wraps; active is a clear accent pill. */}
<nav className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1">
{TABS.map((t) => {
const active = tab === t.id;
{TABS.map((tabItem) => {
const active = tab === tabItem.id;
return (
<button
key={t.id}
onClick={() => setTab(t.id)}
key={tabItem.id}
onClick={() => setTab(tabItem.id)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition ${
active
? "bg-accent text-accent-fg font-medium shadow-md shadow-accent/20"
: "text-muted hover:text-fg hover:bg-card/60"
}`}
>
<t.icon className="w-4 h-4 shrink-0" />
{t.label}
<tabItem.icon className="w-4 h-4 shrink-0" />
{t(`settings.tabs.${tabItem.id}`)}
</button>
);
})}
@ -105,19 +107,19 @@ export default function SettingsPanel({
{/* All tabs stacked in one grid cell so the panel sizes to the tallest tab
(stable height, no jump on switch); the active one is shown on top. */}
<div className="grid flex-1 overflow-y-auto">
{TABS.map((t) => (
{TABS.map((tabItem) => (
<div
key={t.id}
key={tabItem.id}
className={`[grid-area:1/1] p-4 ${
tab === t.id ? "" : "invisible pointer-events-none"
tab === tabItem.id ? "" : "invisible pointer-events-none"
}`}
>
{t.id === "appearance" && (
{tabItem.id === "appearance" && (
<Appearance theme={theme} setTheme={setTheme} view={view} setView={setView} />
)}
{t.id === "notifications" && <Notifications />}
{t.id === "sync" && <Sync me={me} />}
{t.id === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
{tabItem.id === "notifications" && <Notifications />}
{tabItem.id === "sync" && <Sync me={me} />}
{tabItem.id === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
</div>
))}
</div>
@ -190,6 +192,7 @@ function Appearance({
view: "grid" | "list";
setView: (v: "grid" | "list") => void;
}) {
const { t } = useTranslation();
const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1");
const hints = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
@ -208,7 +211,7 @@ function Appearance({
return (
<>
<Section title="Color scheme">
<Section title={t("settings.appearance.colorScheme")}>
<div className="grid grid-cols-4 gap-2">
{SCHEMES.map((s) => (
<Tooltip key={s.id} hint={s.name}>
@ -224,31 +227,31 @@ function Appearance({
</div>
</Section>
<Section title="Display">
<Row label="Dark mode">
<Section title={t("settings.appearance.display")}>
<Row label={t("settings.appearance.darkMode")}>
<Switch
checked={theme.mode === "dark"}
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
/>
</Row>
<Row label="List view" hint="Show the feed as a compact list instead of a grid of cards.">
<Row label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
<Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} />
</Row>
<Row
label="Performance mode"
hint="Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines."
label={t("settings.appearance.performanceMode")}
hint={t("settings.appearance.performanceModeHint")}
>
<Switch checked={perf} onChange={togglePerf} />
</Row>
<Row
label="Show hints"
hint="These little hover explanations. Turn them off once you know your way around."
label={t("settings.appearance.showHints")}
hint={t("settings.appearance.showHintsHint")}
>
<Switch checked={hints} onChange={toggleHints} />
</Row>
</Section>
<Section title="Text size">
<Section title={t("settings.appearance.textSize")}>
<input
type="range"
min={0.9}
@ -265,6 +268,7 @@ function Appearance({
}
function Notifications() {
const { t } = useTranslation();
const [cfg, setCfg] = useState<NotifSettings>(() => getNotifSettings());
function update(p: Partial<NotifSettings>) {
const next = { ...cfg, ...p };
@ -275,23 +279,23 @@ function Notifications() {
const autoOn = cfg.durationMs > 0;
return (
<Section title="Notifications">
<Section title={t("settings.notifications.title")}>
<Row
label="Sound on alerts"
hint="Play a short tone when a notification needs your attention (e.g. errors, prompts)."
label={t("settings.notifications.sound")}
hint={t("settings.notifications.soundHint")}
>
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} />
</Row>
<Row
label="Auto-dismiss toasts"
hint="When off, pop-up toasts stay until you close them. When on, they fade after the set time."
label={t("settings.notifications.autoDismiss")}
hint={t("settings.notifications.autoDismissHint")}
>
<Switch checked={autoOn} onChange={(v) => update({ durationMs: v ? 6000 : 0 })} />
</Row>
{autoOn && (
<div className="py-1.5 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted text-xs">Dismiss after</span>
<span className="text-muted text-xs">{t("settings.notifications.dismissAfter")}</span>
<span className="text-muted text-xs">{(cfg.durationMs / 1000).toFixed(0)}s</span>
</div>
<input
@ -309,20 +313,21 @@ function Notifications() {
onClick={() =>
notify({
level: "info",
title: "Test notification",
message: "This is what a notification looks like.",
title: t("settings.notifications.testTitle"),
message: t("settings.notifications.testMessage"),
sound: true,
})
}
className="mt-2 text-sm text-accent hover:underline"
>
Send a test notification
{t("settings.notifications.sendTest")}
</button>
</Section>
);
}
function Sync({ me }: { me: Me }) {
const { t } = useTranslation();
const qc = useQueryClient();
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage });
@ -332,9 +337,12 @@ function Sync({ me }: { me: Me }) {
onSuccess: (r: { subscriptions?: number }) => {
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: ["channels"] });
notify({ level: "success", message: `Synced ${r.subscriptions ?? 0} subscriptions` });
notify({
level: "success",
message: t("settings.sync.synced", { count: r.subscriptions ?? 0 }),
});
},
onError: () => notify({ level: "error", message: "Sync failed" }),
onError: () => notify({ level: "error", message: t("settings.sync.syncFailed") }),
});
const pauseResume = useMutation({
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
@ -347,69 +355,69 @@ function Sync({ me }: { me: Me }) {
qc.invalidateQueries({ queryKey: ["channels"] });
notify({
level: "success",
message: `Full history requested for ${r.updated ?? 0} channels`,
message: t("settings.sync.fullHistoryRequested", { count: r.updated ?? 0 }),
});
},
onError: () => notify({ level: "error", message: "Couldn't request full history" }),
onError: () => notify({ level: "error", message: t("settings.sync.fullHistoryFailed") }),
});
return (
<>
<Section title="My sync status">
<Section title={t("settings.sync.myStatus")}>
{s ? (
<div className="space-y-1.5 text-sm">
<Row label="Channels" hint="How many channels you're subscribed to.">
<Row label={t("settings.sync.channels")} hint={t("settings.sync.channelsHint")}>
{s.channels_total}
</Row>
<Row
label="Recent synced"
hint="Channels whose recent uploads (~last year) are already in the local database, so they show in your feed."
label={t("settings.sync.recentSynced")}
hint={t("settings.sync.recentSyncedHint")}
>
{`${s.channels_recent_synced}/${s.channels_total}`}
</Row>
<Row
label="Full history"
hint="Channels whose entire back-catalog has been fetched, out of the ones you've requested full history for (opt in per channel on the Channels page)."
label={t("settings.sync.fullHistory")}
hint={t("settings.sync.fullHistoryHint")}
>
{`${s.channels_deep_done}/${s.channels_deep_requested}`}
</Row>
{s.deep_pending_count > 0 && (
<Row
label="Full history ETA"
hint={`Rough estimate to finish full-history backfill of ${s.deep_pending_count} pending channel(s), based on the shared daily quota.`}
label={t("settings.sync.fullHistoryEta")}
hint={t("settings.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
>
{formatEta(s.deep_eta_seconds)}
</Row>
)}
<Row label="My videos" hint="Total videos available to you across your subscribed channels.">
<Row label={t("settings.sync.myVideos")} hint={t("settings.sync.myVideosHint")}>
{s.my_videos.toLocaleString()}
</Row>
<Row
label="Quota left today"
hint="Shared YouTube API budget remaining today (resets midnight US Pacific). Backfill yields to it."
label={t("settings.sync.quotaLeft")}
hint={t("settings.sync.quotaLeftHint")}
>
{s.quota_remaining_today.toLocaleString()}
</Row>
</div>
) : (
<div className="text-muted text-sm">Loading</div>
<div className="text-muted text-sm">{t("settings.sync.loading")}</div>
)}
</Section>
<Section title="Your API usage">
<Section title={t("settings.sync.apiUsage")}>
{usage.data ? (
<>
<div className="space-y-1.5 text-sm">
<Row label="Today" hint="YouTube API units your own actions used today (resets midnight US Pacific). Background sync isn't counted here.">
<Row label={t("settings.sync.today")} hint={t("settings.sync.todayHint")}>
{usage.data.today.toLocaleString()}
</Row>
<Row label="Last 7 days">{usage.data.last_7d.toLocaleString()}</Row>
<Row label="Last 30 days">{usage.data.last_30d.toLocaleString()}</Row>
<Row label="All time">{usage.data.all_time.toLocaleString()}</Row>
<Row label={t("settings.sync.last7d")}>{usage.data.last_7d.toLocaleString()}</Row>
<Row label={t("settings.sync.last30d")}>{usage.data.last_30d.toLocaleString()}</Row>
<Row label={t("settings.sync.allTime")}>{usage.data.all_time.toLocaleString()}</Row>
</div>
{Object.keys(usage.data.by_action).length > 0 && (
<div className="mt-2 pt-2 border-t border-border space-y-1 text-xs text-muted">
<div className="uppercase tracking-wide">By action (30d)</div>
<div className="uppercase tracking-wide">{t("settings.sync.byAction")}</div>
{Object.entries(usage.data.by_action)
.sort((a, b) => b[1] - a[1])
.map(([action, units]) => (
@ -422,42 +430,44 @@ function Sync({ me }: { me: Me }) {
)}
</>
) : (
<div className="text-muted text-sm">No usage yet.</div>
<div className="text-muted text-sm">{t("settings.sync.noUsage")}</div>
)}
</Section>
<Section title="Actions">
<Tooltip hint="Re-import your YouTube subscriptions and pull in any newly-followed channels.">
<Section title={t("settings.sync.actions")}>
<Tooltip hint={t("settings.sync.syncSubscriptionsHint")}>
<button
onClick={() => syncSubs.mutate()}
disabled={syncSubs.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
Sync subscriptions
{t("settings.sync.syncSubscriptions")}
</button>
</Tooltip>
<Tooltip hint="Request full back-catalog backfill for every channel you're subscribed to. Older videos + complete search fill in as the shared daily quota allows.">
<Tooltip hint={t("settings.sync.backfillEverythingHint")}>
<button
onClick={() => deepAll.mutate()}
disabled={deepAll.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition mt-2"
>
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
Backfill everything
{t("settings.sync.backfillEverything")}
</button>
</Tooltip>
</Section>
{me.role === "admin" && s && (
<Section title="Admin">
<Tooltip hint="Pause or resume the background sync for the whole app (all users).">
<Section title={t("settings.sync.admin")}>
<Tooltip hint={t("settings.sync.adminPauseHint")}>
<button
onClick={() => pauseResume.mutate(s.paused)}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm transition"
>
{s.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
{s.paused ? "Resume background sync" : "Pause background sync"}
{s.paused
? t("settings.sync.resumeBackgroundSync")
: t("settings.sync.pauseBackgroundSync")}
</button>
</Tooltip>
</Section>
@ -479,6 +489,7 @@ function AccessRow({
enableHint: string;
onEnable: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-start justify-between gap-3 py-2">
<div className="min-w-0">
@ -489,15 +500,15 @@ function AccessRow({
</div>
{granted ? (
<span className="shrink-0 text-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent">
Granted
{t("settings.account.granted")}
</span>
) : (
<Tooltip hint="Redirects to Google to grant the permission. Google shows an 'unverified app' notice — that's expected; click Advanced → Go to Siftlode.">
<Tooltip hint={t("settings.account.enableHint")}>
<button
onClick={onEnable}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
>
Enable
{t("settings.account.enable")}
</button>
</Tooltip>
)}
@ -506,9 +517,10 @@ function AccessRow({
}
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
const { t } = useTranslation();
return (
<>
<Section title="Account">
<Section title={t("settings.account.title")}>
<div className="flex items-center gap-3 mb-3">
<Avatar
src={me.avatar_url}
@ -523,26 +535,25 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
</div>
</Section>
<Section title="YouTube access">
<Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed mb-1">
Sign-in only shares your name and email. YouTube access is granted separately, below
each is optional and revocable anytime in your Google Account.
{t("settings.account.youtubeAccessIntro")}
</p>
<div className="divide-y divide-border">
<AccessRow
title="Read subscriptions (your feed)"
title={t("settings.account.readTitle")}
granted={me.can_read}
grantedHint="Granted. Siftlode reads the channels you follow to build your feed. It never changes your YouTube account with this."
enableHint="Read-only access to your subscriptions — required to build your feed. Siftlode can't modify anything with it."
grantedHint={t("settings.account.readGrantedHint")}
enableHint={t("settings.account.readEnableHint")}
onEnable={() => {
window.location.href = "/auth/upgrade?access=read";
}}
/>
<AccessRow
title="Unsubscribe (write)"
title={t("settings.account.writeTitle")}
granted={me.can_write}
grantedHint="Granted. You can unsubscribe from channels on YouTube from inside Siftlode. It only writes when you ask it to."
enableHint="Lets Siftlode unsubscribe from channels on YouTube for you. Optional — skip it to stay read-only."
grantedHint={t("settings.account.writeGrantedHint")}
enableHint={t("settings.account.writeEnableHint")}
onEnable={() => {
window.location.href = "/auth/upgrade?access=write";
}}
@ -552,7 +563,7 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
onClick={onOpenWizard}
className="mt-2 text-sm text-accent hover:underline"
>
Walk me through setup
{t("settings.account.walkMeThrough")}
</button>
</Section>
{me.role === "admin" && <AdminInvites />}
@ -561,6 +572,7 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
}
function AdminInvites() {
const { t } = useTranslation();
const qc = useQueryClient();
const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() });
const [newEmail, setNewEmail] = useState("");
@ -572,23 +584,23 @@ function AdminInvites() {
mutationFn: (id: number) => api.approveInvite(id),
onSuccess: () => {
refresh();
notify({ level: "success", message: "Approved — they can sign in now" });
notify({ level: "success", message: t("settings.invites.approved") });
},
onError: () => notify({ level: "error", message: "Approve failed" }),
onError: () => notify({ level: "error", message: t("settings.invites.approveFailed") }),
});
const deny = useMutation({
mutationFn: (id: number) => api.denyInvite(id),
onSuccess: refresh,
onError: () => notify({ level: "error", message: "Deny failed" }),
onError: () => notify({ level: "error", message: t("settings.invites.denyFailed") }),
});
const add = useMutation({
mutationFn: (email: string) => api.addInvite(email),
onSuccess: () => {
setNewEmail("");
refresh();
notify({ level: "success", message: "Added to the whitelist" });
notify({ level: "success", message: t("settings.invites.addedToWhitelist") });
},
onError: () => notify({ level: "error", message: "Couldn't add that email" }),
onError: () => notify({ level: "error", message: t("settings.invites.addFailed") }),
});
const list = invites.data ?? [];
@ -596,9 +608,9 @@ function AdminInvites() {
const decided = list.filter((i) => i.status !== "pending");
return (
<Section title="Access requests">
<Section title={t("settings.invites.title")}>
{pending.length === 0 ? (
<p className="text-sm text-muted">No pending requests.</p>
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
) : (
<div className="space-y-1.5">
{pending.map((i) => (
@ -624,21 +636,21 @@ function AdminInvites() {
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder="Add an email directly…"
placeholder={t("settings.invites.addPlaceholder")}
className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
/>
<button
type="submit"
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
>
<UserPlus className="w-4 h-4" /> Add
<UserPlus className="w-4 h-4" /> {t("settings.invites.add")}
</button>
</form>
{decided.length > 0 && (
<details className="mt-3">
<summary className="text-xs text-muted cursor-pointer">
{decided.length} decided
{t("settings.invites.decided", { count: decided.length })}
</summary>
<div className="mt-2 space-y-1 text-xs text-muted">
{decided.map((i) => (
@ -667,32 +679,35 @@ function InviteRow({
onDeny: () => void;
busy: boolean;
}) {
const { t } = useTranslation();
return (
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{inv.email}</div>
{inv.requested_at && (
<div className="text-[11px] text-muted">
requested {new Date(inv.requested_at).toLocaleString()}
{t("settings.invites.requested", {
time: new Date(inv.requested_at).toLocaleString(),
})}
</div>
)}
</div>
<Tooltip hint="Approve — whitelist this email and email them they're in.">
<Tooltip hint={t("settings.invites.approveHint")}>
<button
onClick={onApprove}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-accent/40 text-accent hover:bg-accent/10 disabled:opacity-50 transition"
aria-label="Approve"
aria-label={t("settings.invites.approve")}
>
<Check className="w-4 h-4" />
</button>
</Tooltip>
<Tooltip hint="Deny this request.">
<Tooltip hint={t("settings.invites.denyHint")}>
<button
onClick={onDeny}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
aria-label="Deny"
aria-label={t("settings.invites.deny")}
>
<X className="w-4 h-4" />
</button>

View file

@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import {
Check,
@ -28,38 +29,31 @@ import { CSS } from "@dnd-kit/utilities";
import { api, type FeedFilters, type Tag } from "../lib/api";
import {
DEFAULT_LAYOUT,
WIDGET_TITLES,
type SidebarLayout,
type WidgetId,
} from "../lib/sidebarLayout";
const SORTS = [
{ id: "newest", label: "Newest" },
{ id: "oldest", label: "Oldest" },
{ id: "views", label: "Most viewed" },
{ id: "duration_desc", label: "Longest" },
{ id: "duration_asc", label: "Shortest" },
{ id: "title", label: "Name (AZ)" },
{ id: "subscribers", label: "Channel subscribers" },
{ id: "priority", label: "Channel priority" },
{ id: "shuffle", label: "Surprise me" },
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
const SORT_IDS = [
"newest",
"oldest",
"views",
"duration_desc",
"duration_asc",
"title",
"subscribers",
"priority",
"shuffle",
];
const SHOWS = [
{ id: "unwatched", label: "Unwatched" },
{ id: "in_progress", label: "In progress" },
{ id: "all", label: "All" },
{ id: "watched", label: "Watched" },
{ id: "saved", label: "Saved" },
{ id: "hidden", label: "Hidden" },
];
const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "saved", "hidden"];
const DATE_PRESETS: { days: number; label: string }[] = [
{ days: 1, label: "24h" },
{ days: 7, label: "1 week" },
{ days: 30, label: "1 month" },
{ days: 180, label: "6 months" },
{ days: 365, label: "1 year" },
const DATE_PRESETS: { days: number; key: string }[] = [
{ days: 1, key: "24h" },
{ days: 7, key: "1week" },
{ days: 30, key: "1month" },
{ days: 180, key: "6months" },
{ days: 365, key: "1year" },
];
// Filter values owned by the sidebar (everything except the header search `q`).
@ -82,10 +76,11 @@ function TagChip({
active: boolean;
onClick: () => void;
}) {
const { t } = useTranslation();
return (
<button
onClick={onClick}
title={`${tag.channel_count} channel${tag.channel_count === 1 ? "" : "s"}`}
title={t("sidebar.channelCount", { count: tag.channel_count })}
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm hover:shadow active:translate-y-px transition ${
active
? "bg-accent text-accent-fg border-accent"
@ -108,6 +103,7 @@ export default function Sidebar({
layout: SidebarLayout;
setLayout: (l: SidebarLayout) => void;
}) {
const { t } = useTranslation();
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const tags = tagsQuery.data ?? [];
@ -128,7 +124,10 @@ export default function Sidebar({
undefined;
// Don't flash the misleading "This channel" fallback while the name is still resolving.
const channelChipLabel =
resolvedChannelName ?? (needChannelName && channelsQuery.isLoading ? "Loading…" : "This channel");
resolvedChannelName ??
(needChannelName && channelsQuery.isLoading
? t("sidebar.loading")
: t("sidebar.thisChannel"));
const languages = tags.filter((t) => t.category === "language");
const topics = tags.filter((t) => t.category === "topic");
const [customDates, setCustomDates] = useState(false);
@ -192,17 +191,17 @@ export default function Sidebar({
case "show":
return (
<div className="grid grid-cols-2 gap-1.5">
{SHOWS.map((s) => (
{SHOW_IDS.map((id) => (
<button
key={s.id}
onClick={() => setFilters({ ...filters, show: s.id })}
key={id}
onClick={() => setFilters({ ...filters, show: id })}
className={`text-xs py-1.5 rounded-lg border shadow-sm active:translate-y-px transition ${
filters.show === s.id
filters.show === id
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border hover:border-accent"
}`}
>
{s.label}
{t("sidebar.show." + id)}
</button>
))}
</div>
@ -214,9 +213,9 @@ export default function Sidebar({
onChange={(e) => setFilters({ ...filters, sort: e.target.value })}
className="w-full bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
>
{SORTS.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
{SORT_IDS.map((id) => (
<option key={id} value={id}>
{t("sidebar.sort." + id)}
</option>
))}
</select>
@ -244,7 +243,7 @@ export default function Sidebar({
: "bg-card border-border hover:border-accent"
}`}
>
{p.label}
{t("sidebar.datePreset." + p.key)}
</button>
);
})}
@ -260,14 +259,14 @@ export default function Sidebar({
: "bg-card border-border hover:border-accent"
}`}
>
Custom
{t("sidebar.custom")}
</button>
</div>
{(customDates || filters.dateFrom || filters.dateTo) && (
<div className="flex flex-col gap-1.5 mt-2">
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">From</span>
<span className="text-muted">{t("sidebar.from")}</span>
<input
type="date"
value={filters.dateFrom ?? ""}
@ -282,7 +281,7 @@ export default function Sidebar({
/>
</label>
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">To</span>
<span className="text-muted">{t("sidebar.to")}</span>
<input
type="date"
value={filters.dateTo ?? ""}
@ -303,7 +302,7 @@ export default function Sidebar({
}
className="text-[11px] text-muted hover:text-accent self-end"
>
clear dates
{t("sidebar.clearDates")}
</button>
)}
</div>
@ -314,17 +313,17 @@ export default function Sidebar({
return (
<>
<Toggle
label="Normal"
label={t("sidebar.content.normal")}
checked={filters.includeNormal}
onChange={(v) => setFilters({ ...filters, includeNormal: v })}
/>
<Toggle
label="Shorts"
label={t("sidebar.content.shorts")}
checked={filters.includeShorts}
onChange={(v) => setFilters({ ...filters, includeShorts: v })}
/>
<Toggle
label="Live / Upcoming"
label={t("sidebar.content.live")}
checked={filters.includeLive}
onChange={(v) => setFilters({ ...filters, includeLive: v })}
/>
@ -352,9 +351,9 @@ export default function Sidebar({
setFilters({ ...filters, tagMode: filters.tagMode === "or" ? "and" : "or" })
}
className="text-[10px] uppercase tracking-wide text-muted hover:text-accent"
title="Match any vs all selected tags"
title={t("sidebar.tagModeTooltip")}
>
{filters.tagMode === "or" ? "Any" : "All"}
{filters.tagMode === "or" ? t("sidebar.any") : t("sidebar.all")}
</button>
</div>
<div className="flex flex-wrap gap-1.5">
@ -381,9 +380,11 @@ export default function Sidebar({
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-3 hidden md:block">
<div className="flex items-center justify-between">
<div className="text-sm font-semibold">
Filters
{t("sidebar.filters")}
{activeCount > 0 && (
<span className="ml-1.5 text-xs font-medium text-accent">{activeCount} active</span>
<span className="ml-1.5 text-xs font-medium text-accent">
{t("sidebar.activeCount", { count: activeCount })}
</span>
)}
</div>
<div className="flex items-center gap-0.5">
@ -393,13 +394,13 @@ export default function Sidebar({
disabled={!active}
className="text-xs text-muted enabled:hover:text-accent disabled:opacity-40 transition px-1"
>
Clear all
{t("sidebar.clearAll")}
</button>
)}
{editing && (
<button
onClick={() => setLayout({ ...DEFAULT_LAYOUT })}
title="Reset to defaults"
title={t("sidebar.resetDefaults")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<RotateCcw className="w-4 h-4" />
@ -407,7 +408,7 @@ export default function Sidebar({
)}
<button
onClick={() => setEditing((e) => !e)}
title={editing ? "Done" : "Customize sidebar"}
title={editing ? t("sidebar.done") : t("sidebar.customize")}
className={`p-1.5 rounded-lg transition hover:bg-card ${
editing ? "text-accent" : "text-muted hover:text-fg"
}`}
@ -418,12 +419,14 @@ export default function Sidebar({
</div>
{editing && (
<div className="text-[11px] text-muted -mt-1">Drag to reorder · eye to show/hide</div>
<div className="text-[11px] text-muted -mt-1">{t("sidebar.editHint")}</div>
)}
{filters.channelId && !editing && (
<div className="glass-card rounded-xl">
<div className="px-3 pt-2 text-xs uppercase tracking-wide text-muted">Channel</div>
<div className="px-3 pt-2 text-xs uppercase tracking-wide text-muted">
{t("sidebar.channel")}
</div>
<div className="px-3 pb-3 pt-2">
<button
onClick={() =>
@ -445,7 +448,7 @@ export default function Sidebar({
<WidgetCard
key={id}
id={id}
title={WIDGET_TITLES[id]}
title={t("sidebar.widget." + id)}
editing={editing}
collapsed={!!layout.collapsed[id]}
hidden={!!layout.hidden[id]}
@ -481,6 +484,7 @@ function WidgetCard({
onToggleHidden: () => void;
children: React.ReactNode;
}) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
disabled: !editing,
@ -504,7 +508,7 @@ function WidgetCard({
<button
{...attributes}
{...listeners}
title="Drag to reorder"
title={t("sidebar.dragToReorder")}
className="-ml-1 cursor-grab active:cursor-grabbing text-muted hover:text-fg touch-none"
>
<GripVertical className="w-4 h-4" />
@ -516,7 +520,7 @@ function WidgetCard({
{editing ? (
<button
onClick={onToggleHidden}
title={hidden ? "Show widget" : "Hide widget"}
title={hidden ? t("sidebar.showWidget") : t("sidebar.hideWidget")}
className="text-muted hover:text-fg"
>
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
@ -524,7 +528,7 @@ function WidgetCard({
) : (
<button
onClick={onToggleCollapse}
title={collapsed ? "Expand" : "Collapse"}
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
className="text-muted hover:text-fg"
>
<ChevronDown

View file

@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { api, type AdminQuotaRow } from "../lib/api";
import { quotaActionLabel } from "../lib/format";
@ -6,6 +7,7 @@ import { quotaActionLabel } from "../lib/format";
const RANGES = [7, 30, 90] as const;
export default function Stats() {
const { t } = useTranslation();
const [days, setDays] = useState<number>(30);
const q = useQuery({
queryKey: ["admin-quota", days],
@ -18,7 +20,7 @@ export default function Stats() {
return (
<div className="p-4 max-w-4xl mx-auto">
<div className="flex items-center justify-between gap-3 mb-4">
<h2 className="text-lg font-semibold">API quota usage</h2>
<h2 className="text-lg font-semibold">{t("stats.title")}</h2>
<div className="flex items-center gap-1">
{RANGES.map((d) => (
<button
@ -30,30 +32,34 @@ export default function Stats() {
: "bg-card border-border text-muted hover:border-accent"
}`}
>
{d}d
{t("stats.rangeDays", { count: d })}
</button>
))}
</div>
</div>
<p className="text-xs text-muted mb-4 leading-relaxed">
YouTube Data API units attributed by who triggered the spend. <b className="text-fg/80">System</b> is
shared background work (scheduled backfill, enrichment, resync) that isn't tied to one person.
{t("stats.introBefore")}
<b className="text-fg/80">{t("stats.introSystem")}</b>
{t("stats.introAfter")}
</p>
{q.isLoading ? (
<div className="text-muted py-8">Loading</div>
<div className="text-muted py-8">{t("stats.loading")}</div>
) : !data ? (
<div className="text-muted py-8">No data.</div>
<div className="text-muted py-8">{t("stats.noData")}</div>
) : (
<>
{/* Daily totals (instance-wide, Pacific days) */}
<div className="glass-card rounded-xl p-3 mb-4">
<div className="text-xs text-muted mb-2">
Daily total ({data.range_days}d · {grandTotal.toLocaleString()} units)
{t("stats.dailyTotal", {
count: data.range_days,
units: grandTotal.toLocaleString(),
})}
</div>
{data.daily.length === 0 ? (
<div className="text-muted text-sm">No usage in this range.</div>
<div className="text-muted text-sm">{t("stats.noUsageInRange")}</div>
) : (
<div className="flex items-end gap-0.5 h-24">
{data.daily.map((d) => (
@ -61,7 +67,10 @@ export default function Stats() {
key={d.day}
className="flex-1 bg-accent/70 hover:bg-accent rounded-t transition"
style={{ height: `${Math.max(2, (d.total / maxDaily) * 100)}%` }}
title={`${d.day}: ${d.total.toLocaleString()} units`}
title={t("stats.dailyTooltip", {
day: d.day,
units: d.total.toLocaleString(),
})}
/>
))}
</div>
@ -71,7 +80,7 @@ export default function Stats() {
{/* Per-user breakdown */}
<div className="flex flex-col gap-1.5">
{data.rows.length === 0 ? (
<div className="text-muted py-4">No usage in this range.</div>
<div className="text-muted py-4">{t("stats.noUsageInRange")}</div>
) : (
data.rows.map((r) => (
<UserRow key={r.user_id ?? "system"} row={r} max={data.rows[0]?.total || 1} />
@ -85,6 +94,7 @@ export default function Stats() {
}
function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const actions = Object.entries(row.by_action).sort((a, b) => b[1] - a[1]);
const isSystem = row.user_id === null;
@ -95,7 +105,7 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
className="w-full flex items-center gap-3 text-left"
>
<span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
{isSystem ? "System (background)" : row.email}
{isSystem ? t("stats.system") : row.email}
</span>
<span className="text-sm font-semibold tabular-nums shrink-0">
{row.total.toLocaleString()}

View file

@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Database, History, Loader2, Pause, Play } from "lucide-react";
import { api } from "../lib/api";
import { formatViews } from "../lib/format";
@ -14,6 +15,7 @@ export default function SyncStatus({
isAdmin: boolean;
onGoToFullHistory: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const { data } = useQuery({
queryKey: ["my-status"],
@ -34,37 +36,40 @@ export default function SyncStatus({
return (
<div className="hidden lg:flex items-center gap-2 text-xs text-muted">
<Tooltip hint="Videos from your subscriptions, against the total in the shared catalog that every user's subscriptions have pulled in.">
<Tooltip hint={t("header.sync.countTooltip")}>
<span className="flex items-center gap-1.5 cursor-default">
<Database className="w-3.5 h-3.5" />
<span>
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span> yours
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
{t("header.sync.yours")}
</span>
<span className="opacity-40">/</span>
<span>{formatViews(data.total_videos)} total</span>
<span>
{formatViews(data.total_videos)} {t("header.sync.total")}
</span>
</span>
</Tooltip>
<span className="opacity-40">·</span>
{data.paused ? (
<span className="text-accent font-medium">paused</span>
<span className="text-accent font-medium">{t("header.sync.paused")}</span>
) : syncing > 0 ? (
<span className="flex items-center gap-1">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{syncing} syncing
{t("header.sync.syncing", { count: syncing })}
</span>
) : (
<span>all synced</span>
<span>{t("header.sync.allSynced")}</span>
)}
{notFull > 0 && (
<>
<span className="opacity-40">·</span>
<Tooltip hint="Some of your channels only have their recent uploads. Click to open the channel manager (filtered to these) and request their full back-catalog.">
<Tooltip hint={t("header.sync.fullHistoryTooltip")}>
<button
onClick={onGoToFullHistory}
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
>
<History className="w-3.5 h-3.5" />
{notFull} without full history
{t("header.sync.withoutFullHistory", { count: notFull })}
</button>
</Tooltip>
</>
@ -75,7 +80,7 @@ export default function SyncStatus({
<button
onClick={() => toggle.mutate()}
disabled={toggle.isPending}
title={data.paused ? "Resume background sync" : "Pause background sync"}
title={data.paused ? t("header.sync.resume") : t("header.sync.pause")}
className="ml-1 p-1 rounded-md hover:bg-card hover:text-fg transition"
>
{data.paused ? <Play className="w-3.5 h-3.5" /> : <Pause className="w-3.5 h-3.5" />}

View file

@ -1,4 +1,5 @@
import { useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import {
AlertCircle,
AlertTriangle,
@ -26,44 +27,45 @@ export const LEVEL_STYLE: Record<
};
export default function Toaster() {
const { t } = useTranslation();
const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]">
{toasts.map((t) => {
const { icon: Icon, color, bar } = LEVEL_STYLE[t.level];
{toasts.map((toast) => {
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
return (
<div
key={t.id}
key={toast.id}
className="glass relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
>
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">
{t.title && <div className="text-sm font-semibold">{t.title}</div>}
<div className="text-sm break-words">{t.message}</div>
{t.action && (
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>}
<div className="text-sm break-words">{toast.message}</div>
{toast.action && (
<button
onClick={() => {
t.action!.onClick();
dismiss(t.id);
toast.action!.onClick();
dismiss(toast.id);
}}
className="mt-1 text-accent text-sm font-semibold hover:underline"
>
{t.action.label}
{toast.action.label}
</button>
)}
</div>
<button
onClick={() => dismiss(t.id)}
onClick={() => dismiss(toast.id)}
className="shrink-0 text-muted hover:text-fg"
title="Dismiss"
title={t("notifications.dismiss")}
>
<X className="w-4 h-4" />
</button>
{t.duration && (
{toast.duration && (
<div
className={`absolute left-0 bottom-0 h-0.5 w-full origin-left ${bar}`}
style={{ animation: `toastbar ${t.duration}ms linear forwards` }}
style={{ animation: `toastbar ${toast.duration}ms linear forwards` }}
/>
)}
</div>

View file

@ -0,0 +1,43 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Sparkles, X } from "lucide-react";
import { FRONTEND_VERSION, SEEN_VERSION_KEY } from "../lib/version";
// Eye-catching, dismissible bar shown once after the running build's version changes
// (compares the baked frontend version to the last one the user acknowledged).
export default function VersionBanner({ onOpen }: { onOpen: () => void }) {
const { t } = useTranslation();
const [dismissed, setDismissed] = useState(false);
const seen = localStorage.getItem(SEEN_VERSION_KEY);
// Don't nag in un-stamped dev builds, or once this version has been seen.
const show = FRONTEND_VERSION !== "dev" && seen !== FRONTEND_VERSION && !dismissed;
if (!show) return null;
function markSeen() {
localStorage.setItem(SEEN_VERSION_KEY, FRONTEND_VERSION);
setDismissed(true);
}
return (
<div className="shrink-0 flex items-center gap-2 px-4 py-2 text-sm bg-accent/15 border-b border-accent/30 text-fg">
<Sparkles className="w-4 h-4 text-accent shrink-0" />
<span className="min-w-0">{t("header.banner.updated", { version: FRONTEND_VERSION })}</span>
<button
onClick={() => {
onOpen();
markSeen();
}}
className="ml-1 px-2.5 py-1 rounded-lg font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
{t("header.banner.releaseNotes")}
</button>
<button
onClick={markSeen}
title={t("header.banner.dismiss")}
className="ml-auto p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<X className="w-4 h-4" />
</button>
</div>
);
}

View file

@ -1,4 +1,5 @@
import { memo } from "react";
import { useTranslation } from "react-i18next";
import {
Bookmark,
Check,
@ -23,6 +24,7 @@ function Actions({
onState: (id: string, status: string) => void;
onChannelFilter?: (channelId: string, channelName: string) => void;
}) {
const { t } = useTranslation();
const act = (status: string) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@ -32,7 +34,7 @@ function Actions({
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
<button
onClick={act("watched")}
title={video.status === "watched" ? "Watched — click to unmark" : "Mark watched"}
title={video.status === "watched" ? t("card.watchedUnmark") : t("card.markWatched")}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.status === "watched" && "text-accent"
@ -46,7 +48,7 @@ function Actions({
</button>
<button
onClick={act("saved")}
title={video.status === "saved" ? "Saved — click to remove" : "Save for later"}
title={video.status === "saved" ? t("card.savedRemove") : t("card.saveForLater")}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.status === "saved" && "fill-current text-accent"
@ -56,7 +58,7 @@ function Actions({
</button>
<button
onClick={act("hidden")}
title={video.status === "hidden" ? "Unhide" : "Hide"}
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.status === "hidden" && "text-accent"
@ -73,9 +75,9 @@ function Actions({
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onChannelFilter(video.channel_id, video.channel_title ?? "This channel");
onChannelFilter(video.channel_id, video.channel_title ?? t("card.thisChannel"));
}}
title="Only this channel"
title={t("card.onlyThisChannel")}
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"
>
<ListFilter className="w-4 h-4" />
@ -106,6 +108,7 @@ function Thumb({
className?: string;
onOpen?: (v: Video, startAt?: number | null) => void;
}) {
const { t } = useTranslation();
// A non-zero saved position on an unfinished video = "in progress": show a resume
// progress bar and offer Continue / Restart instead of a plain Play.
const inProgress = video.position_seconds > 0 && video.status !== "watched";
@ -149,7 +152,7 @@ function Thumb({
)}
{video.live_status === "was_live" && (
<span className="absolute top-1.5 left-1.5 bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
stream
{t("card.stream")}
</span>
)}
{video.status === "saved" && (
@ -164,25 +167,25 @@ function Thumb({
<>
<button
onClick={open(null)}
title="Continue where you left off"
title={t("card.continueTitle")}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-semibold bg-accent text-accent-fg shadow-lg hover:opacity-90 transition"
>
<Play className="w-4 h-4 fill-current" />
Continue
{t("card.continue")}
</button>
<button
onClick={open(0)}
title="Play from the beginning"
title={t("card.restartTitle")}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-white/15 text-white backdrop-blur-sm hover:bg-white/25 transition"
>
<RotateCcw className="w-4 h-4" />
Restart
{t("card.restart")}
</button>
</>
) : (
<button
onClick={open(null)}
title="Play"
title={t("card.play")}
className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-accent text-accent-fg shadow-lg hover:scale-105 transition"
>
<Play className="w-5 h-5 fill-current translate-x-[1px]" />
@ -213,10 +216,15 @@ function VideoCard({
onChannelFilter?: (channelId: string, channelName: string) => void;
onOpen?: (v: Video, startAt?: number | null) => void;
}) {
const { t } = useTranslation();
const watched = video.status === "watched";
const meta = (
<>
{video.view_count != null && <>{formatViews(video.view_count)} views · </>}
{video.view_count != null && (
<>
{formatViews(video.view_count)} {t("card.views")} ·{" "}
</>
)}
{relativeTime(video.published_at)}
</>
);

View file

@ -0,0 +1,58 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
// Endonyms (shown as-is regardless of the active language — the convention for language pickers).
export const LANGUAGES = [
{ code: "en", label: "English" },
{ code: "hu", label: "Magyar" },
{ code: "de", label: "Deutsch" },
] as const;
export type LangCode = (typeof LANGUAGES)[number]["code"];
const SUPPORTED = LANGUAGES.map((l) => l.code) as readonly string[];
export const LANG_KEY = "siftlode.lang";
// Auto-load every locale file (locales/<lang>/<area>.json) and merge each <area> under one
// `translation` namespace, so components use t("area.key") and adding an area needs no wiring.
const mods = import.meta.glob("./locales/*/*.json", { eager: true }) as Record<
string,
{ default: Record<string, unknown> }
>;
const resources: Record<string, { translation: Record<string, unknown> }> = {};
for (const [path, mod] of Object.entries(mods)) {
const m = path.match(/\/locales\/([a-z]{2})\/([a-zA-Z0-9_]+)\.json$/);
if (!m) continue;
const [, lang, area] = m;
(resources[lang] ??= { translation: {} }).translation[area] = mod.default;
}
export function isSupported(code: string | null | undefined): code is LangCode {
return !!code && SUPPORTED.includes(code);
}
// Initial language before the server preference is known: stored choice, else the browser
// language, else English. (After login, App adopts the server-persisted preference.)
export function detectInitialLang(): LangCode {
const stored = localStorage.getItem(LANG_KEY);
if (isSupported(stored)) return stored;
const nav = (navigator.language || "en").slice(0, 2).toLowerCase();
return isSupported(nav) ? nav : "en";
}
const initial = detectInitialLang();
void i18n.use(initReactI18next).init({
resources,
lng: initial,
fallbackLng: "en",
interpolation: { escapeValue: false },
returnNull: false,
});
document.documentElement.lang = initial;
export function setLanguage(code: LangCode): void {
void i18n.changeLanguage(code);
localStorage.setItem(LANG_KEY, code);
document.documentElement.lang = code;
}
export default i18n;

View file

@ -0,0 +1,12 @@
{
"title": "Über",
"tagline": "Selbstgehosteter, mehrbenutzerfähiger Reader für deine YouTube-Abos.",
"frontend": "Frontend",
"backend": "Backend",
"database": "Datenbank",
"buildDate": "Build-Datum",
"whatsNew": "Neuigkeiten",
"releaseNotes": "Versionshinweise",
"new": "Neu",
"fixes": "Korrekturen"
}

View file

@ -0,0 +1,17 @@
{
"watchedUnmark": "Angesehen — zum Aufheben klicken",
"markWatched": "Als angesehen markieren",
"savedRemove": "Gespeichert — zum Entfernen klicken",
"saveForLater": "Für später speichern",
"unhide": "Einblenden",
"hide": "Ausblenden",
"onlyThisChannel": "Nur dieser Kanal",
"thisChannel": "Dieser Kanal",
"continueTitle": "Dort fortsetzen, wo du aufgehört hast",
"continue": "Fortsetzen",
"restartTitle": "Von vorne abspielen",
"restart": "Von vorne",
"play": "Abspielen",
"stream": "Stream",
"views": "Aufrufe"
}

View file

@ -0,0 +1,71 @@
{
"intro": "Lege die <0>Priorität</0> eines Kanals fest, um seine Videos nach oben zu schieben, wenn du nach „Kanalpriorität“ sortierst, hänge eigene <1>Tags</1> an, um den Feed zu filtern, oder <2>blende</2> einen Kanal <2>aus</2>, um ihn ohne Abbestellung aus dem Feed zu entfernen.",
"filterPlaceholder": "Kanäle filtern…",
"syncSubscriptions": "Abos synchronisieren",
"syncSubscriptionsHint": "Importiert deine Abo-Liste erneut von YouTube — fügt neu abonnierte Kanäle hinzu und entfernt abbestellte. Die Videos selbst werden weiterhin automatisch im Hintergrund synchronisiert; sie werden hierbei nicht neu geladen.",
"backfillEverything": "Alles nachladen",
"backfillEverythingHint": "Fordert das vollständige Nachladen des gesamten Katalogs für jeden abonnierten Kanal an. Ältere Videos und die Suche werden vollständig, soweit das gemeinsame Tageskontingent es zulässt — das kann eine Weile dauern.",
"filters": {
"all": "Alle",
"needsFull": "Verlauf unvollständig",
"fullySynced": "Vollständig synchronisiert",
"hidden": "Ausgeblendet"
},
"tags": {
"yourTags": "Deine Tags",
"yourTagsHint": "Deine persönlichen Labels. Hänge sie unten an Kanäle an und filtere den Feed dann über die Seitenleiste nach Tag. (Getrennt von den automatischen Sprach-/Themen-Tags.)",
"newTag": "neuer Tag",
"createTag": "Tag erstellen"
},
"loading": "Kanäle werden geladen…",
"empty": "Keine Kanäle.",
"stats": {
"channels": "Kanäle",
"channelsHint": "Kanäle, die du abonniert hast.",
"recentSynced": "Aktuelle synchronisiert",
"recentSyncedHint": "Kanäle, deren neueste Uploads in der lokalen Datenbank sind und in deinem Feed erscheinen.",
"fullHistory": "Vollständiger Verlauf",
"fullHistoryHint": "Kanäle, deren gesamter Katalog geladen ist, von denen, für die du den vollständigen Verlauf angefordert hast.",
"left": "übrig",
"leftHint": "Grobe Schätzung zum Abschluss des Nachladens des vollständigen Verlaufs für {{count}} ausstehende(n) Kanal/Kanäle, basierend auf dem gemeinsamen Tageskontingent.",
"myVideos": "Meine Videos",
"myVideosHint": "Insgesamt verfügbare Videos über all deine Kanäle.",
"quotaLeft": "Kontingent übrig",
"quotaLeftHint": "Heute noch verbleibendes gemeinsames YouTube-API-Budget (wird um Mitternacht US-Pazifikzeit zurückgesetzt)."
},
"row": {
"stored": "{{formatted}} gespeichert",
"subs": "{{formatted}} Abonnenten",
"priorityHint": "Deine Einstufung für diesen Kanal. Sortiere den Feed nach „Kanalpriorität“, um Kanäle mit höherer Priorität nach oben zu bringen.",
"raisePriority": "Priorität erhöhen",
"lowerPriority": "Priorität senken",
"recent": "aktuell",
"recentSyncedHint": "Neueste Uploads synchronisiert.",
"recentNotSyncedHint": "Neueste Uploads noch nicht geladen.",
"full": "vollständig",
"fullHint": "Vollständiger Verlauf geladen.",
"fullHistoryQueued": "vollständiger Verlauf in Warteschlange",
"queuedRequestedHint": "Vollständiger Verlauf angefordert — der gesamte Katalog dieses Kanals wird nachgeladen, soweit das gemeinsame Kontingent es zulässt. Klicke, um deine Anforderung abzubrechen.",
"queuedByOtherHint": "Ein anderer Abonnent hat den vollständigen Verlauf dieses Kanals bereits angefordert, sein gesamter Katalog ist also für alle unterwegs — hier gibt es nichts zu tun.",
"getFullHistory": "vollständigen Verlauf laden",
"getFullHistoryHint": "Bisher nur neueste Uploads. Klicke, um den gesamten Katalog dieses Kanals anzufordern (ältere Videos + vollständige Suche).",
"hiddenHint": "Ausgeblendet — die Videos dieses Kanals bleiben aus deinem Feed heraus. Klicke, um sie wieder anzuzeigen.",
"hideHint": "Blendet die Videos dieses Kanals aus deinem Feed aus. Er bleibt abonniert; dies bestellt nicht bei YouTube ab.",
"unhide": "Einblenden",
"hideFromFeed": "Aus Feed ausblenden",
"unsubscribeHint": "Bestelle diesen Kanal bei YouTube ab (ändert dein echtes Konto). Im schreibgeschützten Modus ist dies ausgeblendet — nutze stattdessen Ausblenden.",
"unsubscribeOnYoutube": "Bei YouTube abbestellen",
"thisChannel": "Dieser Kanal"
},
"notify": {
"synced": "{{count}} Abos synchronisiert",
"syncFailed": "Abo-Synchronisierung fehlgeschlagen",
"unsubscribed": "Bei YouTube abbestellt",
"unsubscribeFailed": "Abbestellen fehlgeschlagen",
"fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert",
"fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden",
"needYouTube": "Verbinde dein YouTube-Konto, um dies zu tun.",
"connect": "Verbinden"
},
"confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus."
}

View file

@ -0,0 +1,12 @@
{
"privacyPolicy": "Datenschutzerklärung",
"termsOfService": "Nutzungsbedingungen",
"close": "Schließen",
"cancel": "Abbrechen",
"save": "Speichern",
"loading": "Wird geladen…",
"language": "Sprache",
"somethingWrong": "Etwas ist schiefgelaufen.",
"accessRequestsTitle": "Zugangsanfragen",
"accessRequestsMessage": "{{count}} ausstehend — prüfe sie unter Einstellungen → Konto."
}

View file

@ -0,0 +1,9 @@
{
"boundary": {
"title": "Etwas ist schiefgelaufen.",
"subtitle": "Die App ist auf einen unerwarteten Fehler gestoßen.",
"reload": "Neu laden",
"notifTitle": "Etwas ist kaputtgegangen",
"notifMessage": "Unerwarteter Fehler"
}
}

View file

@ -0,0 +1,17 @@
{
"loading": "Feed wird geladen…",
"loadError": "Feed konnte nicht geladen werden.",
"emptyTitle": "Dein Feed ist leer",
"emptyBody": "Verbinde dein YouTube-Konto, um deine Abos zu importieren und deinen Feed aufzubauen.",
"setUp": "Meinen Feed einrichten",
"noMatches": "Keine Videos entsprechen diesen Filtern.",
"videoCount_one": "{{formattedCount}} Video",
"videoCount_other": "{{formattedCount}} Videos",
"loadingMore": "Mehr wird geladen…",
"hiddenNamed": "Ausgeblendet: „{{title}}”",
"hidden": "Video ausgeblendet",
"undo": "Rückgängig",
"markedWatchedNamed": "Als angesehen markiert: „{{title}}”",
"markedWatched": "Als angesehen markiert",
"unwatch": "Nicht mehr als angesehen"
}

View file

@ -0,0 +1,32 @@
{
"feed": "Feed",
"searchPlaceholder": "Deine Abos durchsuchen…",
"channelManager": "Kanalverwaltung",
"usageStats": "Nutzung & Statistik",
"account": {
"admin": "Admin",
"feed": "Feed",
"channels": "Kanäle",
"stats": "Statistik",
"settings": "Einstellungen",
"about": "Über",
"signOut": "Abmelden"
},
"sync": {
"yours": "deine",
"total": "gesamt",
"countTooltip": "Videos aus deinen Abos im Vergleich zur Gesamtzahl im gemeinsamen Katalog, den die Abos aller Nutzer gefüllt haben.",
"paused": "pausiert",
"syncing": "{{count}} werden synchronisiert",
"allSynced": "alles synchronisiert",
"withoutFullHistory": "{{count}} ohne vollständigen Verlauf",
"fullHistoryTooltip": "Einige deiner Kanäle haben nur ihre neuesten Uploads. Klicke, um die Kanalverwaltung (auf diese gefiltert) zu öffnen und ihren vollständigen Katalog anzufordern.",
"pause": "Hintergrund-Synchronisierung pausieren",
"resume": "Hintergrund-Synchronisierung fortsetzen"
},
"banner": {
"updated": "Aktualisiert auf v{{version}} — sieh, was sich geändert hat.",
"releaseNotes": "Versionshinweise",
"dismiss": "Schließen"
}
}

View file

@ -0,0 +1,12 @@
{
"tagline": "Ein selbstgehosteter, filterbarer Feed deiner YouTube-Abos — sortieren, taggen und den Lärm ausblenden. Melde dich mit Google an, um loszulegen; der YouTube-Zugriff ist ein optionaler, separater Schritt, den du steuerst.",
"signIn": "Mit Google anmelden",
"approved": "Du bist bereits freigeschaltet — melde dich einfach oben mit Google an.",
"requested": "Danke — deine Anfrage ist eingegangen. Wir benachrichtigen dich per E-Mail, sobald sie genehmigt ist.",
"noAccessYet": "Noch keinen Zugang?",
"denied": "Dieses Google-Konto ist nicht freigeschaltet. Fordere unten Zugang an.",
"emailPlaceholder": "du@gmail.com",
"sending": "Wird gesendet…",
"requestAccess": "Zugang anfordern",
"submitError": "Konnte nicht gesendet werden — prüfe die Adresse und versuche es erneut."
}

View file

@ -0,0 +1,19 @@
{
"title": "Benachrichtigungen",
"clearAll": "Alle löschen",
"clear": "Löschen",
"empty": "Noch keine Benachrichtigungen.",
"actionNeeded": "Aktion erforderlich",
"dismiss": "Schließen",
"findInFeed": "Im Feed finden",
"unhide": "Einblenden",
"unwatch": "Nicht angesehen",
"unhidden": "Eingeblendet „{{title}}”",
"unwatched": "Als nicht angesehen markiert „{{title}}”",
"time": {
"justNow": "gerade eben",
"minutes": "vor {{count}} Min.",
"hours": "vor {{count}} Std.",
"days": "vor {{count}} T."
}
}

View file

@ -0,0 +1,29 @@
{
"close": "Schließen",
"consent": "Google zeigt einen <0>„Google hat diese App nicht überprüft“</0>-Bildschirm — das ist zu erwarten, da Siftlode die vollständige Überprüfung von Google nicht durchlaufen hat. Es ist sicher fortzufahren: klicke auf <1>Erweitert</1> → <2>Weiter zu Siftlode</2>. Du kannst den Zugriff jederzeit in deinem <3>Google-Konto</3> widerrufen.",
"read": {
"title": "Verbinde deine YouTube-Abos",
"body": "Du bist angemeldet. Um deinen Feed aufzubauen, benötigt Siftlode <0>schreibgeschützten</0> Zugriff auf die Kanäle, die du auf YouTube abonniert hast. Es postet, löscht oder ändert damit niemals etwas.",
"connect": "Verbinden (schreibgeschützt)",
"skip": "Vorerst überspringen"
},
"importing": {
"title": "Dein Feed wird aufgebaut…",
"body": "Deine YouTube-Abos werden importiert. Bereits in Siftlode vorhandene Kanäle erscheinen sofort; neue werden automatisch im Hintergrund ergänzt."
},
"write": {
"title": "Du bist verbunden",
"importFailed": "Dein YouTube-Konto ist verbunden, aber der Import der Abos ist fehlgeschlagen — du kannst es unter Einstellungen → Synchronisierung erneut versuchen. ",
"feedReady": "Dein Feed ist bereit{{channels}}. Optional kannst du Siftlode erlauben, ",
"feedReadyChannels": " ({{count}} Kanäle)",
"rationale": "Kanäle auf YouTube für dich <0>abzubestellen</0> — dafür ist eine zusätzliche Schreibberechtigung nötig. Überspringe es, um schreibgeschützt zu bleiben; du kannst es später jederzeit in den Einstellungen aktivieren.",
"retryImport": "Import erneut versuchen",
"enableUnsubscribe": "Abbestellen aktivieren",
"keepReadOnly": "Nein danke — schreibgeschützt bleiben"
},
"done": {
"title": "Alles erledigt",
"body": "Lese- und Abbestell-Zugriff sind beide erteilt. Du kannst beide jederzeit unter Einstellungen → Konto oder in deinem Google-Konto überprüfen oder widerrufen.",
"done": "Fertig"
}
}

View file

@ -0,0 +1,17 @@
{
"loading": "Wird geladen…",
"backToOriginal": "Zurück zum ursprünglichen Video",
"back": "Zurück",
"close": "Schließen",
"closeEsc": "Schließen (Esc)",
"description": "Beschreibung",
"noDescription": "Keine Beschreibung.",
"channel": "Kanal",
"views": "{{formattedCount}} Aufrufe",
"stream": "Stream",
"watched": "Angesehen",
"markWatched": "Als angesehen markieren",
"watchedUnmark": "Angesehen — zum Aufheben klicken",
"jumpToTime": "Zu dieser Stelle springen",
"playInApp": "Im integrierten Player abspielen"
}

View file

@ -0,0 +1,101 @@
{
"title": "Einstellungen",
"tabs": {
"appearance": "Darstellung",
"notifications": "Benachrichtigungen",
"sync": "Synchronisierung",
"account": "Konto"
},
"appearance": {
"colorScheme": "Farbschema",
"display": "Anzeige",
"darkMode": "Dunkler Modus",
"listView": "Listenansicht",
"listViewHint": "Zeigt den Feed als kompakte Liste statt als Kartenraster an.",
"performanceMode": "Leistungsmodus",
"performanceModeHint": "Schaltet den durchscheinenden Glaseffekt und die weichen Schatten aus, für flüssigere Darstellung auf langsameren Geräten.",
"showHints": "Hinweise anzeigen",
"showHintsHint": "Diese kleinen Hover-Erklärungen. Schalte sie aus, sobald du dich auskennst.",
"textSize": "Schriftgröße"
},
"notifications": {
"title": "Benachrichtigungen",
"sound": "Ton bei Hinweisen",
"soundHint": "Spielt einen kurzen Ton ab, wenn eine Benachrichtigung deine Aufmerksamkeit braucht (z. B. Fehler, Abfragen).",
"autoDismiss": "Toasts automatisch ausblenden",
"autoDismissHint": "Ausgeschaltet bleiben Pop-up-Toasts, bis du sie schließt. Eingeschaltet blenden sie sich nach der eingestellten Zeit aus.",
"dismissAfter": "Ausblenden nach",
"sendTest": "Testbenachrichtigung senden",
"testTitle": "Testbenachrichtigung",
"testMessage": "So sieht eine Benachrichtigung aus."
},
"sync": {
"myStatus": "Mein Synchronisierungsstatus",
"channels": "Kanäle",
"channelsHint": "Wie viele Kanäle du abonniert hast.",
"recentSynced": "Neueste synchronisiert",
"recentSyncedHint": "Kanäle, deren neueste Uploads (~letztes Jahr) bereits in der lokalen Datenbank sind und so in deinem Feed erscheinen.",
"fullHistory": "Vollständiger Verlauf",
"fullHistoryHint": "Kanäle, deren gesamter Katalog abgerufen wurde, von denen, für die du den vollständigen Verlauf angefordert hast (pro Kanal auf der Kanäle-Seite aktivierbar).",
"fullHistoryEta": "Vollständiger Verlauf Restzeit",
"fullHistoryEtaHint": "Grobe Schätzung, um das Nachladen des vollständigen Verlaufs von {{count}} ausstehenden Kanal/Kanälen abzuschließen, basierend auf dem gemeinsamen Tageskontingent.",
"myVideos": "Meine Videos",
"myVideosHint": "Gesamtzahl der dir verfügbaren Videos über deine abonnierten Kanäle.",
"quotaLeft": "Heute verbleibendes Kontingent",
"quotaLeftHint": "Heute verbleibendes gemeinsames YouTube-API-Budget (wird um Mitternacht US-Pazifik zurückgesetzt). Das Nachladen ordnet sich diesem unter.",
"loading": "Wird geladen…",
"apiUsage": "Deine API-Nutzung",
"today": "Heute",
"todayHint": "YouTube-API-Einheiten, die deine eigenen Aktionen heute verbraucht haben (wird um Mitternacht US-Pazifik zurückgesetzt). Die Hintergrund-Synchronisierung zählt hier nicht.",
"last7d": "Letzte 7 Tage",
"last30d": "Letzte 30 Tage",
"allTime": "Gesamt",
"byAction": "Nach Aktion (30 Tage)",
"noUsage": "Noch keine Nutzung.",
"actions": "Aktionen",
"syncSubscriptions": "Abos synchronisieren",
"syncSubscriptionsHint": "Importiert deine YouTube-Abos erneut und holt neu gefolgte Kanäle hinzu.",
"backfillEverything": "Alles nachladen",
"backfillEverythingHint": "Fordert das Nachladen des vollständigen Katalogs für jeden Kanal an, den du abonniert hast. Ältere Videos und die vollständige Suche werden gefüllt, soweit das gemeinsame Tageskontingent es zulässt.",
"synced": "{{count}} Abos synchronisiert",
"syncFailed": "Synchronisierung fehlgeschlagen",
"fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert",
"fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden",
"admin": "Admin",
"adminPauseHint": "Pausiert oder setzt die Hintergrund-Synchronisierung für die gesamte App fort (alle Nutzer).",
"resumeBackgroundSync": "Hintergrund-Synchronisierung fortsetzen",
"pauseBackgroundSync": "Hintergrund-Synchronisierung pausieren"
},
"account": {
"title": "Konto",
"youtubeAccess": "YouTube-Zugriff",
"youtubeAccessIntro": "Die Anmeldung teilt nur deinen Namen und deine E-Mail-Adresse. Der YouTube-Zugriff wird separat erteilt, unten — jeder ist optional und jederzeit in deinem Google-Konto widerrufbar.",
"readTitle": "Abos lesen (dein Feed)",
"readGrantedHint": "Erteilt. Siftlode liest die Kanäle, denen du folgst, um deinen Feed aufzubauen. Damit ändert es nie dein YouTube-Konto.",
"readEnableHint": "Nur-Lese-Zugriff auf deine Abos — erforderlich, um deinen Feed aufzubauen. Siftlode kann damit nichts ändern.",
"writeTitle": "Abbestellen (Schreiben)",
"writeGrantedHint": "Erteilt. Du kannst Kanäle auf YouTube von innerhalb Siftlode abbestellen. Es schreibt nur, wenn du es anforderst.",
"writeEnableHint": "Ermöglicht Siftlode, Kanäle auf YouTube für dich abzubestellen. Optional — überspringe es, um im Nur-Lese-Modus zu bleiben.",
"granted": "Erteilt",
"enable": "Aktivieren",
"enableHint": "Leitet zu Google weiter, um die Berechtigung zu erteilen. Google zeigt einen Hinweis „nicht verifizierte App“ — das ist zu erwarten; klicke auf Erweitert → Weiter zu Siftlode.",
"walkMeThrough": "Führe mich durch die Einrichtung"
},
"invites": {
"title": "Zugriffsanfragen",
"noPending": "Keine ausstehenden Anfragen.",
"addPlaceholder": "E-Mail direkt hinzufügen…",
"add": "Hinzufügen",
"decided": "{{count}} entschieden",
"approved": "Genehmigt — sie können sich jetzt anmelden",
"approveFailed": "Genehmigung fehlgeschlagen",
"denyFailed": "Ablehnung fehlgeschlagen",
"addedToWhitelist": "Zur Whitelist hinzugefügt",
"addFailed": "Diese E-Mail konnte nicht hinzugefügt werden",
"requested": "angefragt {{time}}",
"approveHint": "Genehmigen — setzt diese E-Mail auf die Whitelist und benachrichtigt sie per E-Mail, dass sie dabei sind.",
"approve": "Genehmigen",
"denyHint": "Diese Anfrage ablehnen.",
"deny": "Ablehnen"
}
}

View file

@ -0,0 +1,65 @@
{
"filters": "Filter",
"activeCount": "{{count}} aktiv",
"clearAll": "Alle löschen",
"resetDefaults": "Auf Standard zurücksetzen",
"customize": "Seitenleiste anpassen",
"done": "Fertig",
"editHint": "Zum Umordnen ziehen · Auge zum Ein-/Ausblenden",
"channel": "Kanal",
"loading": "Wird geladen…",
"thisChannel": "Dieser Kanal",
"channelCount": "{{count}} Kanal",
"channelCount_other": "{{count}} Kanäle",
"dragToReorder": "Zum Umordnen ziehen",
"showWidget": "Widget einblenden",
"hideWidget": "Widget ausblenden",
"expand": "Ausklappen",
"collapse": "Einklappen",
"any": "Beliebig",
"all": "Alle",
"tagModeTooltip": "Beliebige vs. alle ausgewählten Tags treffen",
"custom": "Benutzerdefiniert",
"from": "Von",
"to": "Bis",
"clearDates": "Daten löschen",
"widget": {
"show": "Anzeigen",
"sort": "Sortierung",
"date": "Upload-Datum",
"content": "Inhaltstyp",
"language": "Sprache",
"topic": "Thema"
},
"show": {
"unwatched": "Ungesehen",
"in_progress": "Angefangen",
"all": "Alle",
"watched": "Angesehen",
"saved": "Gespeichert",
"hidden": "Ausgeblendet"
},
"sort": {
"newest": "Neueste",
"oldest": "Älteste",
"views": "Meistgesehen",
"duration_desc": "Längste",
"duration_asc": "Kürzeste",
"title": "Name (AZ)",
"subscribers": "Kanal-Abonnenten",
"priority": "Kanal-Priorität",
"shuffle": "Überrasch mich"
},
"content": {
"normal": "Normal",
"shorts": "Shorts",
"live": "Live / Demnächst"
},
"datePreset": {
"24h": "24 Std.",
"1week": "1 Woche",
"1month": "1 Monat",
"6months": "6 Monate",
"1year": "1 Jahr"
}
}

View file

@ -0,0 +1,13 @@
{
"title": "API-Kontingentnutzung",
"rangeDays": "{{count}} T",
"introBefore": "YouTube-Data-API-Einheiten, zugeordnet danach, wer die Nutzung ausgelöst hat. ",
"introSystem": "System",
"introAfter": " ist gemeinsame Hintergrundarbeit (geplantes Nachladen, Anreicherung, erneute Synchronisierung), die nicht an eine Person gebunden ist.",
"loading": "Wird geladen…",
"noData": "Keine Daten.",
"dailyTotal": "Tagessumme ({{count}} T · {{units}} Einheiten)",
"noUsageInRange": "Keine Nutzung in diesem Zeitraum.",
"dailyTooltip": "{{day}}: {{units}} Einheiten",
"system": "System (Hintergrund)"
}

View file

@ -0,0 +1,10 @@
{
"justNow": "gerade eben",
"secondsAgo": "vor {{count}} Sek.",
"minutesAgo": "vor {{count}} Min.",
"hoursAgo": "vor {{count}} Std.",
"daysAgo": "vor {{count}} T.",
"weeksAgo": "vor {{count}} Wo.",
"monthsAgo": "vor {{count}} Mon.",
"yearsAgo": "vor {{count}} J."
}

View file

@ -0,0 +1,12 @@
{
"title": "About",
"tagline": "Self-hosted, multi-user reader for your own YouTube subscriptions.",
"frontend": "Frontend",
"backend": "Backend",
"database": "Database",
"buildDate": "Build date",
"whatsNew": "What's new",
"releaseNotes": "Release notes",
"new": "New",
"fixes": "Fixes"
}

View file

@ -0,0 +1,17 @@
{
"watchedUnmark": "Watched — click to unmark",
"markWatched": "Mark watched",
"savedRemove": "Saved — click to remove",
"saveForLater": "Save for later",
"unhide": "Unhide",
"hide": "Hide",
"onlyThisChannel": "Only this channel",
"thisChannel": "This channel",
"continueTitle": "Continue where you left off",
"continue": "Continue",
"restartTitle": "Play from the beginning",
"restart": "Restart",
"play": "Play",
"stream": "stream",
"views": "views"
}

View file

@ -0,0 +1,71 @@
{
"intro": "Set a channel's <0>priority</0> to push its videos up when you sort by “Channel priority”, attach your own <1>tags</1> to filter the feed, or <2>hide</2> a channel to drop it from the feed without unsubscribing.",
"filterPlaceholder": "Filter channels…",
"syncSubscriptions": "Sync subscriptions",
"syncSubscriptionsHint": "Re-import your subscription list from YouTube — adds channels you've newly followed and drops ones you've unfollowed. The videos themselves keep syncing automatically in the background; this does not re-fetch them.",
"backfillEverything": "Backfill everything",
"backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos and search become complete as the shared daily quota allows — this can take a while.",
"filters": {
"all": "All",
"needsFull": "Needs full history",
"fullySynced": "Fully synced",
"hidden": "Hidden"
},
"tags": {
"yourTags": "Your tags",
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)",
"newTag": "new tag",
"createTag": "Create tag"
},
"loading": "Loading channels…",
"empty": "No channels.",
"stats": {
"channels": "Channels",
"channelsHint": "Channels you're subscribed to.",
"recentSynced": "Recent synced",
"recentSyncedHint": "Channels whose recent uploads are in the local DB and show in your feed.",
"fullHistory": "Full history",
"fullHistoryHint": "Channels whose entire back-catalog is fetched, out of the ones you've requested full history for.",
"left": "left",
"leftHint": "Rough estimate to finish full-history backfill of {{count}} pending channel(s), based on the shared daily quota.",
"myVideos": "My videos",
"myVideosHint": "Total videos available across your channels.",
"quotaLeft": "Quota left",
"quotaLeftHint": "Shared YouTube API budget left today (resets midnight US Pacific)."
},
"row": {
"stored": "{{formatted}} stored",
"subs": "{{formatted}} subs",
"priorityHint": "Your ranking for this channel. Sort the feed by “Channel priority” to bring higher-priority channels to the top.",
"raisePriority": "Raise priority",
"lowerPriority": "Lower priority",
"recent": "recent",
"recentSyncedHint": "Recent uploads synced.",
"recentNotSyncedHint": "Recent uploads not fetched yet.",
"full": "full",
"fullHint": "Full history fetched.",
"fullHistoryQueued": "full history queued",
"queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.",
"queuedByOtherHint": "Another subscriber already requested this channel's full history, so its whole back-catalog is on its way to everyone — nothing to do here.",
"getFullHistory": "get full history",
"getFullHistoryHint": "Only recent uploads so far. Click to request this channel's full back-catalog (older videos + complete search).",
"hiddenHint": "Hidden — this channel's videos are kept out of your feed. Click to show them again.",
"hideHint": "Hide this channel's videos from your feed. It stays subscribed; this doesn't unsubscribe on YouTube.",
"unhide": "Unhide",
"hideFromFeed": "Hide from feed",
"unsubscribeHint": "Unsubscribe from this channel on YouTube (changes your real account). Read-only mode hides this — use Hide instead.",
"unsubscribeOnYoutube": "Unsubscribe on YouTube",
"thisChannel": "This channel"
},
"notify": {
"synced": "Synced {{count}} subscriptions",
"syncFailed": "Subscription sync failed",
"unsubscribed": "Unsubscribed on YouTube",
"unsubscribeFailed": "Unsubscribe failed",
"fullHistoryRequested": "Full history requested for {{count}} channels",
"fullHistoryFailed": "Couldn't request full history",
"needYouTube": "Connect your YouTube account to do this.",
"connect": "Connect"
},
"confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead."
}

View file

@ -0,0 +1,12 @@
{
"privacyPolicy": "Privacy Policy",
"termsOfService": "Terms of Service",
"close": "Close",
"cancel": "Cancel",
"save": "Save",
"loading": "Loading…",
"language": "Language",
"somethingWrong": "Something went wrong.",
"accessRequestsTitle": "Access requests",
"accessRequestsMessage": "{{count}} pending — review in Settings → Account."
}

View file

@ -0,0 +1,9 @@
{
"boundary": {
"title": "Something went wrong.",
"subtitle": "The app hit an unexpected error.",
"reload": "Reload",
"notifTitle": "Something broke",
"notifMessage": "Unexpected error"
}
}

View file

@ -0,0 +1,17 @@
{
"loading": "Loading feed…",
"loadError": "Couldn't load the feed.",
"emptyTitle": "Your feed is empty",
"emptyBody": "Connect your YouTube account to import your subscriptions and build your feed.",
"setUp": "Set up my feed",
"noMatches": "No videos match these filters.",
"videoCount_one": "{{formattedCount}} video",
"videoCount_other": "{{formattedCount}} videos",
"loadingMore": "Loading more…",
"hiddenNamed": "Hidden “{{title}}”",
"hidden": "Video hidden",
"undo": "Undo",
"markedWatchedNamed": "Marked watched “{{title}}”",
"markedWatched": "Marked watched",
"unwatch": "Unwatch"
}

View file

@ -0,0 +1,32 @@
{
"feed": "Feed",
"searchPlaceholder": "Search your subscriptions…",
"channelManager": "Channel manager",
"usageStats": "Usage & stats",
"account": {
"admin": "Admin",
"feed": "Feed",
"channels": "Channels",
"stats": "Stats",
"settings": "Settings",
"about": "About",
"signOut": "Sign out"
},
"sync": {
"yours": "yours",
"total": "total",
"countTooltip": "Videos from your subscriptions, against the total in the shared catalog that every user's subscriptions have pulled in.",
"paused": "paused",
"syncing": "{{count}} syncing",
"allSynced": "all synced",
"withoutFullHistory": "{{count}} without full history",
"fullHistoryTooltip": "Some of your channels only have their recent uploads. Click to open the channel manager (filtered to these) and request their full back-catalog.",
"pause": "Pause background sync",
"resume": "Resume background sync"
},
"banner": {
"updated": "Updated to v{{version}} — see what's changed.",
"releaseNotes": "Release notes",
"dismiss": "Dismiss"
}
}

View file

@ -0,0 +1,12 @@
{
"tagline": "A self-hosted, filterable feed of your YouTube subscriptions — sort, tag, and tune out the noise. Sign in with Google to get started; YouTube access is an optional, separate step you control.",
"signIn": "Sign in with Google",
"approved": "You're already approved — just sign in with Google above.",
"requested": "Thanks — your request is in. We'll email you when it's approved.",
"noAccessYet": "No access yet?",
"denied": "That Google account isn't approved. Request access below.",
"emailPlaceholder": "you@gmail.com",
"sending": "Sending…",
"requestAccess": "Request access",
"submitError": "Couldn't submit — check the address and try again."
}

View file

@ -0,0 +1,19 @@
{
"title": "Notifications",
"clearAll": "Clear all",
"clear": "Clear",
"empty": "No notifications yet.",
"actionNeeded": "Action needed",
"dismiss": "Dismiss",
"findInFeed": "Find in feed",
"unhide": "Unhide",
"unwatch": "Unwatch",
"unhidden": "Unhidden “{{title}}”",
"unwatched": "Unwatched “{{title}}”",
"time": {
"justNow": "just now",
"minutes": "{{count}}m ago",
"hours": "{{count}}h ago",
"days": "{{count}}d ago"
}
}

View file

@ -0,0 +1,29 @@
{
"close": "Close",
"consent": "Google will show a <0>\"Google hasn't verified this app\"</0> screen — that's expected, because Siftlode hasn't gone through Google's full review. It's safe to continue: click <1>Advanced</1> → <2>Go to Siftlode</2>. You can revoke access anytime from your <3>Google Account</3>.",
"read": {
"title": "Connect your YouTube subscriptions",
"body": "You're signed in. To build your feed, Siftlode needs <0>read-only</0> access to the channels you're subscribed to on YouTube. It never posts, deletes, or changes anything with this.",
"connect": "Connect (read-only)",
"skip": "Skip for now"
},
"importing": {
"title": "Building your feed…",
"body": "Importing your YouTube subscriptions. Channels already in Siftlode show up instantly; any new ones fill in automatically in the background."
},
"write": {
"title": "You're connected",
"importFailed": "Your YouTube account is connected, but importing subscriptions failed — you can retry from Settings → Sync. ",
"feedReady": "Your feed is ready{{channels}}. Optionally, let Siftlode ",
"feedReadyChannels": " ({{count}} channels)",
"rationale": "<0>unsubscribe</0> from channels on YouTube for you — this needs an extra write permission. Skip it to stay read-only; you can always enable it later in Settings.",
"retryImport": "Retry import",
"enableUnsubscribe": "Enable unsubscribe",
"keepReadOnly": "No thanks — keep it read-only"
},
"done": {
"title": "All set",
"body": "Read and unsubscribe access are both granted. You can review or revoke either one anytime in Settings → Account, or from your Google Account.",
"done": "Done"
}
}

View file

@ -0,0 +1,17 @@
{
"loading": "Loading…",
"backToOriginal": "Back to the original video",
"back": "Back",
"close": "Close",
"closeEsc": "Close (Esc)",
"description": "Description",
"noDescription": "No description.",
"channel": "Channel",
"views": "{{formattedCount}} views",
"stream": "stream",
"watched": "Watched",
"markWatched": "Mark watched",
"watchedUnmark": "Watched — click to unmark",
"jumpToTime": "Jump to this time",
"playInApp": "Play in the in-app player"
}

View file

@ -0,0 +1,101 @@
{
"title": "Settings",
"tabs": {
"appearance": "Appearance",
"notifications": "Notifications",
"sync": "Sync",
"account": "Account"
},
"appearance": {
"colorScheme": "Color scheme",
"display": "Display",
"darkMode": "Dark mode",
"listView": "List view",
"listViewHint": "Show the feed as a compact list instead of a grid of cards.",
"performanceMode": "Performance mode",
"performanceModeHint": "Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines.",
"showHints": "Show hints",
"showHintsHint": "These little hover explanations. Turn them off once you know your way around.",
"textSize": "Text size"
},
"notifications": {
"title": "Notifications",
"sound": "Sound on alerts",
"soundHint": "Play a short tone when a notification needs your attention (e.g. errors, prompts).",
"autoDismiss": "Auto-dismiss toasts",
"autoDismissHint": "When off, pop-up toasts stay until you close them. When on, they fade after the set time.",
"dismissAfter": "Dismiss after",
"sendTest": "Send a test notification",
"testTitle": "Test notification",
"testMessage": "This is what a notification looks like."
},
"sync": {
"myStatus": "My sync status",
"channels": "Channels",
"channelsHint": "How many channels you're subscribed to.",
"recentSynced": "Recent synced",
"recentSyncedHint": "Channels whose recent uploads (~last year) are already in the local database, so they show in your feed.",
"fullHistory": "Full history",
"fullHistoryHint": "Channels whose entire back-catalog has been fetched, out of the ones you've requested full history for (opt in per channel on the Channels page).",
"fullHistoryEta": "Full history ETA",
"fullHistoryEtaHint": "Rough estimate to finish full-history backfill of {{count}} pending channel(s), based on the shared daily quota.",
"myVideos": "My videos",
"myVideosHint": "Total videos available to you across your subscribed channels.",
"quotaLeft": "Quota left today",
"quotaLeftHint": "Shared YouTube API budget remaining today (resets midnight US Pacific). Backfill yields to it.",
"loading": "Loading…",
"apiUsage": "Your API usage",
"today": "Today",
"todayHint": "YouTube API units your own actions used today (resets midnight US Pacific). Background sync isn't counted here.",
"last7d": "Last 7 days",
"last30d": "Last 30 days",
"allTime": "All time",
"byAction": "By action (30d)",
"noUsage": "No usage yet.",
"actions": "Actions",
"syncSubscriptions": "Sync subscriptions",
"syncSubscriptionsHint": "Re-import your YouTube subscriptions and pull in any newly-followed channels.",
"backfillEverything": "Backfill everything",
"backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos + complete search fill in as the shared daily quota allows.",
"synced": "Synced {{count}} subscriptions",
"syncFailed": "Sync failed",
"fullHistoryRequested": "Full history requested for {{count}} channels",
"fullHistoryFailed": "Couldn't request full history",
"admin": "Admin",
"adminPauseHint": "Pause or resume the background sync for the whole app (all users).",
"resumeBackgroundSync": "Resume background sync",
"pauseBackgroundSync": "Pause background sync"
},
"account": {
"title": "Account",
"youtubeAccess": "YouTube access",
"youtubeAccessIntro": "Sign-in only shares your name and email. YouTube access is granted separately, below — each is optional and revocable anytime in your Google Account.",
"readTitle": "Read subscriptions (your feed)",
"readGrantedHint": "Granted. Siftlode reads the channels you follow to build your feed. It never changes your YouTube account with this.",
"readEnableHint": "Read-only access to your subscriptions — required to build your feed. Siftlode can't modify anything with it.",
"writeTitle": "Unsubscribe (write)",
"writeGrantedHint": "Granted. You can unsubscribe from channels on YouTube from inside Siftlode. It only writes when you ask it to.",
"writeEnableHint": "Lets Siftlode unsubscribe from channels on YouTube for you. Optional — skip it to stay read-only.",
"granted": "Granted",
"enable": "Enable",
"enableHint": "Redirects to Google to grant the permission. Google shows an 'unverified app' notice — that's expected; click Advanced → Go to Siftlode.",
"walkMeThrough": "Walk me through setup"
},
"invites": {
"title": "Access requests",
"noPending": "No pending requests.",
"addPlaceholder": "Add an email directly…",
"add": "Add",
"decided": "{{count}} decided",
"approved": "Approved — they can sign in now",
"approveFailed": "Approve failed",
"denyFailed": "Deny failed",
"addedToWhitelist": "Added to the whitelist",
"addFailed": "Couldn't add that email",
"requested": "requested {{time}}",
"approveHint": "Approve — whitelist this email and email them they're in.",
"approve": "Approve",
"denyHint": "Deny this request.",
"deny": "Deny"
}
}

View file

@ -0,0 +1,65 @@
{
"filters": "Filters",
"activeCount": "{{count}} active",
"clearAll": "Clear all",
"resetDefaults": "Reset to defaults",
"customize": "Customize sidebar",
"done": "Done",
"editHint": "Drag to reorder · eye to show/hide",
"channel": "Channel",
"loading": "Loading…",
"thisChannel": "This channel",
"channelCount": "{{count}} channel",
"channelCount_other": "{{count}} channels",
"dragToReorder": "Drag to reorder",
"showWidget": "Show widget",
"hideWidget": "Hide widget",
"expand": "Expand",
"collapse": "Collapse",
"any": "Any",
"all": "All",
"tagModeTooltip": "Match any vs all selected tags",
"custom": "Custom",
"from": "From",
"to": "To",
"clearDates": "clear dates",
"widget": {
"show": "Show",
"sort": "Sort",
"date": "Upload date",
"content": "Content type",
"language": "Language",
"topic": "Topic"
},
"show": {
"unwatched": "Unwatched",
"in_progress": "In progress",
"all": "All",
"watched": "Watched",
"saved": "Saved",
"hidden": "Hidden"
},
"sort": {
"newest": "Newest",
"oldest": "Oldest",
"views": "Most viewed",
"duration_desc": "Longest",
"duration_asc": "Shortest",
"title": "Name (AZ)",
"subscribers": "Channel subscribers",
"priority": "Channel priority",
"shuffle": "Surprise me"
},
"content": {
"normal": "Normal",
"shorts": "Shorts",
"live": "Live / Upcoming"
},
"datePreset": {
"24h": "24h",
"1week": "1 week",
"1month": "1 month",
"6months": "6 months",
"1year": "1 year"
}
}

View file

@ -0,0 +1,13 @@
{
"title": "API quota usage",
"rangeDays": "{{count}}d",
"introBefore": "YouTube Data API units attributed by who triggered the spend. ",
"introSystem": "System",
"introAfter": " is shared background work (scheduled backfill, enrichment, resync) that isn't tied to one person.",
"loading": "Loading…",
"noData": "No data.",
"dailyTotal": "Daily total ({{count}}d · {{units}} units)",
"noUsageInRange": "No usage in this range.",
"dailyTooltip": "{{day}}: {{units}} units",
"system": "System (background)"
}

View file

@ -0,0 +1,10 @@
{
"justNow": "just now",
"secondsAgo": "{{count}}s ago",
"minutesAgo": "{{count}} min ago",
"hoursAgo": "{{count}}h ago",
"daysAgo": "{{count}}d ago",
"weeksAgo": "{{count}} wk ago",
"monthsAgo": "{{count}} mo ago",
"yearsAgo": "{{count}} yr ago"
}

View file

@ -0,0 +1,12 @@
{
"title": "Névjegy",
"tagline": "Saját üzemeltetésű, többfelhasználós olvasó a YouTube-feliratkozásaidhoz.",
"frontend": "Frontend",
"backend": "Backend",
"database": "Adatbázis",
"buildDate": "Build dátuma",
"whatsNew": "Mi újság",
"releaseNotes": "Verziójegyzék",
"new": "Új",
"fixes": "Javítások"
}

View file

@ -0,0 +1,17 @@
{
"watchedUnmark": "Megnézve — kattints a visszavonáshoz",
"markWatched": "Megnézettnek jelöl",
"savedRemove": "Mentve — kattints az eltávolításhoz",
"saveForLater": "Mentés későbbre",
"unhide": "Megjelenítés",
"hide": "Elrejtés",
"onlyThisChannel": "Csak ez a csatorna",
"thisChannel": "Ez a csatorna",
"continueTitle": "Folytatás ott, ahol abbahagytad",
"continue": "Folytatás",
"restartTitle": "Lejátszás az elejétől",
"restart": "Elölről",
"play": "Lejátszás",
"stream": "stream",
"views": "megtekintés"
}

View file

@ -0,0 +1,71 @@
{
"intro": "Állíts be egy csatornának <0>prioritást</0>, hogy a videói előrébb kerüljenek, amikor „Csatorna prioritás” szerint rendezel, csatolj saját <1>címkéket</1> a hírfolyam szűréséhez, vagy <2>rejts el</2> egy csatornát, hogy leiratkozás nélkül kivedd a hírfolyamból.",
"filterPlaceholder": "Csatornák szűrése…",
"syncSubscriptions": "Feliratkozások szinkronizálása",
"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",
"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.",
"filters": {
"all": "Mind",
"needsFull": "Hiányos előzmény",
"fullySynced": "Teljesen szinkronizálva",
"hidden": "Elrejtett"
},
"tags": {
"yourTags": "Címkéid",
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)",
"newTag": "új címke",
"createTag": "Címke létrehozása"
},
"loading": "Csatornák betöltése…",
"empty": "Nincsenek csatornák.",
"stats": {
"channels": "Csatorna",
"channelsHint": "Csatornák, amelyekre feliratkoztál.",
"recentSynced": "Legutóbbi szinkronizálva",
"recentSyncedHint": "Csatornák, amelyek legutóbbi feltöltései a helyi adatbázisban vannak, és megjelennek a hírfolyamodban.",
"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.",
"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.",
"myVideos": "Videóim",
"myVideosHint": "A csatornáidon elérhető videók teljes száma.",
"quotaLeft": "Maradék kvóta",
"quotaLeftHint": "A ma még hátralévő megosztott YouTube API-keret (csendes-óceáni idő szerint éjfélkor nullázódik)."
},
"row": {
"stored": "{{formatted}} tárolt",
"subs": "{{formatted}} feliratkozó",
"priorityHint": "A te rangsorod ehhez a csatornához. Rendezd a hírfolyamot „Csatorna prioritás” szerint, hogy a magasabb prioritású csatornák felülre kerüljenek.",
"raisePriority": "Prioritás növelése",
"lowerPriority": "Prioritás csökkentése",
"recent": "legutóbbi",
"recentSyncedHint": "Legutóbbi feltöltések szinkronizálva.",
"recentNotSyncedHint": "A legutóbbi feltöltések még nincsenek letöltve.",
"full": "teljes",
"fullHint": "Teljes előzmény letöltve.",
"fullHistoryQueued": "teljes előzmény sorban",
"queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.",
"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.",
"getFullHistory": "teljes előzmény lekérése",
"getFullHistoryHint": "Egyelőre csak a legutóbbi feltöltések. Kattints a csatorna teljes archívumának lekéréséhez (régebbi videók + teljes keresés).",
"hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a hírfolyamodban. Kattints, hogy újra láthatóak legyenek.",
"hideHint": "A csatorna videóinak elrejtése a hírfolyamodból. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.",
"unhide": "Megjelenítés",
"hideFromFeed": "Elrejtés a hírfolyamból",
"unsubscribeHint": "Leiratkozás erről a csatornáról a YouTube-on (megváltoztatja a valódi fiókodat). Csak olvasható módban ez rejtve van — használd helyette az Elrejtést.",
"unsubscribeOnYoutube": "Leiratkozás a YouTube-on",
"thisChannel": "Ez a csatorna"
},
"notify": {
"synced": "{{count}} feliratkozás szinkronizálva",
"syncFailed": "A feliratkozások szinkronizálása sikertelen",
"unsubscribed": "Leiratkozva a YouTube-on",
"unsubscribeFailed": "A leiratkozás sikertelen",
"fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához",
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni",
"needYouTube": "Ehhez csatlakoztasd a YouTube-fiókod.",
"connect": "Csatlakoztatás"
},
"confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a hírfolyamodból szeretnéd eltávolítani, inkább rejtsd el."
}

View file

@ -0,0 +1,12 @@
{
"privacyPolicy": "Adatvédelmi irányelvek",
"termsOfService": "Felhasználási feltételek",
"close": "Bezárás",
"cancel": "Mégse",
"save": "Mentés",
"loading": "Betöltés…",
"language": "Nyelv",
"somethingWrong": "Hiba történt.",
"accessRequestsTitle": "Hozzáférési kérések",
"accessRequestsMessage": "{{count}} függőben — nézd át a Beállítások → Fiók menüben."
}

View file

@ -0,0 +1,9 @@
{
"boundary": {
"title": "Hiba történt.",
"subtitle": "Az alkalmazás váratlan hibába ütközött.",
"reload": "Újratöltés",
"notifTitle": "Valami elromlott",
"notifMessage": "Váratlan hiba"
}
}

View file

@ -0,0 +1,17 @@
{
"loading": "Hírfolyam betöltése…",
"loadError": "Nem sikerült betölteni a hírfolyamot.",
"emptyTitle": "A hírfolyamod üres",
"emptyBody": "Kösd össze a YouTube-fiókodat, hogy importáld a feliratkozásaidat, és felépítsd a hírfolyamodat.",
"setUp": "Hírfolyam beállítása",
"noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.",
"videoCount_one": "{{formattedCount}} videó",
"videoCount_other": "{{formattedCount}} videó",
"loadingMore": "Továbbiak betöltése…",
"hiddenNamed": "Elrejtve: „{{title}}”",
"hidden": "Videó elrejtve",
"undo": "Visszavonás",
"markedWatchedNamed": "Megnézettnek jelölve: „{{title}}”",
"markedWatched": "Megnézettnek jelölve",
"unwatch": "Megnézés visszavonása"
}

View file

@ -0,0 +1,32 @@
{
"feed": "Hírfolyam",
"searchPlaceholder": "Keresés a feliratkozásaid között…",
"channelManager": "Csatornakezelő",
"usageStats": "Használat és statisztika",
"account": {
"admin": "Adminisztrátor",
"feed": "Hírfolyam",
"channels": "Csatornák",
"stats": "Statisztika",
"settings": "Beállítások",
"about": "Névjegy",
"signOut": "Kijelentkezés"
},
"sync": {
"yours": "tiéd",
"total": "összes",
"countTooltip": "A feliratkozásaidból származó videók száma a megosztott katalógus teljes számához képest, amelyet az összes felhasználó feliratkozásai gyűjtöttek össze.",
"paused": "szüneteltetve",
"syncing": "{{count}} szinkronizálás alatt",
"allSynced": "minden szinkronban",
"withoutFullHistory": "{{count}} teljes előzmény nélkül",
"fullHistoryTooltip": "Néhány csatornádnak csak a legutóbbi feltöltései vannak meg. Kattints a csatornakezelő megnyitásához (ezekre szűrve), és kérd le a teljes archívumukat.",
"pause": "Háttér-szinkronizálás szüneteltetése",
"resume": "Háttér-szinkronizálás folytatása"
},
"banner": {
"updated": "Frissítve a(z) v{{version}} verzióra — nézd meg, mi változott.",
"releaseNotes": "Verziójegyzék",
"dismiss": "Elvetés"
}
}

View file

@ -0,0 +1,12 @@
{
"tagline": "Saját üzemeltetésű, szűrhető hírfolyam a YouTube-feliratkozásaidból — rendezd, címkézd, és szűrd ki a zajt. A kezdéshez jelentkezz be Google-fiókkal; a YouTube-hozzáférés külön, általad vezérelt lépés.",
"signIn": "Bejelentkezés Google-fiókkal",
"approved": "Már jóvá vagy hagyva — csak jelentkezz be a fenti Google-gombbal.",
"requested": "Köszönjük — a kérésedet rögzítettük. E-mailben értesítünk, ha jóváhagytuk.",
"noAccessYet": "Még nincs hozzáférésed?",
"denied": "Ez a Google-fiók nincs jóváhagyva. Lent kérhetsz hozzáférést.",
"emailPlaceholder": "te@gmail.com",
"sending": "Küldés…",
"requestAccess": "Hozzáférés kérése",
"submitError": "Nem sikerült elküldeni — ellenőrizd a címet, és próbáld újra."
}

View file

@ -0,0 +1,19 @@
{
"title": "Értesítések",
"clearAll": "Összes törlése",
"clear": "Törlés",
"empty": "Még nincs értesítés.",
"actionNeeded": "Teendő",
"dismiss": "Elvetés",
"findInFeed": "Keresés a hírfolyamban",
"unhide": "Megjelenítés",
"unwatch": "Megtekintés visszavonása",
"unhidden": "Megjelenítve: „{{title}}”",
"unwatched": "Megtekintés visszavonva: „{{title}}”",
"time": {
"justNow": "az imént",
"minutes": "{{count}} perce",
"hours": "{{count}} órája",
"days": "{{count}} napja"
}
}

View file

@ -0,0 +1,29 @@
{
"close": "Bezárás",
"consent": "A Google megjelenít egy <0>„A Google nem ellenőrizte ezt az alkalmazást”</0> képernyőt — ez várható, mert a Siftlode nem ment át a Google teljes ellenőrzésén. Biztonságos folytatni: kattints a <1>Speciális</1> → <2>Ugrás a Siftlode-ra</2> lehetőségre. A hozzáférést bármikor visszavonhatod a <3>Google-fiókodban</3>.",
"read": {
"title": "Csatlakoztasd a YouTube-feliratkozásaidat",
"body": "Be vagy jelentkezve. A hírfolyamod felépítéséhez a Siftlode-nak <0>csak olvasható</0> hozzáférésre van szüksége azokhoz a csatornákhoz, amelyekre a YouTube-on feliratkoztál. Ezzel soha nem tesz közzé, nem töröl és nem módosít semmit.",
"connect": "Csatlakozás (csak olvasható)",
"skip": "Most kihagyom"
},
"importing": {
"title": "A hírfolyamod felépítése…",
"body": "A YouTube-feliratkozásaid importálása folyamatban. A Siftlode-ban már meglévő csatornák azonnal megjelennek; az újak automatikusan feltöltődnek a háttérben."
},
"write": {
"title": "Csatlakoztatva",
"importFailed": "A YouTube-fiókod csatlakoztatva, de a feliratkozások importálása sikertelen volt — újrapróbálhatod a Beállítások → Szinkronizálás menüben. ",
"feedReady": "A hírfolyamod készen áll{{channels}}. Választhatóan engedélyezheted, hogy a Siftlode ",
"feedReadyChannels": " ({{count}} csatorna)",
"rationale": "<0>leiratkozzon</0> helyetted a YouTube-csatornákról — ehhez egy plusz írási engedély szükséges. Hagyd ki, ha csak olvasható módban szeretnél maradni; ezt később bármikor engedélyezheted a Beállításokban.",
"retryImport": "Importálás újra",
"enableUnsubscribe": "Leiratkozás engedélyezése",
"keepReadOnly": "Köszönöm, nem — maradjon csak olvasható"
},
"done": {
"title": "Minden kész",
"body": "Az olvasási és a leiratkozási hozzáférés is engedélyezve van. Bármelyiket bármikor áttekintheted vagy visszavonhatod a Beállítások → Fiók menüben, vagy a Google-fiókodban.",
"done": "Kész"
}
}

View file

@ -0,0 +1,17 @@
{
"loading": "Betöltés…",
"backToOriginal": "Vissza az eredeti videóhoz",
"back": "Vissza",
"close": "Bezárás",
"closeEsc": "Bezárás (Esc)",
"description": "Leírás",
"noDescription": "Nincs leírás.",
"channel": "Csatorna",
"views": "{{formattedCount}} megtekintés",
"stream": "élő adás",
"watched": "Megtekintve",
"markWatched": "Megtekintettnek jelöl",
"watchedUnmark": "Megtekintve — kattints a jelölés visszavonásához",
"jumpToTime": "Ugrás erre az időpontra",
"playInApp": "Lejátszás a beépített lejátszóban"
}

View file

@ -0,0 +1,101 @@
{
"title": "Beállítások",
"tabs": {
"appearance": "Megjelenés",
"notifications": "Értesítések",
"sync": "Szinkronizálás",
"account": "Fiók"
},
"appearance": {
"colorScheme": "Színséma",
"display": "Megjelenítés",
"darkMode": "Sötét mód",
"listView": "Listanézet",
"listViewHint": "A hírfolyam kompakt listaként jelenik meg a kártyarács helyett.",
"performanceMode": "Teljesítmény mód",
"performanceModeHint": "Kikapcsolja az áttetsző üvegelmosást és a lágy árnyékokat a gyorsabb megjelenítésért lassabb gépeken.",
"showHints": "Tippek megjelenítése",
"showHintsHint": "Ezek a kis lebegő magyarázatok. Kapcsold ki őket, ha már kiismerted magad.",
"textSize": "Betűméret"
},
"notifications": {
"title": "Értesítések",
"sound": "Hang riasztásoknál",
"soundHint": "Rövid hangjelzés, ha egy értesítés a figyelmedet igényli (pl. hibák, kérdések).",
"autoDismiss": "Buborékok automatikus eltüntetése",
"autoDismissHint": "Kikapcsolva a felugró buborékok addig maradnak, amíg be nem zárod őket. Bekapcsolva a beállított idő után elhalványulnak.",
"dismissAfter": "Eltüntetés ennyi után",
"sendTest": "Tesztértesítés küldése",
"testTitle": "Tesztértesítés",
"testMessage": "Így néz ki egy értesítés."
},
"sync": {
"myStatus": "Saját szinkronizálási állapot",
"channels": "Csatornák",
"channelsHint": "Hány csatornára vagy feliratkozva.",
"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 hírfolyamodban.",
"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).",
"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.",
"myVideos": "Saját videók",
"myVideosHint": "A számodra elérhető videók összesen a feliratkozott csatornáidon.",
"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.",
"loading": "Betöltés…",
"apiUsage": "Saját API-használat",
"today": "Ma",
"todayHint": "A saját műveleteid által ma használt YouTube API-egységek (éjfélkor nullázódik, USA csendes-óceáni idő szerint). A háttér-szinkronizálás itt nem számít bele.",
"last7d": "Utolsó 7 nap",
"last30d": "Utolsó 30 nap",
"allTime": "Mindenkori",
"byAction": "Művelet szerint (30 nap)",
"noUsage": "Még nincs használat.",
"actions": "Műveletek",
"syncSubscriptions": "Feliratkozások szinkronizálása",
"syncSubscriptionsHint": "Újraimportálja a YouTube-feliratkozásaidat és behúzza az újonnan követett csatornákat.",
"backfillEverything": "Minden letöltése",
"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.",
"synced": "{{count}} feliratkozás szinkronizálva",
"syncFailed": "A szinkronizálás sikertelen",
"fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához",
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni",
"admin": "Adminisztrátor",
"adminPauseHint": "A háttér-szinkronizálás szüneteltetése vagy folytatása az egész alkalmazásra (minden felhasználóra).",
"resumeBackgroundSync": "Háttér-szinkronizálás folytatása",
"pauseBackgroundSync": "Háttér-szinkronizálás szüneteltetése"
},
"account": {
"title": "Fiók",
"youtubeAccess": "YouTube-hozzáférés",
"youtubeAccessIntro": "A bejelentkezés csak a nevedet és e-mail-címedet osztja meg. A YouTube-hozzáférést külön kell megadni, lent — mindegyik opcionális és bármikor visszavonható a Google-fiókodban.",
"readTitle": "Feliratkozások olvasása (a hírfolyamod)",
"readGrantedHint": "Megadva. A Siftlode beolvassa az általad követett csatornákat, hogy felépítse a hírfolyamodat. Ezzel sosem módosítja a YouTube-fiókodat.",
"readEnableHint": "Csak olvasási hozzáférés a feliratkozásaidhoz — a hírfolyam felépítéséhez szükséges. A Siftlode ezzel semmit sem tud módosítani.",
"writeTitle": "Leiratkozás (írás)",
"writeGrantedHint": "Megadva. A Siftlode-on belülről leiratkozhatsz csatornákról a YouTube-on. Csak akkor ír, amikor te kéred.",
"writeEnableHint": "Lehetővé teszi, hogy a Siftlode leiratkozzon helyetted csatornákról a YouTube-on. Opcionális — hagyd ki, ha csak olvasási módban szeretnél maradni.",
"granted": "Megadva",
"enable": "Engedélyezés",
"enableHint": "Átirányít a Google-höz az engedély megadásához. A Google „nem ellenőrzött alkalmazás” figyelmeztetést mutat — ez normális; kattints a Speciális → Tovább a Siftlode-ra lehetőségre.",
"walkMeThrough": "Vezess végig a beállításon"
},
"invites": {
"title": "Hozzáférési kérelmek",
"noPending": "Nincs függőben lévő kérelem.",
"addPlaceholder": "E-mail közvetlen hozzáadása…",
"add": "Hozzáadás",
"decided": "{{count}} eldöntve",
"approved": "Jóváhagyva — most már be tud jelentkezni",
"approveFailed": "A jóváhagyás sikertelen",
"denyFailed": "Az elutasítás sikertelen",
"addedToWhitelist": "Hozzáadva a fehérlistához",
"addFailed": "Nem sikerült hozzáadni ezt az e-mailt",
"requested": "kérve: {{time}}",
"approveHint": "Jóváhagyás — fehérlistára teszi ezt az e-mailt és e-mailben értesíti őket, hogy bekerültek.",
"approve": "Jóváhagyás",
"denyHint": "Kérelem elutasítása.",
"deny": "Elutasítás"
}
}

View file

@ -0,0 +1,65 @@
{
"filters": "Szűrők",
"activeCount": "{{count}} aktív",
"clearAll": "Összes törlése",
"resetDefaults": "Visszaállítás alapértelmezettre",
"customize": "Oldalsáv testreszabása",
"done": "Kész",
"editHint": "Húzd az átrendezéshez · szem a megjelenítéshez/elrejtéshez",
"channel": "Csatorna",
"loading": "Betöltés…",
"thisChannel": "Ez a csatorna",
"channelCount": "{{count}} csatorna",
"channelCount_other": "{{count}} csatorna",
"dragToReorder": "Húzd az átrendezéshez",
"showWidget": "Modul megjelenítése",
"hideWidget": "Modul elrejtése",
"expand": "Kibontás",
"collapse": "Összecsukás",
"any": "Bármelyik",
"all": "Összes",
"tagModeTooltip": "Bármelyik vagy az összes kijelölt címke illeszkedjen",
"custom": "Egyéni",
"from": "Ettől",
"to": "Eddig",
"clearDates": "dátumok törlése",
"widget": {
"show": "Megjelenítés",
"sort": "Rendezés",
"date": "Feltöltés dátuma",
"content": "Tartalomtípus",
"language": "Nyelv",
"topic": "Téma"
},
"show": {
"unwatched": "Nem nézett",
"in_progress": "Megkezdett",
"all": "Összes",
"watched": "Megnézett",
"saved": "Mentett",
"hidden": "Rejtett"
},
"sort": {
"newest": "Legújabb",
"oldest": "Legrégebbi",
"views": "Legnézettebb",
"duration_desc": "Leghosszabb",
"duration_asc": "Legrövidebb",
"title": "Név (AZ)",
"subscribers": "Csatorna feliratkozói",
"priority": "Csatorna prioritás",
"shuffle": "Lepj meg"
},
"content": {
"normal": "Normál",
"shorts": "Shorts",
"live": "Élő / Hamarosan"
},
"datePreset": {
"24h": "24 óra",
"1week": "1 hét",
"1month": "1 hónap",
"6months": "6 hónap",
"1year": "1 év"
}
}

View file

@ -0,0 +1,13 @@
{
"title": "API-kvóta használat",
"rangeDays": "{{count}} nap",
"introBefore": "A YouTube Data API-egységek aszerint, hogy ki váltotta ki a felhasználást. A ",
"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.",
"loading": "Betöltés…",
"noData": "Nincs adat.",
"dailyTotal": "Napi összesen ({{count}} nap · {{units}} egység)",
"noUsageInRange": "Nincs használat ebben az időszakban.",
"dailyTooltip": "{{day}}: {{units}} egység",
"system": "Rendszer (háttér)"
}

View file

@ -0,0 +1,10 @@
{
"justNow": "épp most",
"secondsAgo": "{{count}} mp-e",
"minutesAgo": "{{count}} perce",
"hoursAgo": "{{count}} órája",
"daysAgo": "{{count}} napja",
"weeksAgo": "{{count}} hete",
"monthsAgo": "{{count}} hónapja",
"yearsAgo": "{{count}} éve"
}

View file

@ -86,11 +86,20 @@ export interface FeedFilters {
dateTo?: string;
}
export interface VersionInfo {
app_version: string;
git_sha: string;
build_date: string | null;
db_revision: string | null;
}
class HttpError extends Error {
status: number;
constructor(status: number) {
super(`HTTP ${status}`);
detail?: string; // server-provided reason (FastAPI's `detail`), when present
constructor(status: number, detail?: string) {
super(detail || `HTTP ${status}`);
this.status = status;
this.detail = detail;
}
}
@ -121,11 +130,19 @@ async function req(url: string, opts: RequestInit = {}): Promise<any> {
throw e;
}
if (!r.ok) {
// Capture the server's reason (FastAPI returns `{ detail }`) so callers can react.
let detail: string | undefined;
try {
const body = await r.json();
if (body && typeof body.detail === "string") detail = body.detail;
} catch {
/* no JSON body */
}
// Server faults are worth surfacing; 401/403/404 etc. are handled by callers.
if (r.status >= 500) {
notifyErrorThrottled(`Server error ${r.status}`, `${method} ${url}`);
}
throw new HttpError(r.status);
throw new HttpError(r.status, detail);
}
return r.status === 204 ? null : r.json();
}
@ -221,6 +238,7 @@ export interface ManagedChannel {
export const api = {
me: (): Promise<Me> => req("/api/me"),
version: (): Promise<VersionInfo> => req("/api/version"),
tags: (): Promise<Tag[]> => req("/api/tags"),
status: (): Promise<SyncStatus> => req("/api/sync/status"),
feed: (f: FeedFilters, offset: number, limit: number): Promise<FeedResponse> =>

View file

@ -1,28 +1,30 @@
import i18n from "../i18n";
export function relativeTime(iso: string | null): string {
if (!iso) return "";
const then = new Date(iso).getTime();
const secs = Math.max(0, (Date.now() - then) / 1000);
const units: [number, string][] = [
[60, "s"],
[3600, "min"],
[86400, "h"],
[604800, "d"],
[2592000, "wk"],
[31536000, "mo"],
[60, "time.secondsAgo"],
[3600, "time.minutesAgo"],
[86400, "time.hoursAgo"],
[604800, "time.daysAgo"],
[2592000, "time.weeksAgo"],
[31536000, "time.monthsAgo"],
];
if (secs < 60) return "just now";
if (secs < 60) return i18n.t("time.justNow");
for (let i = 0; i < units.length - 1; i++) {
const [, label] = units[i];
const [, key] = units[i];
const next = units[i + 1][0];
if (secs < next) {
const v = Math.floor(secs / units[i][0]);
return `${v} ${label} ago`;
return i18n.t(key, { count: v });
}
}
const years = Math.floor(secs / 31536000);
if (years >= 1) return `${years} yr ago`;
if (years >= 1) return i18n.t("time.yearsAgo", { count: years });
const months = Math.floor(secs / 2592000);
return `${months} mo ago`;
return i18n.t("time.monthsAgo", { count: months });
}
export function formatDuration(sec: number | null): string {

View file

@ -0,0 +1,46 @@
// User-facing release notes. One entry per released version, newest first. The end-of-line
// `sha` is the repo commit the release was cut from (our stand-in for an issue/ticket ref);
// it's attached when the version is tagged. `chores` are internal and hidden from the public
// Release Notes dialog by default.
export interface ReleaseEntry {
version: string;
date: string; // ISO date (YYYY-MM-DD)
sha?: string; // short commit the release was tagged from
summary?: string;
features?: string[];
fixes?: string[];
chores?: string[];
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.1.0",
date: "2026-06-15",
// sha filled in when v0.1.0 is tagged at release time.
summary:
"First release: a self-hosted, multi-user reader for your own YouTube subscriptions — " +
"fast local-first feed with rich filtering, in-app playback with resume, and channel management.",
features: [
"Multi-user feed of your own YouTube subscriptions: invite-only Google sign-in, per-user watch/save/hide state, encrypted token storage.",
"Ingest: subscription import, free RSS new-video detection, recent-first and full-history backfill, metadata enrichment (duration, views, category, Shorts & livestream classification), a shared daily API-quota guard, and a background scheduler.",
"Automatic tagging: system tags for channel language and topic (your own tags are never overwritten).",
"Reader UI: grid/list feed scoped to your subscriptions; faceted tag filters, content-type toggles (Normal / Shorts / Live), date range, search and sort; four colour schemes (dark/light) with adjustable text size.",
"In-app player: a modal YouTube player that resumes where you left off, auto-marks watched near the end, and turns description timestamps and links into clickable seeks.",
"Watch progress: a resume progress bar on video cards, Continue / Restart buttons, and an “In progress” feed filter — your position is saved server-side, so it follows you across devices.",
"Channel manager: per-channel priority, hide, personal tags, sync badges, and per-channel full-history opt-in.",
"Header status: your video count vs. the whole catalogue, sync progress, and (for admins) a pause control; onboarding wizard; notification centre; admin usage & quota stats.",
"Multilingual interface — switch between English, Hungarian and German; your choice is saved to your account and the default is guessed from your Google locale.",
"About dialog and this Release Notes view, with a heads-up banner when a new version ships.",
],
fixes: [
"Backfill no longer skips the band of videos sitting on the 365-day boundary page.",
"A channel whose full catalogue is already stored is no longer stuck showing “needs full history”.",
],
chores: [
"Public-readiness repo hygiene: internal planning/infra details removed, build stamped with version/commit.",
],
},
];
export const CURRENT_VERSION = RELEASE_NOTES[0]?.version ?? "dev";

View file

@ -0,0 +1,8 @@
// Build info baked into the SPA at build time (Vite inlines VITE_*-prefixed env).
// Injected via Docker build-args (see Dockerfile); falls back to "dev" for un-stamped builds.
export const FRONTEND_VERSION = import.meta.env.VITE_APP_VERSION || "dev";
export const FRONTEND_SHA = import.meta.env.VITE_GIT_SHA || "unknown";
export const FRONTEND_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || "";
// Key for the "last version the user has seen release notes for" (drives the banner).
export const SEEN_VERSION_KEY = "siftlode.seenVersion";

View file

@ -5,6 +5,7 @@ import App from "./App";
import ErrorBoundary from "./components/ErrorBoundary";
import PrivacyPolicy from "./components/legal/PrivacyPolicy";
import Terms from "./components/legal/Terms";
import "./i18n";
import "./index.css";
const queryClient = new QueryClient({

7
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_VERSION?: string;
readonly VITE_GIT_SHA?: string;
readonly VITE_BUILD_DATE?: string;
}