Merge: promote dev to prod

This commit is contained in:
npeter83 2026-07-04 20:59:53 +02:00
commit a4c4e5a2b8
32 changed files with 304 additions and 140 deletions

View file

@ -1 +1 @@
0.22.1 0.22.2

View file

@ -719,6 +719,18 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
return user return user
def optional_current_user(
request: Request, db: Session = Depends(get_db)
) -> User | None:
"""Like `current_user` but returns None instead of raising 401 when there's no valid session.
Used by the bootstrap /api/me probe so the logged-out landing doesn't log a failed request to
the browser console (a non-2xx fetch is a console error no matter how the app handles it)."""
try:
return current_user(request, db)
except HTTPException:
return None
def require_human(user: User = Depends(current_user)) -> User: def require_human(user: User = Depends(current_user)) -> User:
"""Reject the shared demo account from actions that need a real Google/YouTube identity """Reject the shared demo account from actions that need a real Google/YouTube identity
or spend the shared quota. Most YouTube paths are already closed to it implicitly (it has or spend the shared quota. Most YouTube paths are already closed to it implicitly (it has

View file

@ -112,6 +112,20 @@ async def setup_gate(request, call_next):
return await call_next(request) return await call_next(request)
@app.middleware("http")
async def static_cache_headers(request, call_next):
"""Long-cache the content-hashed SPA bundles. Vite hashes their filename, so a given
/assets URL never changes content and a browser can hold it forever this is what makes
repeat loads instant and clears the Lighthouse "efficient cache lifetimes" audit. index.html
stays no-cache (set on its own FileResponse) so a deploy is picked up at once; other
SPA-root static files (welcome images, favicon, robots.txt) get a moderate TTL in the SPA
fallback below."""
response = await call_next(request)
if request.url.path.startswith("/assets/"):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
app.include_router(health.router) app.include_router(health.router)
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(sync.router) app.include_router(sync.router)
@ -134,6 +148,13 @@ app.include_router(quota.router)
app.include_router(version.router) app.include_router(version.router)
app.include_router(setup_routes.router) app.include_router(setup_routes.router)
# Ensure modern image types resolve to the right Content-Type when FileResponse guesses from the
# filename (the runtime's mimetypes db doesn't always know .webp → it'd fall back to octet-stream).
import mimetypes
mimetypes.add_type("image/webp", ".webp")
mimetypes.add_type("image/avif", ".avif")
# The built SPA (populated by the Docker frontend build stage). # The built SPA (populated by the Docker frontend build stage).
STATIC_DIR = Path(__file__).parent / "static_spa" STATIC_DIR = Path(__file__).parent / "static_spa"
# index.html is unhashed and references the content-hashed /assets bundles, so it MUST NOT be # index.html is unhashed and references the content-hashed /assets bundles, so it MUST NOT be
@ -166,5 +187,7 @@ async def spa_fallback(full_path: str) -> FileResponse:
if full_path: if full_path:
candidate = (STATIC_DIR / full_path).resolve() candidate = (STATIC_DIR / full_path).resolve()
if candidate.is_file() and STATIC_DIR.resolve() in candidate.parents: if candidate.is_file() and STATIC_DIR.resolve() in candidate.parents:
return FileResponse(candidate) # Real SPA-root assets (welcome images, favicon, robots.txt) rarely change and have
# stable names, so a moderate cache is safe and satisfies the cache-lifetime audit.
return FileResponse(candidate, headers={"Cache-Control": "public, max-age=604800"})
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS) return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)

View file

@ -9,6 +9,7 @@ from app.auth import (
has_read_scope, has_read_scope,
has_write_scope, has_write_scope,
is_allowed, is_allowed,
optional_current_user,
purge_user, purge_user,
) )
from app.db import get_db from app.db import get_db
@ -67,8 +68,13 @@ def switch_account(
@router.get("") @router.get("")
def get_me( def get_me(
user: User = Depends(current_user), db: Session = Depends(get_db) user: User | None = Depends(optional_current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
# The app's bootstrap probe: return 200 with `authenticated: False` when logged out (rather
# than 401) so the public landing never logs a failed /api/me to the browser console. Other
# protected endpoints still 401, so mid-session expiry is still caught by the global handler.
if user is None:
return {"authenticated": False}
pending_invites = 0 pending_invites = 0
if user.role == "admin": if user.role == "admin":
pending_invites = ( pending_invites = (
@ -80,6 +86,7 @@ def get_me(
or 0 or 0
) )
return { return {
"authenticated": True,
"id": user.id, "id": user.id,
"email": user.email, "email": user.email,
"display_name": user.display_name, "display_name": user.display_name,

View file

@ -5,6 +5,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Siftlode</title> <title>Siftlode</title>
<meta
name="description"
content="Siftlode is a self-hosted, multi-user reader for your YouTube subscriptions — filter, sort, tag and search your feed, build playlists that sync both ways, and watch in a clean, ad-free interface."
/>
<meta property="og:title" content="Siftlode" />
<meta
property="og:description"
content="A self-hosted, multi-user reader for your YouTube subscriptions — filter, sort, tag and search, build two-way-syncing playlists, and watch ad-free."
/>
<meta property="og:type" content="website" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -0,0 +1,7 @@
# Siftlode is mostly a private, login-gated app; only the public landing and legal
# pages are worth indexing. Allow crawling but keep bots out of the API surface.
User-agent: *
Allow: /
Disallow: /api/
Disallow: /auth/
Disallow: /watch/

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View file

@ -1,10 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
api, api,
getActiveAccount, getActiveAccount,
HttpError,
setActiveAccount, setActiveAccount,
setUnauthorizedHandler, setUnauthorizedHandler,
type FeedFilters, type FeedFilters,
@ -41,36 +40,43 @@ import {
} from "./lib/storage"; } from "./lib/storage";
import { useConfirm } from "./components/ConfirmProvider"; import { useConfirm } from "./components/ConfirmProvider";
import type { PrefsController } from "./components/SettingsPanel"; import type { PrefsController } from "./components/SettingsPanel";
import type { ChannelStatusFilter, ChannelsView } from "./components/Channels";
// Persistent shell — always mounted, so eagerly imported.
import Welcome from "./components/Welcome"; import Welcome from "./components/Welcome";
import SetupWizard from "./components/SetupWizard"; import SetupWizard from "./components/SetupWizard";
import Header from "./components/Header"; import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar"; import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
import Feed from "./components/Feed";
import ChannelPage from "./components/ChannelPage";
import BackToTop from "./components/BackToTop"; import BackToTop from "./components/BackToTop";
import Channels, { type ChannelStatusFilter, type ChannelsView } from "./components/Channels";
import Playlists from "./components/Playlists";
import Stats from "./components/Stats";
import Scheduler from "./components/Scheduler";
import ConfigPanel from "./components/ConfigPanel";
import AdminUsers, { focusAccessRequestsTab } from "./components/AdminUsers";
import SettingsPanel from "./components/SettingsPanel";
import NotificationsPanel from "./components/NotificationsPanel";
import Messages from "./components/Messages";
import DownloadCenter from "./components/DownloadCenter";
import { setNavigator } from "./lib/nav";
import ChatDock from "./components/ChatDock"; import ChatDock from "./components/ChatDock";
import OnboardingWizard from "./components/OnboardingWizard";
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
import Toaster from "./components/Toaster"; import Toaster from "./components/Toaster";
import ErrorDialog from "./components/ErrorDialog"; import ErrorDialog from "./components/ErrorDialog";
import About from "./components/About";
import ReleaseNotes from "./components/ReleaseNotes";
import VersionBanner from "./components/VersionBanner"; import VersionBanner from "./components/VersionBanner";
import DemoBanner from "./components/DemoBanner"; import DemoBanner from "./components/DemoBanner";
import { setNavigator } from "./lib/nav";
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
import { CURRENT_VERSION } from "./lib/releaseNotes"; import { CURRENT_VERSION } from "./lib/releaseNotes";
// Route-level code splitting: each module page (and the heavier modals) is its own lazy chunk,
// so the initial load — and the logged-out landing — pulls only the shell, and admin-only pages
// never reach non-admins. All are rendered inside a <Suspense> boundary below.
const Feed = lazy(() => import("./components/Feed"));
const ChannelPage = lazy(() => import("./components/ChannelPage"));
const Channels = lazy(() => import("./components/Channels"));
const Playlists = lazy(() => import("./components/Playlists"));
const Stats = lazy(() => import("./components/Stats"));
const Scheduler = lazy(() => import("./components/Scheduler"));
const ConfigPanel = lazy(() => import("./components/ConfigPanel"));
const AdminUsers = lazy(() => import("./components/AdminUsers"));
const SettingsPanel = lazy(() => import("./components/SettingsPanel"));
const NotificationsPanel = lazy(() => import("./components/NotificationsPanel"));
const Messages = lazy(() => import("./components/Messages"));
const DownloadCenter = lazy(() => import("./components/DownloadCenter"));
const OnboardingWizard = lazy(() => import("./components/OnboardingWizard"));
const About = lazy(() => import("./components/About"));
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
const DEFAULT_FILTERS: FeedFilters = { const DEFAULT_FILTERS: FeedFilters = {
tags: [], tags: [],
tagMode: "or", tagMode: "or",
@ -451,8 +457,9 @@ export default function App() {
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps }, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Any request that 401s means the session ended server-side. If we were signed in (cached // Any request that 401s means the session ended server-side. If we were signed in (cached
// `me` exists), reload to the login page at once (option 2 — also covers any click that hits // `me` is a user object), reload to the login page at once (also covers any click that hits
// the API). Guarded on cached `me` so the public login page's own /api/me 401 can't loop. // the API). Guarded on a cached user so a stray 401 while logged out (cached `me` is null)
// can't loop the public landing.
useEffect(() => { useEffect(() => {
setUnauthorizedHandler(() => { setUnauthorizedHandler(() => {
if (queryClient.getQueryData(["me"])) { if (queryClient.getQueryData(["me"])) {
@ -646,14 +653,20 @@ export default function App() {
return ( return (
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div> <div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
); );
if (meQuery.error instanceof HttpError && meQuery.error.status === 401) // /api/me answers 200 always now (null body = logged out), so a thrown error here is a real
return <Welcome />; // network/5xx failure, and no data means "not signed in" → the public landing.
if (meQuery.error) if (meQuery.error)
return ( return (
<div className="min-h-screen grid place-items-center text-muted"> <div className="min-h-screen grid place-items-center text-muted">
{t("common.somethingWrong")} {t("common.somethingWrong")}
</div> </div>
); );
if (!meQuery.data) return <Welcome />;
// Shown briefly while a lazy page/module chunk loads (first visit to that page only).
const pageFallback = (
<div className="flex-1 grid place-items-center text-muted">{t("common.loading")}</div>
);
return ( return (
<div className="h-screen flex bg-bg text-fg"> <div className="h-screen flex bg-bg text-fg">
@ -689,6 +702,7 @@ export default function App() {
)} )}
<div className="relative flex-1 min-w-0 flex flex-col"> <div className="relative flex-1 min-w-0 flex flex-col">
{channelView ? ( {channelView ? (
<Suspense fallback={pageFallback}>
<ChannelPage <ChannelPage
key={channelView.id} key={channelView.id}
channelId={channelView.id} channelId={channelView.id}
@ -698,6 +712,7 @@ export default function App() {
onBack={closeChannel} onBack={closeChannel}
onOpenChannel={openChannel} onOpenChannel={openChannel}
/> />
</Suspense>
) : ( ) : (
<> <>
<Header <Header
@ -710,6 +725,7 @@ export default function App() {
{meQuery.data!.is_demo && <DemoBanner />} {meQuery.data!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} /> <VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
<main className="flex-1 min-w-0 overflow-y-auto"> <main className="flex-1 min-w-0 overflow-y-auto">
<Suspense fallback={pageFallback}>
{page === "channels" ? ( {page === "channels" ? (
<Channels <Channels
canWrite={meQuery.data!.can_write} canWrite={meQuery.data!.can_write}
@ -767,6 +783,7 @@ export default function App() {
onOpenChannel={openChannel} onOpenChannel={openChannel}
/> />
)} )}
</Suspense>
</main> </main>
</> </>
)} )}
@ -776,6 +793,7 @@ export default function App() {
<Toaster /> <Toaster />
</div> </div>
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />} {meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
<Suspense fallback={null}>
{wizardOpen && meQuery.data && !meQuery.data.is_demo && ( {wizardOpen && meQuery.data && !meQuery.data.is_demo && (
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} /> <OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
)} )}
@ -791,6 +809,7 @@ export default function App() {
{notesOpen && ( {notesOpen && (
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} /> <ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
)} )}
</Suspense>
<ErrorDialog /> <ErrorDialog />
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */} {/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} /> <BackToTop dep={channelView ? `chan:${channelView.id}` : page} />

View file

@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react"; import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api"; import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { LS, setAccountRaw } from "../lib/storage"; import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import { Section } from "./ui/form"; import { Section } from "./ui/form";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
@ -13,15 +13,8 @@ import Tabs, { usePersistedTab } from "./Tabs";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the // Admin-only user & access management: roles, access requests (the Invite whitelist), and the
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab // demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
// carries a badge with the count of pending requests so it's visible without switching to it. // carries a badge with the count of pending requests so it's visible without switching to it.
// The persisted-tab storage key + the access-requests tab id, exported so a notification's // (The tab key + a "focus the Access tab" helper live in lib/adminUsersTab so callers can
// "Review" link can pre-select that tab before navigating here (usePersistedTab reads the key // pre-select the tab without importing this lazy-loaded page — see that file.)
// on mount, and this page mounts fresh on navigation).
export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab;
export const ADMIN_USERS_ACCESS_TAB = "access";
export function focusAccessRequestsTab(): void {
setAccountRaw(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB);
}
export default function AdminUsers({ me }: { me: Me }) { export default function AdminUsers({ me }: { me: Me }) {
const { t } = useTranslation(); const { t } = useTranslation();

View file

@ -224,7 +224,11 @@ function Field({
<div className="flex flex-col items-end gap-1.5 shrink-0"> <div className="flex flex-col items-end gap-1.5 shrink-0">
{isBool ? ( {isBool ? (
<Switch checked={value === "true"} onChange={(v) => onChange(v ? "true" : "false")} /> <Switch
label={t(`config.fields.${item.key}.label`, item.key)}
checked={value === "true"}
onChange={(v) => onChange(v ? "true" : "false")}
/>
) : ( ) : (
<input <input
type={item.secret ? "password" : isNum ? "number" : "text"} type={item.secret ? "password" : isNum ? "number" : "text"}
@ -234,6 +238,7 @@ function Field({
min={isNum && item.min != null ? item.min : undefined} min={isNum && item.min != null ? item.min : undefined}
max={isNum && item.max != null ? item.max : undefined} max={isNum && item.max != null ? item.max : undefined}
placeholder={item.secret ? "••••••••" : String(item.default ?? "")} placeholder={item.secret ? "••••••••" : String(item.default ?? "")}
aria-label={t(`config.fields.${item.key}.label`, item.key)}
className="w-52 bg-card border border-border rounded-xl px-3 py-1.5 text-sm outline-none focus:border-accent disabled:opacity-50" className="w-52 bg-card border border-border rounded-xl px-3 py-1.5 text-sm outline-none focus:border-accent disabled:opacity-50"
/> />
)} )}

View file

@ -1,9 +1,11 @@
import { useState } from "react"; import { lazy, Suspense, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Download } from "lucide-react"; import { Download } from "lucide-react";
import clsx from "clsx"; import clsx from "clsx";
import DownloadDialog from "./DownloadDialog"; // Lazy: the download dialog (+ its profile editor) loads only when a card's download button is
// clicked, so it never ships in the feed chunk for the majority who just browse.
const DownloadDialog = lazy(() => import("./DownloadDialog"));
import { api, type Me } from "../lib/api"; import { api, type Me } from "../lib/api";
// Self-contained download affordance for a video card / the player. Hidden for the demo account // Self-contained download affordance for a video card / the player. Hidden for the demo account
@ -59,7 +61,9 @@ export default function DownloadButton({
<Download className={clsx("w-4 h-4", inQueue && "animate-pulse")} /> <Download className={clsx("w-4 h-4", inQueue && "animate-pulse")} />
</button> </button>
{open && ( {open && (
<Suspense fallback={null}>
<DownloadDialog source={videoId} title={title} onClose={() => setOpen(false)} /> <DownloadDialog source={videoId} title={title} onClose={() => setOpen(false)} />
</Suspense>
)} )}
</> </>
); );

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from "react"; import { lazy, Suspense, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@ -16,10 +16,12 @@ import {
import clsx from "clsx"; import clsx from "clsx";
import Tabs, { usePersistedTab } from "./Tabs"; import Tabs, { usePersistedTab } from "./Tabs";
import Modal from "./Modal"; import Modal from "./Modal";
import DownloadDialog from "./DownloadDialog"; // Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
import ProfileEditor from "./ProfileEditor"; // used (the video editor in particular is heavy — filmstrip + scrubber).
import VideoEditor from "./VideoEditor"; const DownloadDialog = lazy(() => import("./DownloadDialog"));
import ShareDialog from "./ShareDialog"; const ProfileEditor = lazy(() => import("./ProfileEditor"));
const VideoEditor = lazy(() => import("./VideoEditor"));
const ShareDialog = lazy(() => import("./ShareDialog"));
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { api, type DownloadJob, type Me } from "../lib/api"; import { api, type DownloadJob, type Me } from "../lib/api";
@ -582,6 +584,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
{tab === "system" && me.role === "admin" && <AdminSystem />} {tab === "system" && me.role === "admin" && <AdminSystem />}
<Suspense fallback={null}>
{addSource && ( {addSource && (
<DownloadDialog <DownloadDialog
source={addSource} source={addSource}
@ -595,6 +598,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
{renameJob && <RenameModal job={renameJob} onClose={() => setRenameJob(null)} />} {renameJob && <RenameModal job={renameJob} onClose={() => setRenameJob(null)} />}
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />} {shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />} {editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
</Suspense>
</div> </div>
); );
} }

View file

@ -1,9 +1,11 @@
import { useState } from "react"; import { lazy, Suspense, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import Modal from "./Modal"; import Modal from "./Modal";
import ProfileEditor from "./ProfileEditor";
import { api } from "../lib/api"; import { api } from "../lib/api";
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
const ProfileEditor = lazy(() => import("./ProfileEditor"));
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { navigateTo } from "../lib/nav"; import { navigateTo } from "../lib/nav";
@ -105,12 +107,14 @@ export default function DownloadDialog({
</div> </div>
{manage && ( {manage && (
<Suspense fallback={null}>
<ProfileEditor <ProfileEditor
onClose={() => { onClose={() => {
setManage(false); setManage(false);
profilesQ.refetch(); profilesQ.refetch();
}} }}
/> />
</Suspense>
)} )}
</Modal> </Modal>
); );

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowDown, ArrowUp, ArrowLeft, Info, RefreshCw, Trash2, Youtube } from "lucide-react"; import { ArrowDown, ArrowUp, ArrowLeft, Info, RefreshCw, Trash2, Youtube } from "lucide-react";
@ -7,7 +7,8 @@ import i18n from "../i18n";
import { notify, resolveVideo } from "../lib/notifications"; import { notify, resolveVideo } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
import VirtualFeed from "./VirtualFeed"; import VirtualFeed from "./VirtualFeed";
import PlayerModal from "./PlayerModal"; // Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
const PlayerModal = lazy(() => import("./PlayerModal"));
const PAGE = 60; const PAGE = 60;
@ -441,6 +442,7 @@ export default function Feed({
)} )}
{activeVideo && ( {activeVideo && (
<Suspense fallback={null}>
<PlayerModal <PlayerModal
video={activeVideo.video} video={activeVideo.video}
startAt={activeVideo.startAt} startAt={activeVideo.startAt}
@ -448,6 +450,7 @@ export default function Feed({
onState={onState} onState={onState}
onOpenChannel={onOpenChannel} onOpenChannel={onOpenChannel}
/> />
</Suspense>
)} )}
</div> </div>
); );
@ -579,6 +582,7 @@ export default function Feed({
seed: key === "shuffle" ? rollSeed() : undefined, seed: key === "shuffle" ? rollSeed() : undefined,
}); });
}} }}
aria-label={t("feed.sortLabel")}
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent" className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
> >
{/* Relevance only ranks meaningfully when there's a search term — offer it then. */} {/* Relevance only ranks meaningfully when there's a search term — offer it then. */}
@ -646,6 +650,7 @@ export default function Feed({
/> />
)} )}
{activeVideo && ( {activeVideo && (
<Suspense fallback={null}>
<PlayerModal <PlayerModal
video={activeVideo.video} video={activeVideo.video}
startAt={activeVideo.startAt} startAt={activeVideo.startAt}
@ -653,6 +658,7 @@ export default function Feed({
onState={onState} onState={onState}
onOpenChannel={onOpenChannel} onOpenChannel={onOpenChannel}
/> />
</Suspense>
)} )}
{isFetchingNextPage && ( {isFetchingNextPage && (
<div className="text-center text-muted py-4">{t("feed.loadingMore")}</div> <div className="text-center text-muted py-4">{t("feed.loadingMore")}</div>

View file

@ -258,7 +258,6 @@ export default function NavSidebar({
onClick={() => setPage("feed")} onClick={() => setPage("feed")}
className="text-lg font-bold tracking-tight select-none cursor-pointer rounded-md -mx-1 px-1 hover:bg-card hover:opacity-90 transition" className="text-lg font-bold tracking-tight select-none cursor-pointer rounded-md -mx-1 px-1 hover:bg-card hover:opacity-90 transition"
title={t("header.feed")} title={t("header.feed")}
aria-label={t("header.feed")}
> >
Sift<span className="text-accent">lode</span> Sift<span className="text-accent">lode</span>
</button> </button>

View file

@ -4,7 +4,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, UserPlus, X } from "lucide-react"; import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, UserPlus, X } from "lucide-react";
import { api, type AppNotification, type FeedFilters } from "../lib/api"; import { api, type AppNotification, type FeedFilters } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { focusAccessRequestsTab } from "./AdminUsers"; import { focusAccessRequestsTab } from "../lib/adminUsersTab";
import { import {
clearAll as clearClient, clearAll as clearClient,
dismiss as dismissClient, dismiss as dismissClient,

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@ -39,7 +39,7 @@ import { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { getAccountRaw, LS, readAccountMerged, setAccountRaw, writeAccount } from "../lib/storage"; import { getAccountRaw, LS, readAccountMerged, setAccountRaw, writeAccount } from "../lib/storage";
import { useUndoable } from "../lib/useUndoable"; import { useUndoable } from "../lib/useUndoable";
import PlayerModal from "./PlayerModal"; const PlayerModal = lazy(() => import("./PlayerModal"));
import UndoToolbar from "./UndoToolbar"; import UndoToolbar from "./UndoToolbar";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
@ -140,6 +140,7 @@ function Row({
<span className="text-xs text-muted w-4 text-center tabular-nums">{index + 1}</span> <span className="text-xs text-muted w-4 text-center tabular-nums">{index + 1}</span>
<button <button
onClick={onPlay} onClick={onPlay}
aria-label={`${t("card.play")}${video.title}`}
className="relative w-[62px] h-[35px] rounded overflow-hidden bg-surface shrink-0 group" className="relative w-[62px] h-[35px] rounded overflow-hidden bg-surface shrink-0 group"
> >
{video.thumbnail_url && ( {video.thumbnail_url && (
@ -540,6 +541,7 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
onChange={(e) => onChange={(e) =>
setPlSort({ ...plSort, key: e.target.value as PlSort["key"] }) setPlSort({ ...plSort, key: e.target.value as PlSort["key"] })
} }
aria-label={t("playlists.sortLabel")}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-1.5 py-1 text-[11px] outline-none focus:border-accent" className="flex-1 min-w-0 bg-card border border-border rounded-md px-1.5 py-1 text-[11px] outline-none focus:border-accent"
> >
<option value="custom">{t("playlists.railSortCustom")}</option> <option value="custom">{t("playlists.railSortCustom")}</option>
@ -848,6 +850,7 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
</main> </main>
{playingIndex != null && items[playingIndex] && ( {playingIndex != null && items[playingIndex] && (
<Suspense fallback={null}>
<PlayerModal <PlayerModal
video={items[playingIndex]} video={items[playingIndex]}
queue={items} queue={items}
@ -856,6 +859,7 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
onClose={() => setPlayingIndex(null)} onClose={() => setPlayingIndex(null)}
onState={playerState} onState={playerState}
/> />
</Suspense>
)} )}
</div> </div>
); );

View file

@ -259,6 +259,7 @@ function MaintenanceSettings({
value={val} value={val}
onChange={(e) => setVal(e.target.value)} onChange={(e) => setVal(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && save()} onKeyDown={(e) => e.key === "Enter" && save()}
aria-label={t("scheduler.maintenance.batchLabel")}
className="w-28 bg-card border border-border rounded px-2 py-1 text-sm tabular-nums outline-none focus:border-accent" className="w-28 bg-card border border-border rounded px-2 py-1 text-sm tabular-nums outline-none focus:border-accent"
/> />
<button <button

View file

@ -138,6 +138,8 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
<Tooltip key={s.id} hint={s.name}> <Tooltip key={s.id} hint={s.name}>
<button <button
onClick={() => setTheme({ ...theme, scheme: s.id as Scheme })} onClick={() => setTheme({ ...theme, scheme: s.id as Scheme })}
aria-label={s.name}
aria-pressed={theme.scheme === s.id}
className={`h-9 w-full rounded-lg border-2 transition ${ className={`h-9 w-full rounded-lg border-2 transition ${
theme.scheme === s.id ? "border-fg" : "border-transparent" theme.scheme === s.id ? "border-fg" : "border-transparent"
}`} }`}
@ -151,24 +153,29 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
<Section title={t("settings.appearance.display")}> <Section title={t("settings.appearance.display")}>
<SettingRow label={t("settings.appearance.darkMode")}> <SettingRow label={t("settings.appearance.darkMode")}>
<Switch <Switch
label={t("settings.appearance.darkMode")}
checked={theme.mode === "dark"} checked={theme.mode === "dark"}
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })} onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
/> />
</SettingRow> </SettingRow>
<SettingRow label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}> <SettingRow label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
<Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} /> <Switch
label={t("settings.appearance.listView")}
checked={view === "list"}
onChange={(v) => setView(v ? "list" : "grid")}
/>
</SettingRow> </SettingRow>
<SettingRow <SettingRow
label={t("settings.appearance.performanceMode")} label={t("settings.appearance.performanceMode")}
hint={t("settings.appearance.performanceModeHint")} hint={t("settings.appearance.performanceModeHint")}
> >
<Switch checked={perf} onChange={setPerf} /> <Switch label={t("settings.appearance.performanceMode")} checked={perf} onChange={setPerf} />
</SettingRow> </SettingRow>
<SettingRow <SettingRow
label={t("settings.appearance.showHints")} label={t("settings.appearance.showHints")}
hint={t("settings.appearance.showHintsHint")} hint={t("settings.appearance.showHintsHint")}
> >
<Switch checked={hints} onChange={setHints} /> <Switch label={t("settings.appearance.showHints")} checked={hints} onChange={setHints} />
</SettingRow> </SettingRow>
</Section> </Section>
@ -180,6 +187,7 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
step={0.02} step={0.02}
value={theme.fontScale} value={theme.fontScale}
onChange={(e) => setTheme({ ...theme, fontScale: Number(e.target.value) })} onChange={(e) => setTheme({ ...theme, fontScale: Number(e.target.value) })}
aria-label={t("settings.appearance.textSize")}
className="w-full accent-accent" className="w-full accent-accent"
/> />
<div className="text-right text-xs text-muted">{Math.round(theme.fontScale * 100)}%</div> <div className="text-right text-xs text-muted">{Math.round(theme.fontScale * 100)}%</div>
@ -202,13 +210,21 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
label={t("settings.notifications.sound")} label={t("settings.notifications.sound")}
hint={t("settings.notifications.soundHint")} hint={t("settings.notifications.soundHint")}
> >
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} /> <Switch
label={t("settings.notifications.sound")}
checked={cfg.sound}
onChange={(v) => update({ sound: v })}
/>
</SettingRow> </SettingRow>
<SettingRow <SettingRow
label={t("settings.notifications.autoDismiss")} label={t("settings.notifications.autoDismiss")}
hint={t("settings.notifications.autoDismissHint")} hint={t("settings.notifications.autoDismissHint")}
> >
<Switch checked={autoOn} onChange={(v) => update({ durationMs: v ? 6000 : 0 })} /> <Switch
label={t("settings.notifications.autoDismiss")}
checked={autoOn}
onChange={(v) => update({ durationMs: v ? 6000 : 0 })}
/>
</SettingRow> </SettingRow>
{autoOn && ( {autoOn && (
<div className="py-1.5 text-sm"> <div className="py-1.5 text-sm">
@ -223,6 +239,7 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
step={1000} step={1000}
value={cfg.durationMs} value={cfg.durationMs}
onChange={(e) => update({ durationMs: Number(e.target.value) })} onChange={(e) => update({ durationMs: Number(e.target.value) })}
aria-label={t("settings.notifications.dismissAfter")}
className="w-full accent-accent mt-1" className="w-full accent-accent mt-1"
/> />
</div> </div>

View file

@ -733,7 +733,7 @@ function Toggle({
return ( return (
<label className="flex items-center justify-between py-1 cursor-pointer text-sm"> <label className="flex items-center justify-between py-1 cursor-pointer text-sm">
<span>{label}</span> <span>{label}</span>
<Switch checked={checked} onChange={onChange} /> <Switch label={label} checked={checked} onChange={onChange} />
</label> </label>
); );
} }

View file

@ -153,6 +153,9 @@ function Thumb({
src={video.thumbnail_url} src={video.thumbnail_url}
alt="" alt=""
loading="lazy" loading="lazy"
decoding="async"
width={1280}
height={720}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : ( ) : (

View file

@ -59,7 +59,7 @@ export default function Welcome() {
{/* App preview */} {/* App preview */}
<Preview <Preview
src="/welcome/feed.png" src="/welcome/feed.webp"
label={t("welcome.preview.feed")} label={t("welcome.preview.feed")}
className="aspect-video" className="aspect-video"
onOpen={open} onOpen={open}
@ -101,13 +101,13 @@ export default function Welcome() {
{/* Secondary showcase — smaller thumbnails; click to view full size. */} {/* Secondary showcase — smaller thumbnails; click to view full size. */}
<section className="mt-12 flex flex-wrap justify-center gap-4"> <section className="mt-12 flex flex-wrap justify-center gap-4">
<Preview <Preview
src="/welcome/channels.png" src="/welcome/channels.webp"
label={t("welcome.preview.channels")} label={t("welcome.preview.channels")}
className="w-full sm:w-72 aspect-[4/3]" className="w-full sm:w-72 aspect-[4/3]"
onOpen={open} onOpen={open}
/> />
<Preview <Preview
src="/welcome/playlists.png" src="/welcome/playlists.webp"
label={t("welcome.preview.playlists")} label={t("welcome.preview.playlists")}
className="w-full sm:w-72 aspect-[4/3]" className="w-full sm:w-72 aspect-[4/3]"
onOpen={open} onOpen={open}

View file

@ -4,17 +4,23 @@ import Tooltip from "../Tooltip";
// Shared form/settings primitives, factored out of the many panels that each re-declared // Shared form/settings primitives, factored out of the many panels that each re-declared
// them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged. // them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged.
/** Pill on/off switch — the bare control; compose it with your own label/row. */ /** Pill on/off switch the bare control; compose it with your own label/row. Pass `label` for
* the accessible name (the visible row text) since the control itself has no inner text. */
export function Switch({ export function Switch({
checked, checked,
onChange, onChange,
label,
}: { }: {
checked: boolean; checked: boolean;
onChange: (v: boolean) => void; onChange: (v: boolean) => void;
label?: string;
}) { }) {
return ( return (
<button <button
type="button" type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => onChange(!checked)} onClick={() => onChange(!checked)}
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`} className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
> >

View file

@ -0,0 +1,15 @@
import { LS, setAccountRaw } from "./storage";
// The admin Users page's persisted-tab key + the Access-requests tab id, plus a helper to
// pre-select that tab before navigating there (a notification's "Review" link uses it —
// usePersistedTab reads the key on mount, and the page mounts fresh on navigation).
//
// Kept out of AdminUsers.tsx so callers (App, NotificationsPanel) can pre-select the tab
// WITHOUT statically importing the — now lazy-loaded — admin page, which would otherwise pull
// it back into the main bundle and defeat the code-split.
export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab;
export const ADMIN_USERS_ACCESS_TAB = "access";
export function focusAccessRequestsTab(): void {
setAccountRaw(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB);
}

View file

@ -253,8 +253,9 @@ interface ReqConfig {
// Set by the app shell. Invoked whenever any request returns 401 so a session that ended // Set by the app shell. Invoked whenever any request returns 401 so a session that ended
// server-side (account suspended/deleted while the tab was open) can drop the user to the login // server-side (account suspended/deleted while the tab was open) can drop the user to the login
// page. The handler itself guards against firing when we were never signed in (public pages // page. The handler itself guards against firing when we were never signed in, so it's safe to
// legitimately 401 on /api/me), so it's safe to call for every 401. // call for every 401. (The bootstrap /api/me probe no longer 401s — it returns 200 with a null
// user when logged out — but other protected endpoints still do.)
let onUnauthorized: (() => void) | null = null; let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: (() => void) | null): void { export function setUnauthorizedHandler(fn: (() => void) | null): void {
onUnauthorized = fn; onUnauthorized = fn;
@ -755,7 +756,13 @@ export interface AdminDownloadQuota {
} }
export const api = { export const api = {
me: (): Promise<Me> => req("/api/me"), // Bootstrap probe: 200 always. Returns the user when signed in, or null when logged out
// (the endpoint answers `{authenticated:false}` rather than 401, so the public landing never
// logs a failed request to the console). React Query treats the null as data, not an error.
me: async (): Promise<Me | null> => {
const r = await req("/api/me");
return r && r.authenticated ? (r as Me) : null;
},
accounts: (): Promise<Account[]> => req("/api/me/accounts"), accounts: (): Promise<Account[]> => req("/api/me/accounts"),
deleteAccount: (): Promise<{ deleted: boolean }> => deleteAccount: (): Promise<{ deleted: boolean }> =>
req("/api/me/account", { method: "DELETE" }), req("/api/me/account", { method: "DELETE" }),

View file

@ -14,6 +14,19 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.22.2",
date: "2026-07-04",
summary: "Faster loading everywhere and better accessibility across the app.",
fixes: [
"The landing page loads much faster — its preview screenshots are now served in a modern, far smaller image format, and static assets are cached so repeat visits are near-instant.",
"The app now loads in smaller pieces: each section is fetched only when you open it, so the first screen appears sooner and a shared watch link no longer downloads the whole app.",
"Accessibility: toggles, colour swatches, sort menus and settings inputs now carry proper labels for screen readers and voice control, across every section of the app.",
],
chores: [
"Search-engine metadata (page description, robots.txt) and a cleaner console on the public page.",
],
},
{ {
version: "0.22.1", version: "0.22.1",
date: "2026-07-04", date: "2026-07-04",

View file

@ -1,22 +1,25 @@
import React from "react"; import React, { lazy, Suspense } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import ErrorBoundary from "./components/ErrorBoundary"; import ErrorBoundary from "./components/ErrorBoundary";
import { ConfirmProvider } from "./components/ConfirmProvider"; import { ConfirmProvider } from "./components/ConfirmProvider";
import PrivacyPolicy from "./components/legal/PrivacyPolicy";
import Terms from "./components/legal/Terms";
import WatchPage from "./components/WatchPage";
import "./i18n"; import "./i18n";
import "./index.css"; import "./index.css";
// Split by top-level route so each entry point is its own chunk: a public /watch share link or a
// legal page never downloads the authenticated app bundle, and the app never carries them either.
// The legal pages live outside the App tree (no /api/me) so Google's consent screen and reviewers
// can fetch them directly; the watch page is a login-free public player. Reached by full-page
// navigation (plain <a href>), so a simple pathname switch is enough — no router needed.
const App = lazy(() => import("./App"));
const PrivacyPolicy = lazy(() => import("./components/legal/PrivacyPolicy"));
const Terms = lazy(() => import("./components/legal/Terms"));
const WatchPage = lazy(() => import("./components/WatchPage"));
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: false } }, defaultOptions: { queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: false } },
}); });
// Public, login-free legal pages. They live outside the authenticated App tree (no /api/me)
// so Google's consent screen and reviewers can fetch them directly. Reached by full-page
// navigation (plain <a href>), so a simple pathname switch is enough — no router needed.
const path = window.location.pathname; const path = window.location.pathname;
const root = const root =
path === "/privacy" ? <PrivacyPolicy /> : path === "/privacy" ? <PrivacyPolicy /> :
@ -32,5 +35,7 @@ const root =
); );
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<React.StrictMode>{root}</React.StrictMode> <React.StrictMode>
<Suspense fallback={<div style={{ minHeight: "100vh" }} />}>{root}</Suspense>
</React.StrictMode>
); );