diff --git a/Dockerfile b/Dockerfile index 6f98c9b..806b8c9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/backend/app/auth.py b/backend/app/auth.py index 787f2b7..53fa444 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -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) diff --git a/backend/app/config.py b/backend/app/config.py index 808dcf6..0f796ab 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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" diff --git a/backend/app/main.py b/backend/app/main.py index 7b1eca8..cb07a68 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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" diff --git a/backend/app/routes/version.py b/backend/app/routes/version.py new file mode 100644 index 0000000..ccaa87a --- /dev/null +++ b/backend/app/routes/version.py @@ -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, + } diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 7918555..5f4f341 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -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 diff --git a/docker-compose.localdev.yml b/docker-compose.localdev.yml index 9835770..1ca87f1 100644 --- a/docker-compose.localdev.yml +++ b/docker-compose.localdev.yml @@ -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. diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 215d2ff..a258ac3 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 diff --git a/docker-compose.server.yml b/docker-compose.server.yml index 2da614d..cdc3624 100644 --- a/docker-compose.server.yml +++ b/docker-compose.server.yml @@ -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} diff --git a/docker-compose.yml b/docker-compose.yml index 5ead8e5..cf9c976 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3f93ad2..00661c5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 0f78c58..8567f4f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6d157e0..4e890b5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(() => loadLocalTheme()); const [filters, setFiltersState] = useState(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("all"); + const [aboutOpen, setAboutOpen] = useState(false); + const [notesOpen, setNotesOpen] = useState(false); + const [notesHighlight, setNotesHighlight] = useState(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
Loading…
; + return ( +
{t("common.loading")}
+ ); if (meQuery.error instanceof HttpError && meQuery.error.status === 401) return ; if (meQuery.error) return (
- Something went wrong. + {t("common.somethingWrong")}
); @@ -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"); }} /> + openReleaseNotes(CURRENT_VERSION)} />
{page === "feed" && ( 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 && ( setWizardOpen(false)} /> )} + {aboutOpen && ( + setAboutOpen(false)} + onOpenReleaseNotes={() => { + setAboutOpen(false); + openReleaseNotes(); + }} + /> + )} + {notesOpen && ( + setNotesOpen(false)} highlight={notesHighlight} /> + )}
); diff --git a/frontend/src/components/About.tsx b/frontend/src/components/About.tsx new file mode 100644 index 0000000..6310ae8 --- /dev/null +++ b/frontend/src/components/About.tsx @@ -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 ( + + {t("about.title")} + + } + onClose={onClose} + > +
+
+ Siftlode +
+
v{FRONTEND_VERSION}
+
+

{t("about.tagline")}

+ +
+ {rows.map(([k, v], i) => ( +
+ {k} + {v} +
+ ))} +
+ + +
+ ); +} diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index a9ffc63..32f4785 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -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 && (
- + {s.deep_pending_count > 0 && ( )} - +
)} @@ -181,13 +199,13 @@ export default function Channels({ 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" /> @@ -225,22 +243,27 @@ export default function Channels({ : "bg-card border-border text-muted hover:border-accent" }`} > - {f.label} + {t(f.labelKey)} ))}

- Set a channel's priority to push its videos up when you sort - by “Channel priority”, attach your own tags to filter the feed, - or hide a channel to drop it from the feed without unsubscribing. + , + , + , + ]} + />

{/* Your tags */}
- + - Your tags + {t("channels.tags.yourTags")} {userTags.map((t) => ( @@ -264,10 +287,10 @@ export default function Channels({ 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" /> - @@ -275,9 +298,9 @@ export default function Channels({ {/* Channel list */} {channelsQuery.isLoading ? ( -
Loading channels…
+
{t("channels.loading")}
) : channels.length === 0 ? ( -
No channels.
+
{t("channels.empty")}
) : (
{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 (
- +
- {c.priority} -
@@ -395,42 +419,42 @@ function ChannelRow({ {c.title ?? c.id}
- {c.stored_videos.toLocaleString()} stored - {c.subscriber_count != null && · {c.subscriber_count.toLocaleString()} subs} + {t("channels.row.stored", { count: c.stored_videos, formatted: c.stored_videos.toLocaleString() })} + {c.subscriber_count != null && · {t("channels.row.subs", { count: c.subscriber_count, formatted: c.subscriber_count.toLocaleString() })}}
{c.backfill_done ? ( - + ) : c.deep_requested ? ( - + ) : c.deep_in_queue ? ( - + - full history queued + {t("channels.row.fullHistoryQueued")} ) : ( - + )} @@ -454,23 +478,19 @@ function ChannelRow({
- {canWrite && ( - + diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index ce2acd5..f72999f 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -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 { 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 { return (
-
Something went wrong.
-
The app hit an unexpected error.
+
{i18n.t("errors.boundary.title")}
+
{i18n.t("errors.boundary.subtitle")}
diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index bbdd000..09a05cf 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -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>({}); // 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
Loading feed…
; - if (query.isError) return
Couldn't load the feed.
; + if (query.isLoading) return
{t("feed.loading")}
; + if (query.isError) return
{t("feed.loadError")}
; if (items.length === 0) { if (!canRead) return (
-

Your feed is empty

-

- Connect your YouTube account to import your subscriptions and build your feed. -

+

{t("feed.emptyTitle")}

+

{t("feed.emptyBody")}

); - return
No videos match these filters.
; + return
{t("feed.noMatches")}
; } return (
{countQuery.data - ? `${countQuery.data.count.toLocaleString()} video${countQuery.data.count === 1 ? "" : "s"}` + ? t("feed.videoCount", { + count: countQuery.data.count, + formattedCount: countQuery.data.count.toLocaleString(), + }) : " "}
{view === "grid" ? ( @@ -205,7 +213,7 @@ export default function Feed({ )}
{isFetchingNextPage && ( -
Loading more…
+
{t("feed.loadingMore")}
)}
); diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 70190dd..7474091 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -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({ @@ -46,17 +55,18 @@ export default function Header({ 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" />
) : (
- {page === "stats" ? "Usage & stats" : "Channel manager"} + {page === "stats" ? t("header.usageStats") : t("header.channelManager")}
)}
+
@@ -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 | null>(null); @@ -134,7 +148,7 @@ function AccountMenu({ {me.role === "admin" && (
- Admin + {t("header.account.admin")}
)} @@ -142,28 +156,32 @@ function AccountMenu({ {page !== "feed" && ( )} {page !== "channels" && ( )} {me.role === "admin" && page !== "stats" && ( )} +
diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..f62a888 --- /dev/null +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -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 | 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 ( +
+ + + {open && ( +
+ {LANGUAGES.map((l) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index 98604d7..71bee93 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -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 (
+
+ +
Siftlode
-

- 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. -

+

{t("login.tagline")}

- Sign in with Google + {t("login.signIn")}
{phase === "approved" ? ( -

- You're already approved — just sign in with Google above. -

+

{t("login.approved")}

) : done ? ( -

- Thanks — your request is in. We'll email you when it's approved. -

+

{t("login.requested")}

) : ( <>
- No access yet? + {t("login.noAccessYet")}
{bounced === "denied" && ( -

- That Google account isn't approved. Request access below. -

+

{t("login.denied")}

)}
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" />
{error &&

{error}

} @@ -88,8 +86,12 @@ export default function Login() {
); diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx new file mode 100644 index 0000000..ff5d383 --- /dev/null +++ b/frontend/src/components/Modal.tsx @@ -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 ): 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( +
+
e.stopPropagation()} + > +
+

{title}

+ +
+
{children}
+
+
, + document.body + ); +} diff --git a/frontend/src/components/NotificationCenter.tsx b/frontend/src/components/NotificationCenter.tsx index 4603218..299ebb0 100644 --- a/frontend/src/components/NotificationCenter.tsx +++ b/frontend/src/components/NotificationCenter.tsx @@ -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({
)}
{notifications.length === 0 ? ( -
No notifications yet.
+
{t("notifications.empty")}
) : ( notifications.map((n) => { const { icon: Icon, color } = LEVEL_STYLE[n.level]; @@ -127,10 +133,10 @@ export default function NotificationCenter({ {n.title &&
{n.title}
}
{n.message}
- {relTime(n.ts)} + {relTime(n.ts, t)} {needsAction && ( - Action needed + {t("notifications.actionNeeded")} )}
@@ -142,24 +148,24 @@ export default function NotificationCenter({ className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline" > - Find in feed + {t("notifications.findInFeed")}
) : watched ? (
) : ( diff --git a/frontend/src/components/OnboardingWizard.tsx b/frontend/src/components/OnboardingWizard.tsx index 51ac1dd..2a7a8d7 100644 --- a/frontend/src/components/OnboardingWizard.tsx +++ b/frontend/src/components/OnboardingWizard.tsx @@ -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() {

- Google will show a "Google hasn't verified this app"{" "} - screen — that's expected, because Siftlode hasn't gone through Google's full review. - It's safe to continue: click Advanced →{" "} - Go to Siftlode. You can revoke access anytime from - your{" "} - - Google Account - - . + , + , + , + , + ]} + />

); @@ -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: () @@ -136,11 +138,12 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
-

Connect your YouTube subscriptions

+

{t("onboarding.read.title")}

- You're signed in. To build your feed, Siftlode needs{" "} - read-only access to the channels you're - subscribed to on YouTube. It never posts, deletes, or changes anything with this. + ]} + />

@@ -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" > - Connect (read-only) + {t("onboarding.read.connect")}
@@ -166,10 +169,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
-

Building your feed…

+

{t("onboarding.importing.title")}

- Importing your YouTube subscriptions. Channels already in Siftlode show up - instantly; any new ones fill in automatically in the background. + {t("onboarding.importing.body")}

)} @@ -179,24 +181,24 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
-

You're connected

+

{t("onboarding.write.title")}

{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 && ( - <> - unsubscribe 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. - + ]} + /> )}

{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" > - Retry import + {t("onboarding.write.retryImport")} )} @@ -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" > - Enable unsubscribe + {t("onboarding.write.enableUnsubscribe")}
@@ -233,10 +235,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
-

All set

+

{t("onboarding.done.title")}

- 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")}

)} diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 1d09399..0e51280 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -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} @@ -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} {navigated && ( )} {showDesc && @@ -422,30 +427,31 @@ export default function PlayerModal({ onMouseLeave={scheduleCloseDesc} >
- Description + {t("player.description")}
{detail.isLoading ? ( -
Loading…
+
{t("player.loading")}
) : detail.data?.description ? (
{renderDescription(detail.data.description, { currentId: currentVideoId, onSeek: seekTo, onLoadVideo: loadVideo, + t, })}
) : ( -
No description.
+
{t("player.noDescription")}
)} , document.body )} @@ -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")}
) : ( {liveData?.author ?? ""} @@ -486,14 +492,16 @@ export default function PlayerModal({ )} {!navigated ? (
- {video.view_count != null && · {formatViews(video.view_count)} views} + {video.view_count != null && ( + · {t("player.views", { count: video.view_count, formattedCount: formatViews(video.view_count) })} + )} · {relativeTime(video.published_at)} {video.duration_seconds != null && ( · {formatDuration(video.duration_seconds)} )} {video.live_status === "was_live" && ( - stream + {t("player.stream")} )}
@@ -501,7 +509,7 @@ export default function PlayerModal({ // Linked video's stats come from the (already-fetched) video detail.
{detail.data?.view_count != null && ( - · {formatViews(detail.data.view_count)} views + · {t("player.views", { count: detail.data.view_count, formattedCount: formatViews(detail.data.view_count) })} )} {detail.data?.published_at && · {relativeTime(detail.data.published_at)}} {detail.data?.duration_seconds != null && ( @@ -513,7 +521,7 @@ export default function PlayerModal({ {!navigated && ( )}
diff --git a/frontend/src/components/ReleaseNotes.tsx b/frontend/src/components/ReleaseNotes.tsx new file mode 100644 index 0000000..0934dda --- /dev/null +++ b/frontend/src/components/ReleaseNotes.tsx @@ -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 ( + +
+ {RELEASE_NOTES.map((r) => ( +
+
+

v{r.version}

+ {fmtDate(r.date)} + {r.sha && ( + + {r.sha} + + )} +
+ {r.summary &&

{r.summary}

} + + {r.features?.length ? ( +
+
+ {t("about.new")} +
+
    + {r.features.map((f, i) => ( +
  • + + {f} +
  • + ))} +
+
+ ) : null} + + {r.fixes?.length ? ( +
+
+ {t("about.fixes")} +
+
    + {r.fixes.map((f, i) => ( +
  • + + {f} +
  • + ))} +
+
+ ) : null} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 7c3329d..b5c6ccc 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -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("appearance"); const [closing, setClosing] = useState(false); @@ -71,7 +73,7 @@ export default function SettingsPanel({ }`} >
-
Settings
+
{t("settings.title")}
); })} @@ -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. */}
- {TABS.map((t) => ( + {TABS.map((tabItem) => (
- {t.id === "appearance" && ( + {tabItem.id === "appearance" && ( )} - {t.id === "notifications" && } - {t.id === "sync" && } - {t.id === "account" && } + {tabItem.id === "notifications" && } + {tabItem.id === "sync" && } + {tabItem.id === "account" && }
))}
@@ -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 ( <> -
+
{SCHEMES.map((s) => ( @@ -224,31 +227,31 @@ function Appearance({
-
- +
+ setTheme({ ...theme, mode: v ? "dark" : "light" })} /> - + setView(v ? "list" : "grid")} />
-
+
(() => getNotifSettings()); function update(p: Partial) { const next = { ...cfg, ...p }; @@ -275,23 +279,23 @@ function Notifications() { const autoOn = cfg.durationMs > 0; return ( -
+
update({ sound: v })} /> update({ durationMs: v ? 6000 : 0 })} /> {autoOn && (
- Dismiss after + {t("settings.notifications.dismissAfter")} {(cfg.durationMs / 1000).toFixed(0)}s
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")}
); } 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 ( <> -
+
{s ? (
- + {s.channels_total} {`${s.channels_recent_synced}/${s.channels_total}`} {`${s.channels_deep_done}/${s.channels_deep_requested}`} {s.deep_pending_count > 0 && ( {formatEta(s.deep_eta_seconds)} )} - + {s.my_videos.toLocaleString()} {s.quota_remaining_today.toLocaleString()}
) : ( -
Loading…
+
{t("settings.sync.loading")}
)}
-
+
{usage.data ? ( <>
- + {usage.data.today.toLocaleString()} - {usage.data.last_7d.toLocaleString()} - {usage.data.last_30d.toLocaleString()} - {usage.data.all_time.toLocaleString()} + {usage.data.last_7d.toLocaleString()} + {usage.data.last_30d.toLocaleString()} + {usage.data.all_time.toLocaleString()}
{Object.keys(usage.data.by_action).length > 0 && (
-
By action (30d)
+
{t("settings.sync.byAction")}
{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 }) { )} ) : ( -
No usage yet.
+
{t("settings.sync.noUsage")}
)}
-
- +
+ - +
{me.role === "admin" && s && ( -
- +
+
@@ -479,6 +489,7 @@ function AccessRow({ enableHint: string; onEnable: () => void; }) { + const { t } = useTranslation(); return (
@@ -489,15 +500,15 @@ function AccessRow({
{granted ? ( - Granted + {t("settings.account.granted")} ) : ( - + )} @@ -506,9 +517,10 @@ function AccessRow({ } function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { + const { t } = useTranslation(); return ( <> -
+
void }) {
-
+

- 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")}

{ window.location.href = "/auth/upgrade?access=read"; }} /> { 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")}
{me.role === "admin" && } @@ -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 ( -
+
{pending.length === 0 ? ( -

No pending requests.

+

{t("settings.invites.noPending")}

) : (
{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" /> {decided.length > 0 && (
- {decided.length} decided + {t("settings.invites.decided", { count: decided.length })}
{decided.map((i) => ( @@ -667,32 +679,35 @@ function InviteRow({ onDeny: () => void; busy: boolean; }) { + const { t } = useTranslation(); return (
{inv.email}
{inv.requested_at && (
- requested {new Date(inv.requested_at).toLocaleString()} + {t("settings.invites.requested", { + time: new Date(inv.requested_at).toLocaleString(), + })}
)}
- + - + diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 63dc123..0a95fdb 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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 (A–Z)" }, - { 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.") 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 ( ))}
@@ -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) => ( - ))} @@ -244,7 +243,7 @@ export default function Sidebar({ : "bg-card border-border hover:border-accent" }`} > - {p.label} + {t("sidebar.datePreset." + p.key)} ); })} @@ -260,14 +259,14 @@ export default function Sidebar({ : "bg-card border-border hover:border-accent" }`} > - Custom + {t("sidebar.custom")}
{(customDates || filters.dateFrom || filters.dateTo) && (
@@ -314,17 +313,17 @@ export default function Sidebar({ return ( <> setFilters({ ...filters, includeNormal: v })} /> setFilters({ ...filters, includeShorts: 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")}
@@ -381,9 +380,11 @@ export default function Sidebar({