diff --git a/VERSION b/VERSION index a04c2de..934e07c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.22.1 \ No newline at end of file +0.22.2 \ No newline at end of file diff --git a/backend/app/auth.py b/backend/app/auth.py index f7f6041..29eafa4 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -719,6 +719,18 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> 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: """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 diff --git a/backend/app/main.py b/backend/app/main.py index d3fb78f..721712c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -112,6 +112,20 @@ async def setup_gate(request, call_next): 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(auth.router) app.include_router(sync.router) @@ -134,6 +148,13 @@ app.include_router(quota.router) app.include_router(version.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). STATIC_DIR = Path(__file__).parent / "static_spa" # 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: candidate = (STATIC_DIR / full_path).resolve() 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) diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index dca46a9..a810f89 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -9,6 +9,7 @@ from app.auth import ( has_read_scope, has_write_scope, is_allowed, + optional_current_user, purge_user, ) from app.db import get_db @@ -67,8 +68,13 @@ def switch_account( @router.get("") 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: + # 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 if user.role == "admin": pending_invites = ( @@ -80,6 +86,7 @@ def get_me( or 0 ) return { + "authenticated": True, "id": user.id, "email": user.email, "display_name": user.display_name, diff --git a/frontend/index.html b/frontend/index.html index fef8afa..c7ed055 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,16 @@ Siftlode + + + +
diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..43d4e0c --- /dev/null +++ b/frontend/public/robots.txt @@ -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/ diff --git a/frontend/public/welcome/channels.png b/frontend/public/welcome/channels.png deleted file mode 100644 index 20391e4..0000000 Binary files a/frontend/public/welcome/channels.png and /dev/null differ diff --git a/frontend/public/welcome/channels.webp b/frontend/public/welcome/channels.webp new file mode 100644 index 0000000..404a67d Binary files /dev/null and b/frontend/public/welcome/channels.webp differ diff --git a/frontend/public/welcome/feed.png b/frontend/public/welcome/feed.png deleted file mode 100644 index 26de65d..0000000 Binary files a/frontend/public/welcome/feed.png and /dev/null differ diff --git a/frontend/public/welcome/feed.webp b/frontend/public/welcome/feed.webp new file mode 100644 index 0000000..33db8a0 Binary files /dev/null and b/frontend/public/welcome/feed.webp differ diff --git a/frontend/public/welcome/playlists.png b/frontend/public/welcome/playlists.png deleted file mode 100644 index 6792c4d..0000000 Binary files a/frontend/public/welcome/playlists.png and /dev/null differ diff --git a/frontend/public/welcome/playlists.webp b/frontend/public/welcome/playlists.webp new file mode 100644 index 0000000..9512288 Binary files /dev/null and b/frontend/public/welcome/playlists.webp differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2a86416..8d0376b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 { useQuery, useQueryClient } from "@tanstack/react-query"; import { api, getActiveAccount, - HttpError, setActiveAccount, setUnauthorizedHandler, type FeedFilters, @@ -41,36 +40,43 @@ import { } from "./lib/storage"; import { useConfirm } from "./components/ConfirmProvider"; 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 SetupWizard from "./components/SetupWizard"; import Header from "./components/Header"; import NavSidebar from "./components/NavSidebar"; import Sidebar from "./components/Sidebar"; -import Feed from "./components/Feed"; -import ChannelPage from "./components/ChannelPage"; 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 OnboardingWizard from "./components/OnboardingWizard"; -import { shouldAutoOpenOnboarding } from "./lib/onboarding"; import Toaster from "./components/Toaster"; import ErrorDialog from "./components/ErrorDialog"; -import About from "./components/About"; -import ReleaseNotes from "./components/ReleaseNotes"; import VersionBanner from "./components/VersionBanner"; 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"; +// 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 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 = { tags: [], tagMode: "or", @@ -451,8 +457,9 @@ export default function App() { }, [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 - // `me` exists), reload to the login page at once (option 2 — 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. + // `me` is a user object), reload to the login page at once (also covers any click that hits + // 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(() => { setUnauthorizedHandler(() => { if (queryClient.getQueryData(["me"])) { @@ -646,14 +653,20 @@ export default function App() { return (
{t("common.loading")}
); - if (meQuery.error instanceof HttpError && meQuery.error.status === 401) - return ; + // /api/me answers 200 always now (null body = logged out), so a thrown error here is a real + // network/5xx failure, and no data means "not signed in" → the public landing. if (meQuery.error) return (
{t("common.somethingWrong")}
); + if (!meQuery.data) return ; + + // Shown briefly while a lazy page/module chunk loads (first visit to that page only). + const pageFallback = ( +
{t("common.loading")}
+ ); return (
@@ -689,15 +702,17 @@ export default function App() { )}
{channelView ? ( - + + + ) : ( <>
} openReleaseNotes(CURRENT_VERSION)} />
+ {page === "channels" ? ( )} +
)} @@ -776,21 +793,23 @@ export default function App() {
{meQuery.data && !meQuery.data.is_demo && } - {wizardOpen && meQuery.data && !meQuery.data.is_demo && ( - setWizardOpen(false)} /> - )} - {aboutOpen && ( - setAboutOpen(false)} - onOpenReleaseNotes={() => { - setAboutOpen(false); - openReleaseNotes(); - }} - /> - )} - {notesOpen && ( - setNotesOpen(false)} highlight={notesHighlight} /> - )} + + {wizardOpen && meQuery.data && !meQuery.data.is_demo && ( + setWizardOpen(false)} /> + )} + {aboutOpen && ( + setAboutOpen(false)} + onOpenReleaseNotes={() => { + setAboutOpen(false); + openReleaseNotes(); + }} + /> + )} + {notesOpen && ( + setNotesOpen(false)} highlight={notesHighlight} /> + )} + {/* Re-binds to the active page's scroll container on navigation (page or channel change). */} diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 9e43dec..20b69af 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react"; import { api, type AdminUserRow, type Invite, type Me } from "../lib/api"; import { notify } from "../lib/notifications"; -import { LS, setAccountRaw } from "../lib/storage"; +import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab"; import Tooltip from "./Tooltip"; import { Section } from "./ui/form"; 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 // 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. -// The persisted-tab storage key + the access-requests tab id, exported so a notification's -// "Review" link can pre-select that tab before navigating here (usePersistedTab reads the key -// 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); -} +// (The tab key + a "focus the Access tab" helper live in lib/adminUsersTab so callers can +// pre-select the tab without importing this lazy-loaded page — see that file.) export default function AdminUsers({ me }: { me: Me }) { const { t } = useTranslation(); diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index fad57be..fb110c7 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -224,7 +224,11 @@ function Field({
{isBool ? ( - onChange(v ? "true" : "false")} /> + onChange(v ? "true" : "false")} + /> ) : ( )} diff --git a/frontend/src/components/DownloadButton.tsx b/frontend/src/components/DownloadButton.tsx index 82e362e..81cee4e 100644 --- a/frontend/src/components/DownloadButton.tsx +++ b/frontend/src/components/DownloadButton.tsx @@ -1,9 +1,11 @@ -import { useState } from "react"; +import { lazy, Suspense, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Download } from "lucide-react"; 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"; // Self-contained download affordance for a video card / the player. Hidden for the demo account @@ -59,7 +61,9 @@ export default function DownloadButton({ {open && ( - setOpen(false)} /> + + setOpen(false)} /> + )} ); diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 2950b62..85e0834 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { lazy, Suspense, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -16,10 +16,12 @@ import { import clsx from "clsx"; import Tabs, { usePersistedTab } from "./Tabs"; import Modal from "./Modal"; -import DownloadDialog from "./DownloadDialog"; -import ProfileEditor from "./ProfileEditor"; -import VideoEditor from "./VideoEditor"; -import ShareDialog from "./ShareDialog"; +// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until +// used (the video editor in particular is heavy — filmstrip + scrubber). +const DownloadDialog = lazy(() => import("./DownloadDialog")); +const ProfileEditor = lazy(() => import("./ProfileEditor")); +const VideoEditor = lazy(() => import("./VideoEditor")); +const ShareDialog = lazy(() => import("./ShareDialog")); import { useConfirm } from "./ConfirmProvider"; import { useLiveQuery } from "../lib/useLiveQuery"; import { api, type DownloadJob, type Me } from "../lib/api"; @@ -582,19 +584,21 @@ export default function DownloadCenter({ me }: { me: Me }) { {tab === "system" && me.role === "admin" && } - {addSource && ( - { - setAddSource(null); - setAddUrl(""); - }} - /> - )} - {manage && setManage(false)} />} - {renameJob && setRenameJob(null)} />} - {shareJob && setShareJob(null)} />} - {editJob && setEditJob(null)} />} + + {addSource && ( + { + setAddSource(null); + setAddUrl(""); + }} + /> + )} + {manage && setManage(false)} />} + {renameJob && setRenameJob(null)} />} + {shareJob && setShareJob(null)} />} + {editJob && setEditJob(null)} />} +
); } diff --git a/frontend/src/components/DownloadDialog.tsx b/frontend/src/components/DownloadDialog.tsx index 1f27474..6c9e87b 100644 --- a/frontend/src/components/DownloadDialog.tsx +++ b/frontend/src/components/DownloadDialog.tsx @@ -1,9 +1,11 @@ -import { useState } from "react"; +import { lazy, Suspense, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import Modal from "./Modal"; -import ProfileEditor from "./ProfileEditor"; 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 { navigateTo } from "../lib/nav"; @@ -105,12 +107,14 @@ export default function DownloadDialog({
{manage && ( - { - setManage(false); - profilesQ.refetch(); - }} - /> + + { + setManage(false); + profilesQ.refetch(); + }} + /> + )} ); diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 3288e26..8152d59 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -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 { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { useDebounced } from "../lib/useDebounced"; 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; @@ -441,13 +442,15 @@ export default function Feed({ )} {activeVideo && ( - setActiveVideo(null)} - onState={onState} - onOpenChannel={onOpenChannel} - /> + + setActiveVideo(null)} + onState={onState} + onOpenChannel={onOpenChannel} + /> + )} ); @@ -579,6 +582,7 @@ export default function Feed({ 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" > {/* Relevance only ranks meaningfully when there's a search term — offer it then. */} @@ -646,13 +650,15 @@ export default function Feed({ /> )} {activeVideo && ( - setActiveVideo(null)} - onState={onState} - onOpenChannel={onOpenChannel} - /> + + setActiveVideo(null)} + onState={onState} + onOpenChannel={onOpenChannel} + /> + )} {isFetchingNextPage && (
{t("feed.loadingMore")}
diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 965d6be..345ef3e 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -258,7 +258,6 @@ export default function NavSidebar({ 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" title={t("header.feed")} - aria-label={t("header.feed")} > Siftlode diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 8844da3..07fcf0e 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -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 { api, type AppNotification, type FeedFilters } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; -import { focusAccessRequestsTab } from "./AdminUsers"; +import { focusAccessRequestsTab } from "../lib/adminUsersTab"; import { clearAll as clearClient, dismiss as dismissClient, diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 5229a8d..cbf846f 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -39,7 +39,7 @@ import { formatDuration } from "../lib/format"; import { notify } from "../lib/notifications"; import { getAccountRaw, LS, readAccountMerged, setAccountRaw, writeAccount } from "../lib/storage"; import { useUndoable } from "../lib/useUndoable"; -import PlayerModal from "./PlayerModal"; +const PlayerModal = lazy(() => import("./PlayerModal")); import UndoToolbar from "./UndoToolbar"; import { useConfirm } from "./ConfirmProvider"; @@ -140,6 +140,7 @@ function Row({ {index + 1}