Merge: promote dev to prod
This commit is contained in:
commit
db6b108a6f
13 changed files with 422 additions and 173 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.9.0
|
||||
0.10.0
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, HttpError, type FeedFilters } from "./lib/api";
|
||||
|
|
@ -17,8 +17,10 @@ import {
|
|||
saveLayoutLocal,
|
||||
type SidebarLayout,
|
||||
} from "./lib/sidebarLayout";
|
||||
import { configureNotifications, notify } from "./lib/notifications";
|
||||
import { setHintsEnabled } from "./lib/hints";
|
||||
import { configureNotifications, getNotifSettings, notify, type NotifSettings } from "./lib/notifications";
|
||||
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
||||
import { useConfirm } from "./components/ConfirmProvider";
|
||||
import type { PrefsController } from "./components/SettingsPanel";
|
||||
import Login from "./components/Login";
|
||||
import Header from "./components/Header";
|
||||
import NavSidebar from "./components/NavSidebar";
|
||||
|
|
@ -54,6 +56,18 @@ const DEFAULT_FILTERS: FeedFilters = {
|
|||
|
||||
const FILTERS_KEY = "subfeed.filters";
|
||||
const PAGE_KEY = "siftlode.page";
|
||||
const PERF_KEY = "subfeed.perfMode";
|
||||
|
||||
// The preferences edited on the Settings page. They apply locally for instant preview but
|
||||
// persist to the server only on an explicit Save — so App holds both the live "draft" (the
|
||||
// individual states below) and the last-saved "baseline" to compute dirty / revert on discard.
|
||||
type EditablePrefs = {
|
||||
theme: ThemePrefs;
|
||||
view: "grid" | "list";
|
||||
performanceMode: boolean;
|
||||
hints: boolean;
|
||||
notifications: NotifSettings;
|
||||
};
|
||||
|
||||
// Page is navigation state, not a filter, but it's also kept out of the address bar — so
|
||||
// persist it to localStorage to survive a reload. A share link's ?page= still wins.
|
||||
|
|
@ -81,9 +95,29 @@ function loadInitialFilters(): FeedFilters {
|
|||
|
||||
export default function App() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
// Refs the (stable) navigation handlers read so they always see the latest unsaved-prefs
|
||||
// state without being recreated; populated by an effect each render (below).
|
||||
const prefsDirtyRef = useRef(false);
|
||||
const discardRef = useRef<() => void>(() => {});
|
||||
const confirmRef = useRef(confirm);
|
||||
const pageRef = useRef<Page>("feed");
|
||||
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
||||
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||
const [view, setView] = useState<"grid" | "list">("grid");
|
||||
// Settings-page prefs draft (apply live, persist on Save — see EditablePrefs).
|
||||
const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1");
|
||||
const [hints, setHints] = useState(() => hintsEnabled());
|
||||
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
|
||||
const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
|
||||
theme: loadLocalTheme(),
|
||||
view: "grid",
|
||||
performanceMode: localStorage.getItem(PERF_KEY) === "1",
|
||||
hints: hintsEnabled(),
|
||||
notifications: getNotifSettings(),
|
||||
}));
|
||||
const [prefsSaveState, setPrefsSaveState] = useState<PrefsController["saveState"]>("idle");
|
||||
const saveMsgTimer = useRef<number | undefined>(undefined);
|
||||
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
||||
const [page, setPageState] = useState<Page>(loadInitialPage);
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
|
|
@ -125,12 +159,29 @@ export default function App() {
|
|||
|
||||
function setPage(next: Page) {
|
||||
if (next === page) return;
|
||||
const go = () => {
|
||||
setPageState(next);
|
||||
localStorage.setItem(PAGE_KEY, next);
|
||||
// Push an in-app history entry so the browser/mouse Back button steps through pages
|
||||
// instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean —
|
||||
// the page rides in history.state, not the query string (filters never go in the URL).
|
||||
window.history.pushState({ ...window.history.state, sfPage: next }, "");
|
||||
};
|
||||
// Guard leaving the Settings page with unsaved preference changes.
|
||||
if (page === "settings" && prefsDirtyRef.current) {
|
||||
void confirmRef.current({
|
||||
title: t("settings.unsaved.title"),
|
||||
message: t("settings.unsaved.message"),
|
||||
confirmLabel: t("settings.unsaved.discard"),
|
||||
danger: true,
|
||||
}).then((ok) => {
|
||||
if (!ok) return;
|
||||
discardRef.current();
|
||||
go();
|
||||
});
|
||||
return;
|
||||
}
|
||||
go();
|
||||
}
|
||||
|
||||
function setSidebarLayout(next: SidebarLayout) {
|
||||
|
|
@ -141,6 +192,15 @@ export default function App() {
|
|||
|
||||
useEffect(() => applyTheme(theme), [theme]);
|
||||
|
||||
// Apply the draft prefs locally for instant preview (their localStorage mirrors update via
|
||||
// the stores too); persistence to the server is deferred to an explicit Save.
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.perf = perf ? "1" : "";
|
||||
localStorage.setItem(PERF_KEY, perf ? "1" : "0");
|
||||
}, [perf]);
|
||||
useEffect(() => setHintsEnabled(hints), [hints]);
|
||||
useEffect(() => configureNotifications(notif), [notif]);
|
||||
|
||||
// Filters live in localStorage, not the address bar. If we arrived via a "Share view"
|
||||
// link, its params have already hydrated the initial state — persist them and strip the
|
||||
// query so the URL stays clean from here on.
|
||||
|
|
@ -162,6 +222,23 @@ export default function App() {
|
|||
);
|
||||
function onPop(e: PopStateEvent) {
|
||||
const p = (e.state?.sfPage as Page) ?? "feed";
|
||||
// Guard a Back step that leaves Settings with unsaved changes: re-assert the Settings
|
||||
// entry so nothing is silently stranded, then ask; on discard, go where Back headed.
|
||||
if (pageRef.current === "settings" && p !== "settings" && prefsDirtyRef.current) {
|
||||
window.history.pushState({ ...window.history.state, sfPage: "settings" }, "");
|
||||
void confirmRef.current({
|
||||
title: t("settings.unsaved.title"),
|
||||
message: t("settings.unsaved.message"),
|
||||
confirmLabel: t("settings.unsaved.discard"),
|
||||
danger: true,
|
||||
}).then((ok) => {
|
||||
if (!ok) return;
|
||||
discardRef.current();
|
||||
setPageState(p);
|
||||
localStorage.setItem(PAGE_KEY, p);
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPageState(p);
|
||||
localStorage.setItem(PAGE_KEY, p);
|
||||
}
|
||||
|
|
@ -182,22 +259,34 @@ export default function App() {
|
|||
useEffect(() => {
|
||||
const prefs = meQuery.data?.preferences;
|
||||
if (!prefs) return;
|
||||
if (prefs.theme) {
|
||||
const merged = { ...DEFAULT_THEME, ...prefs.theme };
|
||||
setThemeState(merged);
|
||||
saveLocalTheme(merged);
|
||||
}
|
||||
if (prefs.view === "grid" || prefs.view === "list") setView(prefs.view);
|
||||
// Adopt the server prefs as both the saved baseline and the live draft. The runtime
|
||||
// effects above apply the draft (theme/perf/hints/notifications) for display.
|
||||
const adopted: EditablePrefs = {
|
||||
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
|
||||
view: prefs.view === "grid" || prefs.view === "list" ? prefs.view : "grid",
|
||||
performanceMode:
|
||||
typeof prefs.performanceMode === "boolean"
|
||||
? prefs.performanceMode
|
||||
: localStorage.getItem(PERF_KEY) === "1",
|
||||
hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(),
|
||||
notifications: prefs.notifications
|
||||
? { ...getNotifSettings(), ...prefs.notifications }
|
||||
: getNotifSettings(),
|
||||
};
|
||||
setThemeState(adopted.theme);
|
||||
saveLocalTheme(adopted.theme);
|
||||
setView(adopted.view);
|
||||
setPerf(adopted.performanceMode);
|
||||
setHints(adopted.hints);
|
||||
setNotif(adopted.notifications);
|
||||
setSavedPrefs(adopted);
|
||||
// Out-of-scope prefs stay instant (edited outside the Settings page).
|
||||
if (prefs.sidebarLayout) {
|
||||
const l = normalizeLayout(prefs.sidebarLayout);
|
||||
setSidebarLayoutState(l);
|
||||
saveLayoutLocal(l);
|
||||
}
|
||||
if (isSupported(prefs.language)) setLanguage(prefs.language);
|
||||
if (prefs.notifications) configureNotifications(prefs.notifications);
|
||||
if (typeof prefs.hints === "boolean") setHintsEnabled(prefs.hints);
|
||||
if (typeof prefs.performanceMode === "boolean")
|
||||
document.documentElement.dataset.perf = prefs.performanceMode ? "1" : "";
|
||||
// Nudge admins when access requests are waiting (in lieu of a server-push bell).
|
||||
const pending = meQuery.data?.pending_invites ?? 0;
|
||||
if (meQuery.data?.role === "admin" && pending > 0) {
|
||||
|
|
@ -216,20 +305,79 @@ export default function App() {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [meQuery.data?.id]);
|
||||
|
||||
// Theme is part of the Settings draft: apply locally for preview, persist on Save.
|
||||
function setTheme(next: ThemePrefs) {
|
||||
setThemeState(next);
|
||||
saveLocalTheme(next);
|
||||
api.savePrefs({ theme: next }).catch(() => {});
|
||||
}
|
||||
function changeView(v: "grid" | "list") {
|
||||
setView(v);
|
||||
api.savePrefs({ view: v }).catch(() => {});
|
||||
}
|
||||
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
|
||||
function changeLanguage(code: LangCode) {
|
||||
setLanguage(code);
|
||||
api.savePrefs({ language: code }).catch(() => {});
|
||||
}
|
||||
|
||||
const prefsDirty =
|
||||
JSON.stringify({ theme, view, performanceMode: perf, hints, notifications: notif }) !==
|
||||
JSON.stringify(savedPrefs);
|
||||
|
||||
const savePrefs = useCallback(() => {
|
||||
const payload: EditablePrefs = { theme, view, performanceMode: perf, hints, notifications: notif };
|
||||
window.clearTimeout(saveMsgTimer.current);
|
||||
setPrefsSaveState("saving");
|
||||
api
|
||||
.savePrefs(payload)
|
||||
.then(() => {
|
||||
setSavedPrefs(payload);
|
||||
setPrefsSaveState("saved");
|
||||
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 2500);
|
||||
})
|
||||
.catch(() => {
|
||||
// api.req() already surfaces the failure (connection-lost status / error dialog);
|
||||
// just flag the bar so the user sees the save didn't take, then clear it.
|
||||
setPrefsSaveState("error");
|
||||
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 4000);
|
||||
});
|
||||
}, [theme, view, perf, hints, notif]);
|
||||
|
||||
const discardPrefs = useCallback(() => {
|
||||
setThemeState(savedPrefs.theme);
|
||||
saveLocalTheme(savedPrefs.theme);
|
||||
setView(savedPrefs.view);
|
||||
setPerf(savedPrefs.performanceMode);
|
||||
setHints(savedPrefs.hints);
|
||||
setNotif(savedPrefs.notifications);
|
||||
window.clearTimeout(saveMsgTimer.current);
|
||||
setPrefsSaveState("idle");
|
||||
}, [savedPrefs]);
|
||||
|
||||
const prefsCtl: PrefsController = {
|
||||
theme,
|
||||
setTheme,
|
||||
view,
|
||||
setView,
|
||||
perf,
|
||||
setPerf,
|
||||
hints,
|
||||
setHints,
|
||||
notif,
|
||||
setNotif,
|
||||
dirty: prefsDirty,
|
||||
save: savePrefs,
|
||||
discard: discardPrefs,
|
||||
saveState: prefsSaveState,
|
||||
};
|
||||
|
||||
// Keep the navigation guards (setPage / popstate) reading the latest values.
|
||||
useEffect(() => {
|
||||
prefsDirtyRef.current = prefsDirty;
|
||||
discardRef.current = discardPrefs;
|
||||
confirmRef.current = confirm;
|
||||
pageRef.current = page;
|
||||
});
|
||||
// No beforeunload guard: a reload/close can only raise the browser's own native prompt
|
||||
// (we can't show our in-app confirm there), and unsaved prefs are local-only — they
|
||||
// revert to the saved server baseline on the next load — so there's nothing to lose.
|
||||
|
||||
if (meQuery.isLoading)
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
|
||||
|
|
@ -307,10 +455,7 @@ export default function App() {
|
|||
) : page === "settings" ? (
|
||||
<SettingsPanel
|
||||
me={meQuery.data!}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
view={view}
|
||||
setView={changeView}
|
||||
prefs={prefsCtl}
|
||||
onOpenWizard={() => setWizardOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -144,18 +144,14 @@ export default function Feed({
|
|||
(id: string, status: string) => {
|
||||
setOverrides((o) => ({ ...o, [id]: status }));
|
||||
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
|
||||
// Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden"
|
||||
// notice (or resolving a stale one) is a claim of success, so firing it optimistically
|
||||
// would falsely report a change that the .catch below is about to roll back (e.g. when
|
||||
// the API is unreachable).
|
||||
api
|
||||
.setState(id, status)
|
||||
.then(() => qc.invalidateQueries({ queryKey: ["feed"] }))
|
||||
.catch(() =>
|
||||
// Server rejected the change — drop the optimistic override so the card
|
||||
// reverts to the real (unchanged) state instead of staying phantom-hidden.
|
||||
setOverrides((o) => {
|
||||
const next = { ...o };
|
||||
delete next[id];
|
||||
return next;
|
||||
})
|
||||
);
|
||||
.then(() => {
|
||||
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||
if (status === "hidden") {
|
||||
const v = loadedRef.current.find((x) => x.id === id);
|
||||
notify({
|
||||
|
|
@ -181,10 +177,20 @@ export default function Feed({
|
|||
meta: { kind: "video-watched", videoId: id, title: v?.title ?? "this video" },
|
||||
});
|
||||
} else if (status === "new") {
|
||||
// Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve any
|
||||
// stale hide/watch notice for this video so it doesn't linger with a dead action.
|
||||
// Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve
|
||||
// any stale hide/watch notice for this video so it doesn't linger with a dead action.
|
||||
resolveVideo(id);
|
||||
}
|
||||
})
|
||||
.catch(() =>
|
||||
// Server rejected the change — drop the optimistic override so the card
|
||||
// reverts to the real (unchanged) state instead of staying phantom-hidden.
|
||||
setOverrides((o) => {
|
||||
const next = { ...o };
|
||||
delete next[id];
|
||||
return next;
|
||||
})
|
||||
);
|
||||
},
|
||||
[qc]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,21 +1,37 @@
|
|||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, RotateCcw, Trash2, User, UserPlus, X } from "lucide-react";
|
||||
import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, RotateCcw, Save, Trash2, User, UserPlus, X } from "lucide-react";
|
||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||
import { api, type Invite, type Me } from "../lib/api";
|
||||
import { formatEta, quotaActionLabel } from "../lib/format";
|
||||
import Avatar from "./Avatar";
|
||||
import {
|
||||
configureNotifications,
|
||||
getNotifSettings,
|
||||
notify,
|
||||
type NotifSettings,
|
||||
} from "../lib/notifications";
|
||||
import { hintsEnabled, setHintsEnabled, subscribeHints } from "../lib/hints";
|
||||
import { notify, type NotifSettings } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
|
||||
// The Settings page edits server-persisted preferences as a draft: changes apply locally
|
||||
// for instant preview but reach the server only on an explicit Save (or revert on Discard).
|
||||
// App owns the draft + baseline (so the leave-the-page guard can see it); this controller is
|
||||
// how the panel reads/writes it. See App.tsx.
|
||||
export type PrefsSaveState = "idle" | "saving" | "saved" | "error";
|
||||
export interface PrefsController {
|
||||
theme: ThemePrefs;
|
||||
setTheme: (t: ThemePrefs) => void;
|
||||
view: "grid" | "list";
|
||||
setView: (v: "grid" | "list") => void;
|
||||
perf: boolean;
|
||||
setPerf: (v: boolean) => void;
|
||||
hints: boolean;
|
||||
setHints: (v: boolean) => void;
|
||||
notif: NotifSettings;
|
||||
setNotif: (n: NotifSettings) => void;
|
||||
dirty: boolean;
|
||||
save: () => void;
|
||||
discard: () => void;
|
||||
saveState: PrefsSaveState;
|
||||
}
|
||||
|
||||
type TabId = "appearance" | "notifications" | "sync" | "account";
|
||||
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
||||
{ id: "appearance", icon: Monitor },
|
||||
|
|
@ -37,17 +53,11 @@ function loadSettingsTab(): TabId {
|
|||
// shown by the Header; the tab rail stays as in-page sub-navigation.
|
||||
export default function SettingsPanel({
|
||||
me,
|
||||
theme,
|
||||
setTheme,
|
||||
view,
|
||||
setView,
|
||||
prefs,
|
||||
onOpenWizard,
|
||||
}: {
|
||||
me: Me;
|
||||
theme: ThemePrefs;
|
||||
setTheme: (t: ThemePrefs) => void;
|
||||
view: "grid" | "list";
|
||||
setView: (v: "grid" | "list") => void;
|
||||
prefs: PrefsController;
|
||||
onOpenWizard: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -59,7 +69,8 @@ export default function SettingsPanel({
|
|||
|
||||
return (
|
||||
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||
<div className="glass rounded-2xl overflow-hidden flex">
|
||||
<div className="glass rounded-2xl overflow-hidden">
|
||||
<div className="flex">
|
||||
{/* Vertical tab rail — never wraps; active is a clear accent pill. */}
|
||||
<nav className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1">
|
||||
{TABS.map((tabItem) => {
|
||||
|
|
@ -81,25 +92,56 @@ export default function SettingsPanel({
|
|||
})}
|
||||
</nav>
|
||||
|
||||
{/* All tabs stacked in one grid cell so the panel sizes to the tallest tab
|
||||
(stable height, no jump on switch); the active one is shown on top. */}
|
||||
<div className="grid flex-1 min-w-0">
|
||||
{TABS.map((tabItem) => (
|
||||
<div
|
||||
key={tabItem.id}
|
||||
className={`[grid-area:1/1] p-4 ${
|
||||
tab === tabItem.id ? "" : "invisible pointer-events-none"
|
||||
}`}
|
||||
{/* Render only the active tab so the panel sizes to its actual content. (Stacking
|
||||
every tab in one grid cell made the whole panel as tall as the tallest tab —
|
||||
Account — leaving the short tabs with dead space and a needless scrollbar.) */}
|
||||
<div className="flex-1 min-w-0 p-4">
|
||||
{tab === "appearance" && <Appearance prefs={prefs} />}
|
||||
{tab === "notifications" && <Notifications prefs={prefs} />}
|
||||
{tab === "sync" && <Sync me={me} />}
|
||||
{tab === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save/Discard bar — spans the whole card (a change can come from any tab). Only the
|
||||
Appearance/Notifications prefs are drafted; the Sync/Account tabs act immediately. */}
|
||||
<PrefsSaveBar prefs={prefs} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PrefsSaveBar({ prefs }: { prefs: PrefsController }) {
|
||||
const { t } = useTranslation();
|
||||
// Stay mounted while a transient "saved"/"error" message is fading, even after dirty clears.
|
||||
if (!prefs.dirty && prefs.saveState === "idle") return null;
|
||||
const saving = prefs.saveState === "saving";
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40">
|
||||
<span className="text-sm text-muted">
|
||||
{prefs.saveState === "saved"
|
||||
? <span className="text-emerald-500 inline-flex items-center gap-1.5"><Check className="w-4 h-4" />{t("settings.save.saved")}</span>
|
||||
: prefs.saveState === "error"
|
||||
? <span className="text-red-500">{t("settings.save.failed")}</span>
|
||||
: t("settings.save.unsaved")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={prefs.discard}
|
||||
disabled={saving || !prefs.dirty}
|
||||
className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
|
||||
>
|
||||
{tabItem.id === "appearance" && (
|
||||
<Appearance theme={theme} setTheme={setTheme} view={view} setView={setView} />
|
||||
)}
|
||||
{tabItem.id === "notifications" && <Notifications />}
|
||||
{tabItem.id === "sync" && <Sync me={me} />}
|
||||
{tabItem.id === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
{t("settings.save.discard")}
|
||||
</button>
|
||||
<button
|
||||
onClick={prefs.save}
|
||||
disabled={saving || !prefs.dirty}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? t("settings.save.saving") : t("settings.save.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -155,35 +197,11 @@ function Switch({ checked, onChange }: { checked: boolean; onChange: (v: boolean
|
|||
);
|
||||
}
|
||||
|
||||
const PERF_KEY = "subfeed.perfMode";
|
||||
|
||||
function Appearance({
|
||||
theme,
|
||||
setTheme,
|
||||
view,
|
||||
setView,
|
||||
}: {
|
||||
theme: ThemePrefs;
|
||||
setTheme: (t: ThemePrefs) => void;
|
||||
view: "grid" | "list";
|
||||
setView: (v: "grid" | "list") => void;
|
||||
}) {
|
||||
function Appearance({ prefs }: { prefs: PrefsController }) {
|
||||
const { t } = useTranslation();
|
||||
const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1");
|
||||
const hints = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.perf = perf ? "1" : "";
|
||||
}, [perf]);
|
||||
function togglePerf(v: boolean) {
|
||||
setPerf(v);
|
||||
localStorage.setItem(PERF_KEY, v ? "1" : "0");
|
||||
api.savePrefs({ performanceMode: v }).catch(() => {});
|
||||
}
|
||||
function toggleHints(v: boolean) {
|
||||
setHintsEnabled(v);
|
||||
api.savePrefs({ hints: v }).catch(() => {});
|
||||
}
|
||||
// Controlled by App's prefs draft: each change applies locally for preview but is only
|
||||
// persisted on an explicit Save (handled by the panel's Save/Discard bar).
|
||||
const { theme, setTheme, view, setView, perf, setPerf, hints, setHints } = prefs;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -217,13 +235,13 @@ function Appearance({
|
|||
label={t("settings.appearance.performanceMode")}
|
||||
hint={t("settings.appearance.performanceModeHint")}
|
||||
>
|
||||
<Switch checked={perf} onChange={togglePerf} />
|
||||
<Switch checked={perf} onChange={setPerf} />
|
||||
</Row>
|
||||
<Row
|
||||
label={t("settings.appearance.showHints")}
|
||||
hint={t("settings.appearance.showHintsHint")}
|
||||
>
|
||||
<Switch checked={hints} onChange={toggleHints} />
|
||||
<Switch checked={hints} onChange={setHints} />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
|
|
@ -243,15 +261,12 @@ function Appearance({
|
|||
);
|
||||
}
|
||||
|
||||
function Notifications() {
|
||||
function Notifications({ prefs }: { prefs: PrefsController }) {
|
||||
const { t } = useTranslation();
|
||||
const [cfg, setCfg] = useState<NotifSettings>(() => getNotifSettings());
|
||||
function update(p: Partial<NotifSettings>) {
|
||||
const next = { ...cfg, ...p };
|
||||
setCfg(next);
|
||||
configureNotifications(next);
|
||||
api.savePrefs({ notifications: next }).catch(() => {});
|
||||
}
|
||||
// Controlled by App's prefs draft (live preview via configureNotifications there); the
|
||||
// panel's Save bar persists it. The "send test" button below still fires immediately.
|
||||
const { notif: cfg, setNotif } = prefs;
|
||||
const update = (p: Partial<NotifSettings>) => setNotif({ ...cfg, ...p });
|
||||
const autoOn = cfg.durationMs > 0;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
"generic": "Der Server konnte die Aktion nicht ausführen. Bitte erneut versuchen.",
|
||||
"server": "Serverfehler",
|
||||
"ok": "OK",
|
||||
"offline": {
|
||||
"title": "Verbindung verloren",
|
||||
"body": "Server nicht erreichbar — er startet möglicherweise gerade neu. Dieser Hinweis verschwindet, sobald die Verbindung wieder steht."
|
||||
},
|
||||
"boundary": {
|
||||
"title": "Etwas ist schiefgelaufen.",
|
||||
"subtitle": "Die App ist auf einen unerwarteten Fehler gestoßen.",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,19 @@
|
|||
"sync": "Synchronisierung",
|
||||
"account": "Konto"
|
||||
},
|
||||
"save": {
|
||||
"unsaved": "Nicht gespeicherte Änderungen",
|
||||
"saving": "Speichern…",
|
||||
"saved": "Einstellungen gespeichert",
|
||||
"failed": "Speichern fehlgeschlagen",
|
||||
"save": "Speichern",
|
||||
"discard": "Verwerfen"
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "Nicht gespeicherte Änderungen",
|
||||
"message": "Du hast nicht gespeicherte Einstellungen. Verwerfen und verlassen?",
|
||||
"discard": "Änderungen verwerfen"
|
||||
},
|
||||
"appearance": {
|
||||
"colorScheme": "Farbschema",
|
||||
"display": "Anzeige",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
"generic": "The server couldn't carry out that action. Please try again.",
|
||||
"server": "Server error",
|
||||
"ok": "OK",
|
||||
"offline": {
|
||||
"title": "Connection lost",
|
||||
"body": "Couldn't reach the server — it may be restarting. This notice will clear once the connection is back."
|
||||
},
|
||||
"boundary": {
|
||||
"title": "Something went wrong.",
|
||||
"subtitle": "The app hit an unexpected error.",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,19 @@
|
|||
"sync": "Sync",
|
||||
"account": "Account"
|
||||
},
|
||||
"save": {
|
||||
"unsaved": "Unsaved changes",
|
||||
"saving": "Saving…",
|
||||
"saved": "Settings saved",
|
||||
"failed": "Couldn't save",
|
||||
"save": "Save",
|
||||
"discard": "Discard"
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "Unsaved changes",
|
||||
"message": "You have unsaved settings. Discard them and leave?",
|
||||
"discard": "Discard changes"
|
||||
},
|
||||
"appearance": {
|
||||
"colorScheme": "Color scheme",
|
||||
"display": "Display",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
"generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.",
|
||||
"server": "Szerverhiba",
|
||||
"ok": "OK",
|
||||
"offline": {
|
||||
"title": "Megszakadt a kapcsolat",
|
||||
"body": "Nem sikerült elérni a szervert — lehet, hogy épp újraindul. Ez az üzenet eltűnik, amint helyreáll a kapcsolat."
|
||||
},
|
||||
"boundary": {
|
||||
"title": "Hiba történt.",
|
||||
"subtitle": "Az alkalmazás váratlan hibába ütközött.",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,19 @@
|
|||
"sync": "Szinkronizálás",
|
||||
"account": "Fiók"
|
||||
},
|
||||
"save": {
|
||||
"unsaved": "Nem mentett változások",
|
||||
"saving": "Mentés…",
|
||||
"saved": "Beállítások elmentve",
|
||||
"failed": "Nem sikerült menteni",
|
||||
"save": "Mentés",
|
||||
"discard": "Elvetés"
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "Nem mentett változások",
|
||||
"message": "Vannak nem mentett beállításaid. Elveted őket és továbblépsz?",
|
||||
"discard": "Változások elvetése"
|
||||
},
|
||||
"appearance": {
|
||||
"colorScheme": "Színséma",
|
||||
"display": "Megjelenítés",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { notify } from "./notifications";
|
||||
import { notify, remove } from "./notifications";
|
||||
import i18n from "../i18n";
|
||||
import { reportError } from "./errorDialog";
|
||||
|
||||
|
|
@ -163,14 +163,24 @@ class HttpError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
// Collapse bursts of connection failures (e.g. while the server restarts) into a
|
||||
// single notification rather than one per failed request.
|
||||
let lastErrorNotifiedAt = 0;
|
||||
function notifyErrorThrottled(title: string, message: string): void {
|
||||
const now = Date.now();
|
||||
if (now - lastErrorNotifiedAt < 30_000) return;
|
||||
lastErrorNotifiedAt = now;
|
||||
notify({ level: "error", title, message });
|
||||
// A single, self-resolving "connection lost" status: while the server is unreachable we
|
||||
// show one sticky, transient (non-persisted) notice; the moment any request reaches the
|
||||
// server again we remove it. This makes good on its "clears once it's back" copy and keeps
|
||||
// bursts of concurrent failures from stacking up — there's only ever one live handle.
|
||||
let connectivityNotifId: number | null = null;
|
||||
function markConnectivityLost(): void {
|
||||
if (connectivityNotifId !== null) return;
|
||||
connectivityNotifId = notify({
|
||||
level: "error",
|
||||
title: i18n.t("errors.offline.title"),
|
||||
message: i18n.t("errors.offline.body"),
|
||||
transient: true,
|
||||
});
|
||||
}
|
||||
function markConnectivityRestored(): void {
|
||||
if (connectivityNotifId === null) return;
|
||||
remove(connectivityNotifId);
|
||||
connectivityNotifId = null;
|
||||
}
|
||||
|
||||
// Gateway statuses that mean "the upstream connection failed", not "the app rejected the
|
||||
|
|
@ -206,10 +216,7 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
|
|||
await delay(250);
|
||||
continue;
|
||||
}
|
||||
notifyErrorThrottled(
|
||||
"Connection lost",
|
||||
"Couldn't reach the server — it may be restarting. This will clear once it's back."
|
||||
);
|
||||
markConnectivityLost();
|
||||
throw e;
|
||||
}
|
||||
|
||||
|
|
@ -221,6 +228,10 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
|
|||
continue;
|
||||
}
|
||||
|
||||
// The server answered (anything that isn't a gateway error means it's reachable) —
|
||||
// clear a standing "connection lost" status if one is showing.
|
||||
if (!RETRIABLE_GATEWAY.has(r.status)) markConnectivityRestored();
|
||||
|
||||
if (!r.ok) {
|
||||
// Capture the server's reason (FastAPI returns `{ detail }`) so callers can react.
|
||||
let detail: string | undefined;
|
||||
|
|
@ -236,10 +247,7 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
|
|||
// A genuine 500 (real fault, carries a JSON detail) still gets the modal, as do
|
||||
// 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow.
|
||||
if (RETRIABLE_GATEWAY.has(r.status)) {
|
||||
notifyErrorThrottled(
|
||||
"Connection lost",
|
||||
"Couldn't reach the server — it may be restarting. This will clear once it's back."
|
||||
);
|
||||
markConnectivityLost();
|
||||
} else if (r.status >= 500) {
|
||||
reportError(detail || `${i18n.t("errors.server")} (${r.status})`);
|
||||
} else if (r.status === 400 || r.status === 409 || r.status === 422) {
|
||||
|
|
@ -456,8 +464,9 @@ export const api = {
|
|||
videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
|
||||
pauseSync: () => req("/api/sync/pause", { method: "POST" }),
|
||||
resumeSync: () => req("/api/sync/resume", { method: "POST" }),
|
||||
// Overwriting preferences is idempotent, so let it retry on a transient gateway blip.
|
||||
savePrefs: (p: Record<string, any>) =>
|
||||
req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }),
|
||||
req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }, { idempotent: true }),
|
||||
|
||||
// --- channel manager ---
|
||||
myStatus: (): Promise<MyStatus> => req("/api/sync/my-status"),
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ export interface Notification {
|
|||
ts: number;
|
||||
read: boolean;
|
||||
dismissed: boolean; // transient toast surface closed (still kept in history)
|
||||
// Live session-only status (e.g. "connection lost"): sticky toast (no auto-dismiss
|
||||
// timer) that the producer removes itself when the condition resolves. Never persisted
|
||||
// to history, so a reload can't orphan it with no live handle to clear it.
|
||||
transient: boolean;
|
||||
}
|
||||
|
||||
export interface NotifyInput {
|
||||
|
|
@ -48,6 +52,7 @@ export interface NotifyInput {
|
|||
meta?: NotifMeta;
|
||||
requiresInteraction?: boolean;
|
||||
sound?: boolean; // force the alert tone (when sound is enabled) even for an info toast
|
||||
transient?: boolean; // live status: sticky toast, not persisted (see Notification.transient)
|
||||
}
|
||||
|
||||
const HISTORY_KEY = "subfeed.notifications";
|
||||
|
|
@ -122,7 +127,7 @@ function load(): Notification[] {
|
|||
// resurrect as active toasts on reload — their auto-dismiss timers aren't
|
||||
// re-armed across a reload, so otherwise they'd stick forever. Also drop the
|
||||
// live `action` callback, which can't survive serialization.
|
||||
return raw.map((n) => ({ ...n, action: undefined, dismissed: true }) as Notification);
|
||||
return raw.map((n) => ({ ...n, action: undefined, dismissed: true, transient: false }) as Notification);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -130,7 +135,9 @@ function load(): Notification[] {
|
|||
|
||||
function persist() {
|
||||
try {
|
||||
const slim = items.map(({ action: _action, ...n }) => n);
|
||||
// Transient status notices are live-session only — never write them to history,
|
||||
// so a reload can't leave one stranded with no producer left to clear it.
|
||||
const slim = items.filter((n) => !n.transient).map(({ action: _action, ...n }) => n);
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(slim));
|
||||
} catch {
|
||||
/* ignore quota / serialization errors */
|
||||
|
|
@ -164,7 +171,9 @@ export function notify(input: NotifyInput): number {
|
|||
const requiresInteraction = input.requiresInteraction ?? false;
|
||||
const level = input.level ?? "info";
|
||||
// config.durationMs === 0 means "don't auto-dismiss" (toast stays until dismissed).
|
||||
const duration = requiresInteraction
|
||||
// A transient status stays put until its producer clears it (no auto-dismiss timer),
|
||||
// same as an interaction-awaiting notice.
|
||||
const duration = requiresInteraction || input.transient
|
||||
? undefined
|
||||
: level === "error" || level === "fatal"
|
||||
? ERROR_TTL
|
||||
|
|
@ -185,6 +194,7 @@ export function notify(input: NotifyInput): number {
|
|||
ts: Date.now(),
|
||||
read: false,
|
||||
dismissed: false,
|
||||
transient: input.transient ?? false,
|
||||
},
|
||||
];
|
||||
emit();
|
||||
|
|
|
|||
|
|
@ -14,6 +14,19 @@ export interface ReleaseEntry {
|
|||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.10.0",
|
||||
date: "2026-06-19",
|
||||
summary: "Settings you save explicitly, plus a calmer, more honest playback experience.",
|
||||
features: [
|
||||
"Settings now save explicitly: your changes preview instantly, but only apply to your account when you press Save — with a clear “Settings saved” confirmation. A Discard button reverts everything, and leaving the page with unsaved changes asks first.",
|
||||
],
|
||||
fixes: [
|
||||
"The in-app player no longer throws a spurious “Server error (502)” when a video finishes or while the server is briefly busy: passing hiccups are retried automatically, and if the server is momentarily unreachable you get a single “connection lost” notice that clears itself the moment it's back — instead of an error you had to dismiss.",
|
||||
"Confirmations like “Marked watched” or “Hidden” now appear only after the server has actually applied the change, so you'll never see a success message for something that didn't go through.",
|
||||
"Tidied the Settings page height — no more unnecessary empty space and scrolling.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.9.0",
|
||||
date: "2026-06-18",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue