Merge: promote dev to prod
This commit is contained in:
commit
a4c4e5a2b8
32 changed files with 304 additions and 140 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.22.1
|
0.22.2
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
7
frontend/public/robots.txt
Normal file
7
frontend/public/robots.txt
Normal 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 |
BIN
frontend/public/welcome/channels.webp
Normal file
BIN
frontend/public/welcome/channels.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 MiB |
BIN
frontend/public/welcome/feed.webp
Normal file
BIN
frontend/public/welcome/feed.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 440 KiB |
BIN
frontend/public/welcome/playlists.webp
Normal file
BIN
frontend/public/welcome/playlists.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
|
|
@ -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,15 +702,17 @@ 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 ? (
|
||||||
<ChannelPage
|
<Suspense fallback={pageFallback}>
|
||||||
key={channelView.id}
|
<ChannelPage
|
||||||
channelId={channelView.id}
|
key={channelView.id}
|
||||||
initialName={channelView.name}
|
channelId={channelView.id}
|
||||||
me={meQuery.data!}
|
initialName={channelView.name}
|
||||||
view={view}
|
me={meQuery.data!}
|
||||||
onBack={closeChannel}
|
view={view}
|
||||||
onOpenChannel={openChannel}
|
onBack={closeChannel}
|
||||||
/>
|
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,21 +793,23 @@ 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} />}
|
||||||
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
|
<Suspense fallback={null}>
|
||||||
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
|
||||||
)}
|
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
||||||
{aboutOpen && (
|
)}
|
||||||
<About
|
{aboutOpen && (
|
||||||
onClose={() => setAboutOpen(false)}
|
<About
|
||||||
onOpenReleaseNotes={() => {
|
onClose={() => setAboutOpen(false)}
|
||||||
setAboutOpen(false);
|
onOpenReleaseNotes={() => {
|
||||||
openReleaseNotes();
|
setAboutOpen(false);
|
||||||
}}
|
openReleaseNotes();
|
||||||
/>
|
}}
|
||||||
)}
|
/>
|
||||||
{notesOpen && (
|
)}
|
||||||
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
|
{notesOpen && (
|
||||||
)}
|
<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} />
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
<DownloadDialog source={videoId} title={title} onClose={() => setOpen(false)} />
|
<Suspense fallback={null}>
|
||||||
|
<DownloadDialog source={videoId} title={title} onClose={() => setOpen(false)} />
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,19 +584,21 @@ export default function DownloadCenter({ me }: { me: Me }) {
|
||||||
|
|
||||||
{tab === "system" && me.role === "admin" && <AdminSystem />}
|
{tab === "system" && me.role === "admin" && <AdminSystem />}
|
||||||
|
|
||||||
{addSource && (
|
<Suspense fallback={null}>
|
||||||
<DownloadDialog
|
{addSource && (
|
||||||
source={addSource}
|
<DownloadDialog
|
||||||
onClose={() => {
|
source={addSource}
|
||||||
setAddSource(null);
|
onClose={() => {
|
||||||
setAddUrl("");
|
setAddSource(null);
|
||||||
}}
|
setAddUrl("");
|
||||||
/>
|
}}
|
||||||
)}
|
/>
|
||||||
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
)}
|
||||||
{renameJob && <RenameModal job={renameJob} onClose={() => setRenameJob(null)} />}
|
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
||||||
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
|
{renameJob && <RenameModal job={renameJob} onClose={() => setRenameJob(null)} />}
|
||||||
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
|
||||||
|
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
<ProfileEditor
|
<Suspense fallback={null}>
|
||||||
onClose={() => {
|
<ProfileEditor
|
||||||
setManage(false);
|
onClose={() => {
|
||||||
profilesQ.refetch();
|
setManage(false);
|
||||||
}}
|
profilesQ.refetch();
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,13 +442,15 @@ export default function Feed({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeVideo && (
|
{activeVideo && (
|
||||||
<PlayerModal
|
<Suspense fallback={null}>
|
||||||
video={activeVideo.video}
|
<PlayerModal
|
||||||
startAt={activeVideo.startAt}
|
video={activeVideo.video}
|
||||||
onClose={() => setActiveVideo(null)}
|
startAt={activeVideo.startAt}
|
||||||
onState={onState}
|
onClose={() => setActiveVideo(null)}
|
||||||
onOpenChannel={onOpenChannel}
|
onState={onState}
|
||||||
/>
|
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,13 +650,15 @@ export default function Feed({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeVideo && (
|
{activeVideo && (
|
||||||
<PlayerModal
|
<Suspense fallback={null}>
|
||||||
video={activeVideo.video}
|
<PlayerModal
|
||||||
startAt={activeVideo.startAt}
|
video={activeVideo.video}
|
||||||
onClose={() => setActiveVideo(null)}
|
startAt={activeVideo.startAt}
|
||||||
onState={onState}
|
onClose={() => setActiveVideo(null)}
|
||||||
onOpenChannel={onOpenChannel}
|
onState={onState}
|
||||||
/>
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,14 +850,16 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{playingIndex != null && items[playingIndex] && (
|
{playingIndex != null && items[playingIndex] && (
|
||||||
<PlayerModal
|
<Suspense fallback={null}>
|
||||||
video={items[playingIndex]}
|
<PlayerModal
|
||||||
queue={items}
|
video={items[playingIndex]}
|
||||||
startIndex={playingIndex}
|
queue={items}
|
||||||
startAt={null}
|
startIndex={playingIndex}
|
||||||
onClose={() => setPlayingIndex(null)}
|
startAt={null}
|
||||||
onState={playerState}
|
onClose={() => setPlayingIndex(null)}
|
||||||
/>
|
onState={playerState}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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"}`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
15
frontend/src/lib/adminUsersTab.ts
Normal file
15
frontend/src/lib/adminUsersTab.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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" }),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue