aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/lib
diff options
context:
space:
mode:
authorEvan Simkowitz <esimkowitz@users.noreply.github.com>2025-12-14 16:39:25 -0800
committerGitHub <noreply@github.com>2025-12-15 00:39:25 +0000
commit7f4202afd73105b850498b55ad66922b3505f0e3 (patch)
treea45f9f1b2599f4c9925e36dc51563b06ba6854ac /apps/web/lib
parent6db14ac492cd5d9e26d0d986513771f14faa7fd0 (diff)
downloadkarakeep-7f4202afd73105b850498b55ad66922b3505f0e3.tar.zst
feat: Add unified reader settings with local overrides (#2230)
* Add initial impl * fix some format inconsistencies, add indicator in user settings when local is out of sync * Fix sliders in user settings, unify constants and formatting * address CodeRabbit suggestions * add mobile implementation * address coderabbit nitpicks * fix responsiveness of the reader settings popover * Move more of the web UI strings to i18n * update translations for more coverage * remove duplicate logic/definitions * fix android font family * add shared reading setting hook between web and mobile * unify reader settings context for both web and mobile * remove unused export * address coderabbit suggestions * fix tests
Diffstat (limited to 'apps/web/lib')
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json33
-rw-r--r--apps/web/lib/readerSettings.tsx155
-rw-r--r--apps/web/lib/userSettings.tsx3
3 files changed, 191 insertions, 0 deletions
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index 33c7d6e2..d05ca702 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -1,5 +1,6 @@
{
"common": {
+ "default": "Default",
"url": "URL",
"name": "Name",
"email": "Email",
@@ -131,6 +132,38 @@
"show": "Show archived bookmarks in tags and lists",
"hide": "Hide archived bookmarks in tags and lists"
}
+ },
+ "reader_settings": {
+ "title": "Reader Settings",
+ "description": "Configure default text settings for the reader view. These settings sync across all your devices.",
+ "font_family": "Font Family",
+ "font_size": "Font Size",
+ "line_height": "Line Height",
+ "save_as_default": "Save as default",
+ "clear_defaults": "Clear all defaults",
+ "not_set": "Not set",
+ "using_default": "Using client default",
+ "preview": "Preview",
+ "preview_text": "The quick brown fox jumps over the lazy dog. This is how your reader view text will appear.",
+ "defaults_cleared": "Reader defaults have been cleared",
+ "local_overrides_title": "Device-specific settings active",
+ "local_overrides_description": "This device has reader settings that differ from your global defaults:",
+ "local_overrides_cleared": "Device-specific settings have been cleared",
+ "clear_local_overrides": "Clear device settings",
+ "serif": "Serif",
+ "sans": "Sans Serif",
+ "mono": "Monospace",
+ "tooltip_default": "Reading settings",
+ "tooltip_preview": "Unsaved preview changes",
+ "tooltip_local": "Device settings differ from global",
+ "tooltip_preview_and_local": "Unsaved preview changes; device settings differ from global",
+ "reset_preview": "Reset preview",
+ "save_to_device": "This device",
+ "save_to_all_devices": "All devices",
+ "save_hint": "Save settings for this device only or sync across all devices",
+ "adjust_hint": "Adjust settings above to preview changes",
+ "clear_override_hint": "Clear device override to use global setting ({{value}})",
+ "preview_inline": "(preview)"
}
},
"stats": {
diff --git a/apps/web/lib/readerSettings.tsx b/apps/web/lib/readerSettings.tsx
new file mode 100644
index 00000000..5966f287
--- /dev/null
+++ b/apps/web/lib/readerSettings.tsx
@@ -0,0 +1,155 @@
+"use client";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+} from "react";
+
+import {
+ ReaderSettingsProvider as BaseReaderSettingsProvider,
+ useReaderSettingsContext,
+} from "@karakeep/shared-react/hooks/reader-settings";
+import {
+ ReaderSettings,
+ ReaderSettingsPartial,
+} from "@karakeep/shared/types/readers";
+
+const LOCAL_STORAGE_KEY = "karakeep-reader-settings";
+
+function getLocalOverridesFromStorage(): ReaderSettingsPartial {
+ if (typeof window === "undefined") return {};
+ try {
+ const stored = localStorage.getItem(LOCAL_STORAGE_KEY);
+ return stored ? JSON.parse(stored) : {};
+ } catch {
+ return {};
+ }
+}
+
+function saveLocalOverridesToStorage(overrides: ReaderSettingsPartial): void {
+ if (typeof window === "undefined") return;
+ localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(overrides));
+}
+
+// Session overrides context - web-specific feature for live preview
+interface SessionOverridesContextValue {
+ sessionOverrides: ReaderSettingsPartial;
+ setSessionOverrides: React.Dispatch<
+ React.SetStateAction<ReaderSettingsPartial>
+ >;
+}
+
+const SessionOverridesContext =
+ createContext<SessionOverridesContextValue | null>(null);
+
+export function ReaderSettingsProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [sessionOverrides, setSessionOverrides] =
+ useState<ReaderSettingsPartial>({});
+
+ const sessionValue = useMemo(
+ () => ({
+ sessionOverrides,
+ setSessionOverrides,
+ }),
+ [sessionOverrides],
+ );
+
+ // Memoize callbacks to prevent unnecessary re-renders
+ const getLocalOverrides = useCallback(getLocalOverridesFromStorage, []);
+ const saveLocalOverrides = useCallback(saveLocalOverridesToStorage, []);
+ const onClearSessionOverrides = useCallback(() => {
+ setSessionOverrides({});
+ }, []);
+
+ return (
+ <BaseReaderSettingsProvider
+ getLocalOverrides={getLocalOverrides}
+ saveLocalOverrides={saveLocalOverrides}
+ sessionOverrides={sessionOverrides}
+ onClearSessionOverrides={onClearSessionOverrides}
+ >
+ <SessionOverridesContext.Provider value={sessionValue}>
+ {children}
+ </SessionOverridesContext.Provider>
+ </BaseReaderSettingsProvider>
+ );
+}
+
+export function useReaderSettings() {
+ const sessionContext = useContext(SessionOverridesContext);
+ if (!sessionContext) {
+ throw new Error(
+ "useReaderSettings must be used within a ReaderSettingsProvider",
+ );
+ }
+
+ const { sessionOverrides, setSessionOverrides } = sessionContext;
+ const baseSettings = useReaderSettingsContext();
+
+ // Update session override (live preview, not persisted)
+ const updateSession = useCallback(
+ (updates: ReaderSettingsPartial) => {
+ setSessionOverrides((prev) => ({ ...prev, ...updates }));
+ },
+ [setSessionOverrides],
+ );
+
+ // Clear all session overrides
+ const clearSession = useCallback(() => {
+ setSessionOverrides({});
+ }, [setSessionOverrides]);
+
+ // Save current settings to local storage (this device only)
+ const saveToDevice = useCallback(() => {
+ const newLocalOverrides = {
+ ...baseSettings.localOverrides,
+ ...sessionOverrides,
+ };
+ baseSettings.setLocalOverrides(newLocalOverrides);
+ saveLocalOverridesToStorage(newLocalOverrides);
+ setSessionOverrides({});
+ }, [baseSettings, sessionOverrides, setSessionOverrides]);
+
+ // Clear a single local override
+ const clearLocalOverride = useCallback(
+ (key: keyof ReaderSettings) => {
+ baseSettings.clearLocal(key);
+ },
+ [baseSettings],
+ );
+
+ // Check if there are unsaved session changes
+ const hasSessionChanges = Object.keys(sessionOverrides).length > 0;
+
+ return {
+ // Current effective settings (what should be displayed)
+ settings: baseSettings.settings,
+
+ // Raw values for UI indicators
+ serverSettings: baseSettings.serverDefaults,
+ localOverrides: baseSettings.localOverrides,
+ sessionOverrides,
+
+ // State indicators
+ hasSessionChanges,
+ hasLocalOverrides: baseSettings.hasLocalOverrides,
+ isSaving: baseSettings.isSaving,
+
+ // Actions
+ updateSession,
+ clearSession,
+ saveToDevice,
+ clearLocalOverrides: baseSettings.clearAllLocal,
+ clearLocalOverride,
+ saveToServer: baseSettings.saveAsDefault,
+ updateServerSetting: baseSettings.saveAsDefault,
+ clearServerDefaults: baseSettings.clearAllDefaults,
+ };
+}
diff --git a/apps/web/lib/userSettings.tsx b/apps/web/lib/userSettings.tsx
index c7a133b7..2bb7c8a5 100644
--- a/apps/web/lib/userSettings.tsx
+++ b/apps/web/lib/userSettings.tsx
@@ -13,6 +13,9 @@ export const UserSettingsContext = createContext<ZUserSettings>({
backupsEnabled: false,
backupsFrequency: "daily",
backupsRetentionDays: 7,
+ readerFontSize: null,
+ readerLineHeight: null,
+ readerFontFamily: null,
});
export function UserSettingsContextProvider({