From 7f4202afd73105b850498b55ad66922b3505f0e3 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Sun, 14 Dec 2025 16:39:25 -0800 Subject: 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 --- apps/mobile/app/dashboard/(tabs)/settings.tsx | 12 + apps/mobile/app/dashboard/_layout.tsx | 8 + .../app/dashboard/bookmarks/[slug]/index.tsx | 27 +- .../app/dashboard/settings/reader-settings.tsx | 264 ++++++++++++ .../components/bookmarks/BookmarkLinkPreview.tsx | 43 +- .../bookmarks/BookmarkLinkTypeSelector.tsx | 6 +- apps/mobile/lib/providers.tsx | 5 +- apps/mobile/lib/readerSettings.tsx | 93 +++++ apps/mobile/lib/settings.ts | 8 + apps/web/app/dashboard/layout.tsx | 37 +- apps/web/app/reader/[bookmarkId]/page.tsx | 140 +------ apps/web/app/reader/layout.tsx | 39 ++ apps/web/app/settings/info/page.tsx | 2 + apps/web/app/settings/layout.tsx | 15 +- .../dashboard/preview/LinkContentSection.tsx | 34 +- .../dashboard/preview/ReaderSettingsPopover.tsx | 457 +++++++++++++++++++++ apps/web/components/settings/ReaderSettings.tsx | 288 +++++++++++++ apps/web/lib/i18n/locales/en/translation.json | 33 ++ apps/web/lib/readerSettings.tsx | 155 +++++++ apps/web/lib/userSettings.tsx | 3 + 20 files changed, 1484 insertions(+), 185 deletions(-) create mode 100644 apps/mobile/app/dashboard/settings/reader-settings.tsx create mode 100644 apps/mobile/lib/readerSettings.tsx create mode 100644 apps/web/app/reader/layout.tsx create mode 100644 apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx create mode 100644 apps/web/components/settings/ReaderSettings.tsx create mode 100644 apps/web/lib/readerSettings.tsx (limited to 'apps') diff --git a/apps/mobile/app/dashboard/(tabs)/settings.tsx b/apps/mobile/app/dashboard/(tabs)/settings.tsx index 76216e00..7c1e00d6 100644 --- a/apps/mobile/app/dashboard/(tabs)/settings.tsx +++ b/apps/mobile/app/dashboard/(tabs)/settings.tsx @@ -87,6 +87,18 @@ export default function Dashboard() { + + + + Reader Text Settings + + + + Show note preview in bookmark + ); } diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx index 7bf0f118..8fd04115 100644 --- a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx +++ b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import { KeyboardAvoidingView } from "react-native"; +import { KeyboardAvoidingView, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Stack, useLocalSearchParams } from "expo-router"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import BookmarkAssetView from "@/components/bookmarks/BookmarkAssetView"; import BookmarkLinkTypeSelector, { BookmarkLinkType, @@ -13,12 +13,14 @@ import FullPageError from "@/components/FullPageError"; import FullPageSpinner from "@/components/ui/FullPageSpinner"; import useAppSettings from "@/lib/settings"; import { api } from "@/lib/trpc"; +import { Settings } from "lucide-react-native"; import { useColorScheme } from "nativewind"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; export default function BookmarkView() { const insets = useSafeAreaInsets(); + const router = useRouter(); const { slug } = useLocalSearchParams(); const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; @@ -87,11 +89,22 @@ export default function BookmarkView() { headerTintColor: isDark ? "#fff" : "#000", headerRight: () => bookmark.content.type === BookmarkTypes.LINK ? ( - setBookmarkLinkType(type)} - bookmark={bookmark} - /> + + {bookmarkLinkType === "reader" && ( + + router.push("/dashboard/settings/reader-settings") + } + > + + + )} + setBookmarkLinkType(type)} + bookmark={bookmark} + /> + ) : undefined, }} /> diff --git a/apps/mobile/app/dashboard/settings/reader-settings.tsx b/apps/mobile/app/dashboard/settings/reader-settings.tsx new file mode 100644 index 00000000..6c522557 --- /dev/null +++ b/apps/mobile/app/dashboard/settings/reader-settings.tsx @@ -0,0 +1,264 @@ +import { useEffect, useState } from "react"; +import { Pressable, ScrollView, View } from "react-native"; +import { Slider } from "react-native-awesome-slider"; +import { runOnJS, useSharedValue } from "react-native-reanimated"; +import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; +import { Divider } from "@/components/ui/Divider"; +import { Text } from "@/components/ui/Text"; +import { MOBILE_FONT_FAMILIES, useReaderSettings } from "@/lib/readerSettings"; +import { useColorScheme } from "@/lib/useColorScheme"; +import { Check, RotateCcw } from "lucide-react-native"; + +import { + formatFontFamily, + formatFontSize, + formatLineHeight, + READER_SETTING_CONSTRAINTS, +} from "@karakeep/shared/types/readers"; +import { ZReaderFontFamily } from "@karakeep/shared/types/users"; + +export default function ReaderSettingsPage() { + const { isDarkColorScheme: isDark } = useColorScheme(); + + const { + settings, + localOverrides, + hasLocalOverrides, + hasServerDefaults, + updateLocal, + clearAllLocal, + saveAsDefault, + clearAllDefaults, + } = useReaderSettings(); + + const { + fontSize: effectiveFontSize, + lineHeight: effectiveLineHeight, + fontFamily: effectiveFontFamily, + } = settings; + + // Shared values for sliders + const fontSizeProgress = useSharedValue(effectiveFontSize); + const fontSizeMin = useSharedValue( + READER_SETTING_CONSTRAINTS.fontSize.min, + ); + const fontSizeMax = useSharedValue( + READER_SETTING_CONSTRAINTS.fontSize.max, + ); + + const lineHeightProgress = useSharedValue(effectiveLineHeight); + const lineHeightMin = useSharedValue( + READER_SETTING_CONSTRAINTS.lineHeight.min, + ); + const lineHeightMax = useSharedValue( + READER_SETTING_CONSTRAINTS.lineHeight.max, + ); + + // Display values for showing rounded values while dragging + const [displayFontSize, setDisplayFontSize] = useState(effectiveFontSize); + const [displayLineHeight, setDisplayLineHeight] = + useState(effectiveLineHeight); + + // Sync slider progress and display values with effective settings + useEffect(() => { + fontSizeProgress.value = effectiveFontSize; + setDisplayFontSize(effectiveFontSize); + }, [effectiveFontSize]); + + useEffect(() => { + lineHeightProgress.value = effectiveLineHeight; + setDisplayLineHeight(effectiveLineHeight); + }, [effectiveLineHeight]); + + const handleFontFamilyChange = (fontFamily: ZReaderFontFamily) => { + updateLocal({ fontFamily }); + }; + + const handleFontSizeChange = (value: number) => { + updateLocal({ fontSize: Math.round(value) }); + }; + + const handleLineHeightChange = (value: number) => { + updateLocal({ lineHeight: Math.round(value * 10) / 10 }); + }; + + const handleSaveAsDefault = () => { + saveAsDefault(); + // Note: clearAllLocal is called automatically in the shared hook's onSuccess + }; + + const handleClearLocalOverrides = () => { + clearAllLocal(); + }; + + const handleClearServerDefaults = () => { + clearAllDefaults(); + }; + + const fontFamilyOptions: ZReaderFontFamily[] = ["serif", "sans", "mono"]; + + return ( + + + {/* Font Family Selection */} + + + Font Family + {localOverrides.fontFamily !== undefined && ( + (local) + )} + + + {fontFamilyOptions.map((fontFamily, index) => { + const isChecked = effectiveFontFamily === fontFamily; + return ( + + handleFontFamilyChange(fontFamily)} + className="flex flex-row items-center justify-between py-2" + > + + {formatFontFamily(fontFamily)} + + {isChecked && } + + {index < fontFamilyOptions.length - 1 && ( + + )} + + ); + })} + + + + {/* Font Size */} + + + Font Size ({formatFontSize(displayFontSize)}) + {localOverrides.fontSize !== undefined && ( + (local) + )} + + + + {READER_SETTING_CONSTRAINTS.fontSize.min} + + + null} + onValueChange={(value) => { + "worklet"; + runOnJS(setDisplayFontSize)(Math.round(value)); + }} + onSlidingComplete={(value) => + handleFontSizeChange(Math.round(value)) + } + /> + + + {READER_SETTING_CONSTRAINTS.fontSize.max} + + + + + {/* Line Height */} + + + Line Height ({formatLineHeight(displayLineHeight)}) + {localOverrides.lineHeight !== undefined && ( + (local) + )} + + + + {READER_SETTING_CONSTRAINTS.lineHeight.min} + + + null} + onValueChange={(value) => { + "worklet"; + runOnJS(setDisplayLineHeight)(Math.round(value * 10) / 10); + }} + onSlidingComplete={handleLineHeightChange} + /> + + + {READER_SETTING_CONSTRAINTS.lineHeight.max} + + + + + {/* Preview */} + + + Preview + + + + The quick brown fox jumps over the lazy dog. Pack my box with five + dozen liquor jugs. How vexingly quick daft zebras jump! + + + + + + + {/* Save as Default */} + + + Save as Default (All Devices) + + + + {/* Clear Local */} + {hasLocalOverrides && ( + + + Clear Local Overrides + + )} + + {/* Clear Server */} + {hasServerDefaults && ( + + + Clear Server Defaults + + )} + + + ); +} diff --git a/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx b/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx index 730bcd08..e0b592d6 100644 --- a/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx +++ b/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx @@ -5,6 +5,7 @@ import WebView from "react-native-webview"; import { WebViewSourceUri } from "react-native-webview/lib/WebViewTypes"; import { Text } from "@/components/ui/Text"; import { useAssetUrl } from "@/lib/hooks"; +import { useReaderSettings, WEBVIEW_FONT_FAMILIES } from "@/lib/readerSettings"; import { api } from "@/lib/trpc"; import { useColorScheme } from "@/lib/useColorScheme"; @@ -38,6 +39,7 @@ export function BookmarkLinkReaderPreview({ bookmark: ZBookmark; }) { const { isDarkColorScheme: isDark } = useColorScheme(); + const { settings: readerSettings } = useReaderSettings(); const { data: bookmarkWithContent, @@ -61,6 +63,10 @@ export function BookmarkLinkReaderPreview({ throw new Error("Wrong content type rendered"); } + const fontFamily = WEBVIEW_FONT_FAMILIES[readerSettings.fontFamily]; + const fontSize = readerSettings.fontSize; + const lineHeight = readerSettings.lineHeight; + return ( diff --git a/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx b/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx index 58cbcc8d..c7fd4be3 100644 --- a/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx +++ b/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx @@ -43,7 +43,7 @@ export default function BookmarkLinkTypeSelector({ }: BookmarkLinkTypeSelectorProps) { const availableTypes = getAvailableViewTypes(bookmark); - const allActions = [ + const viewActions = [ { id: "reader" as const, title: "Reader View", @@ -66,7 +66,7 @@ export default function BookmarkLinkTypeSelector({ }, ]; - const availableActions = allActions.filter((action) => + const availableViewActions = viewActions.filter((action) => availableTypes.includes(action.id), ); @@ -76,7 +76,7 @@ export default function BookmarkLinkTypeSelector({ Haptics.selectionAsync(); onChange(nativeEvent.event as BookmarkLinkType); }} - actions={availableActions} + actions={availableViewActions} shouldOpenOnLongPress={false} > Haptics.selectionAsync()} color="gray" /> diff --git a/apps/mobile/lib/providers.tsx b/apps/mobile/lib/providers.tsx index 938b8aeb..36ed7e71 100644 --- a/apps/mobile/lib/providers.tsx +++ b/apps/mobile/lib/providers.tsx @@ -4,6 +4,7 @@ import { ToastProvider } from "@/components/ui/Toast"; import { TRPCProvider } from "@karakeep/shared-react/providers/trpc-provider"; +import { ReaderSettingsProvider } from "./readerSettings"; import useAppSettings from "./settings"; export function Providers({ children }: { children: React.ReactNode }) { @@ -20,7 +21,9 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/apps/mobile/lib/readerSettings.tsx b/apps/mobile/lib/readerSettings.tsx new file mode 100644 index 00000000..9a3fc835 --- /dev/null +++ b/apps/mobile/lib/readerSettings.tsx @@ -0,0 +1,93 @@ +import { ReactNode, useCallback } from "react"; +import { Platform } from "react-native"; + +import { + ReaderSettingsProvider as BaseReaderSettingsProvider, + useReaderSettingsContext, +} from "@karakeep/shared-react/hooks/reader-settings"; +import { ReaderSettingsPartial } from "@karakeep/shared/types/readers"; +import { ZReaderFontFamily } from "@karakeep/shared/types/users"; + +import { useSettings } from "./settings"; + +// Mobile-specific font families for native Text components +// On Android, use generic font family names: "serif", "sans-serif", "monospace" +// On iOS, use specific font names like "Georgia" and "Courier" +// Note: undefined means use the system default font +export const MOBILE_FONT_FAMILIES: Record< + ZReaderFontFamily, + string | undefined +> = Platform.select({ + android: { + serif: "serif", + sans: undefined, + mono: "monospace", + }, + default: { + serif: "Georgia", + sans: undefined, + mono: "Courier", + }, +})!; + +// Font families for WebView HTML content (CSS font stacks) +export const WEBVIEW_FONT_FAMILIES: Record = { + serif: "Georgia, 'Times New Roman', serif", + sans: "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif", + mono: "ui-monospace, Menlo, Monaco, 'Courier New', monospace", +} as const; + +/** + * Mobile-specific provider for reader settings. + * Wraps the shared provider with mobile storage callbacks. + */ +export function ReaderSettingsProvider({ children }: { children: ReactNode }) { + // Read from zustand store directly to keep callback stable (empty deps). + const getLocalOverrides = useCallback((): ReaderSettingsPartial => { + const currentSettings = useSettings.getState().settings.settings; + return { + fontSize: currentSettings.readerFontSize, + lineHeight: currentSettings.readerLineHeight, + fontFamily: currentSettings.readerFontFamily, + }; + }, []); + + const saveLocalOverrides = useCallback((overrides: ReaderSettingsPartial) => { + const currentSettings = useSettings.getState().settings.settings; + // Remove reader settings keys first, then add back only defined ones + const { + readerFontSize: _fs, + readerLineHeight: _lh, + readerFontFamily: _ff, + ...rest + } = currentSettings; + + const newSettings = { ...rest }; + if (overrides.fontSize !== undefined) { + (newSettings as typeof currentSettings).readerFontSize = + overrides.fontSize; + } + if (overrides.lineHeight !== undefined) { + (newSettings as typeof currentSettings).readerLineHeight = + overrides.lineHeight; + } + if (overrides.fontFamily !== undefined) { + (newSettings as typeof currentSettings).readerFontFamily = + overrides.fontFamily; + } + + useSettings.getState().setSettings(newSettings); + }, []); + + return ( + + {children} + + ); +} + +// Re-export the context hook as useReaderSettings for mobile consumers +export { useReaderSettingsContext as useReaderSettings }; diff --git a/apps/mobile/lib/settings.ts b/apps/mobile/lib/settings.ts index 40a33976..745c778d 100644 --- a/apps/mobile/lib/settings.ts +++ b/apps/mobile/lib/settings.ts @@ -2,6 +2,8 @@ import * as SecureStore from "expo-secure-store"; import { z } from "zod"; import { create } from "zustand"; +import { zReaderFontFamilySchema } from "@karakeep/shared/types/users"; + const SETTING_NAME = "settings"; const zSettingsSchema = z.object({ @@ -16,6 +18,10 @@ const zSettingsSchema = z.object({ .default("reader"), showNotes: z.boolean().optional().default(false), customHeaders: z.record(z.string(), z.string()).optional().default({}), + // Reader settings (local device overrides) + readerFontSize: z.number().int().min(12).max(24).optional(), + readerLineHeight: z.number().min(1.2).max(2.5).optional(), + readerFontFamily: zReaderFontFamilySchema.optional(), }); export type Settings = z.infer; @@ -73,3 +79,5 @@ export default function useAppSettings() { return { ...settings, setSettings, load }; } + +export { useSettings }; diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx index 911d542c..be65e66a 100644 --- a/apps/web/app/dashboard/layout.tsx +++ b/apps/web/app/dashboard/layout.tsx @@ -4,6 +4,7 @@ import MobileSidebar from "@/components/shared/sidebar/MobileSidebar"; import Sidebar from "@/components/shared/sidebar/Sidebar"; import SidebarLayout from "@/components/shared/sidebar/SidebarLayout"; import { Separator } from "@/components/ui/separator"; +import { ReaderSettingsProvider } from "@/lib/readerSettings"; import { UserSettingsContextProvider } from "@/lib/userSettings"; import { api } from "@/server/api/client"; import { getServerAuthSession } from "@/server/auth"; @@ -98,23 +99,25 @@ export default async function Dashboard({ return ( - - - - - } - /> - } - mobileSidebar={} - modal={modal} - > - {children} - + + + + + + } + /> + } + mobileSidebar={} + modal={modal} + > + {children} + + ); } diff --git a/apps/web/app/reader/[bookmarkId]/page.tsx b/apps/web/app/reader/[bookmarkId]/page.tsx index e32811a9..133bb601 100644 --- a/apps/web/app/reader/[bookmarkId]/page.tsx +++ b/apps/web/app/reader/[bookmarkId]/page.tsx @@ -3,36 +3,18 @@ import { Suspense, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import HighlightCard from "@/components/dashboard/highlights/HighlightCard"; +import ReaderSettingsPopover from "@/components/dashboard/preview/ReaderSettingsPopover"; import ReaderView from "@/components/dashboard/preview/ReaderView"; import { Button } from "@/components/ui/button"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; -import { Slider } from "@/components/ui/slider"; -import { - HighlighterIcon as Highlight, - Minus, - Plus, - Printer, - Settings, - Type, - X, -} from "lucide-react"; +import { useReaderSettings } from "@/lib/readerSettings"; +import { HighlighterIcon as Highlight, Printer, X } from "lucide-react"; import { useSession } from "next-auth/react"; import { api } from "@karakeep/shared-react/trpc"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; +import { READER_FONT_FAMILIES } from "@karakeep/shared/types/readers"; import { getBookmarkTitle } from "@karakeep/shared/utils/bookmarkUtils"; export default function ReaderViewPage() { @@ -47,19 +29,10 @@ export default function ReaderViewPage() { const { data: session } = useSession(); const router = useRouter(); - const [fontSize, setFontSize] = useState([18]); - const [lineHeight, setLineHeight] = useState([1.6]); - const [fontFamily, setFontFamily] = useState("serif"); + const { settings } = useReaderSettings(); const [showHighlights, setShowHighlights] = useState(false); - const [showSettings, setShowSettings] = useState(false); const isOwner = session?.user?.id === bookmark?.userId; - const fontFamilies = { - serif: "ui-serif, Georgia, Cambria, serif", - sans: "ui-sans-serif, system-ui, sans-serif", - mono: "ui-monospace, Menlo, Monaco, monospace", - }; - const onClose = () => { if (window.history.length > 1) { router.back(); @@ -89,94 +62,7 @@ export default function ReaderViewPage() { - - - - - -
-
- -

Reading Settings

-
- -
-
- - -
- -
-
- - - {fontSize[0]}px - -
-
- - - -
-
- -
-
- - - {lineHeight[0]} - -
- -
-
-
-
-
+ + + + +

{getSettingsTooltip()}

+
+ + +
+
+
+ +

+ {t("settings.info.reader_settings.title")} +

+
+ {hasSessionChanges && ( + + {t("settings.info.reader_settings.preview")} + + )} +
+ +
+
+
+ +
+ {sessionOverrides.fontFamily !== undefined && ( + + {t("settings.info.reader_settings.preview_inline")} + + )} + {hasLocalOverride("fontFamily") && + sessionOverrides.fontFamily === undefined && ( + + + + + +

+ {t( + "settings.info.reader_settings.clear_override_hint", + { + value: t( + `settings.info.reader_settings.${getServerValue("fontFamily")}` as const, + ), + }, + )} +

+
+
+ )} +
+
+ +
+ +
+
+ +
+ + {formatFontSize(settings.fontSize)} + {sessionOverrides.fontSize !== undefined && ( + + {t("settings.info.reader_settings.preview_inline")} + + )} + + {hasLocalOverride("fontSize") && + sessionOverrides.fontSize === undefined && ( + + + + + +

+ {t( + "settings.info.reader_settings.clear_override_hint", + { + value: formatFontSize( + getServerValue("fontSize"), + ), + }, + )} +

+
+
+ )} +
+
+
+ + + updateSession({ fontSize: value }) + } + max={READER_SETTING_CONSTRAINTS.fontSize.max} + min={READER_SETTING_CONSTRAINTS.fontSize.min} + step={READER_SETTING_CONSTRAINTS.fontSize.step} + className={`flex-1 ${ + hasLocalOverride("fontSize") && + sessionOverrides.fontSize === undefined + ? "[&_[role=slider]]:border-primary/50" + : "" + }`} + /> + +
+
+ +
+
+ +
+ + {formatLineHeight(settings.lineHeight)} + {sessionOverrides.lineHeight !== undefined && ( + + {t("settings.info.reader_settings.preview_inline")} + + )} + + {hasLocalOverride("lineHeight") && + sessionOverrides.lineHeight === undefined && ( + + + + + +

+ {t( + "settings.info.reader_settings.clear_override_hint", + { + value: formatLineHeight( + getServerValue("lineHeight"), + ), + }, + )} +

+
+
+ )} +
+
+
+ + + updateSession({ lineHeight: value }) + } + max={READER_SETTING_CONSTRAINTS.lineHeight.max} + min={READER_SETTING_CONSTRAINTS.lineHeight.min} + step={READER_SETTING_CONSTRAINTS.lineHeight.step} + className={`flex-1 ${ + hasLocalOverride("lineHeight") && + sessionOverrides.lineHeight === undefined + ? "[&_[role=slider]]:border-primary/50" + : "" + }`} + /> + +
+
+ + {hasSessionChanges && ( + <> + + +
+ + +
+ + +
+ +

+ {t("settings.info.reader_settings.save_hint")} +

+
+ + )} + + {!hasSessionChanges && ( +

+ {t("settings.info.reader_settings.adjust_hint")} +

+ )} +
+
+
+ + ); +} diff --git a/apps/web/components/settings/ReaderSettings.tsx b/apps/web/components/settings/ReaderSettings.tsx new file mode 100644 index 00000000..ce4017c7 --- /dev/null +++ b/apps/web/components/settings/ReaderSettings.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { useState } from "react"; +import { useClientConfig } from "@/lib/clientConfig"; +import { useTranslation } from "@/lib/i18n/client"; +import { useReaderSettings } from "@/lib/readerSettings"; +import { AlertTriangle, BookOpen, Laptop, RotateCcw } from "lucide-react"; + +import { + formatFontSize, + formatLineHeight, + READER_DEFAULTS, + READER_FONT_FAMILIES, + READER_SETTING_CONSTRAINTS, +} from "@karakeep/shared/types/readers"; + +import { Alert, AlertDescription } from "../ui/alert"; +import { Button } from "../ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../ui/card"; +import { Label } from "../ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { Slider } from "../ui/slider"; +import { toast } from "../ui/use-toast"; + +export default function ReaderSettings() { + const { t } = useTranslation(); + const clientConfig = useClientConfig(); + const { + settings, + serverSettings, + localOverrides, + hasLocalOverrides, + clearServerDefaults, + clearLocalOverrides, + updateServerSetting, + } = useReaderSettings(); + + // Local state for slider dragging (null = not dragging, use server value) + const [draggingFontSize, setDraggingFontSize] = useState(null); + const [draggingLineHeight, setDraggingLineHeight] = useState( + null, + ); + + const hasServerSettings = + serverSettings.fontSize !== null || + serverSettings.lineHeight !== null || + serverSettings.fontFamily !== null; + + const handleClearDefaults = () => { + clearServerDefaults(); + toast({ description: t("settings.info.reader_settings.defaults_cleared") }); + }; + + const handleClearLocalOverrides = () => { + clearLocalOverrides(); + toast({ + description: t("settings.info.reader_settings.local_overrides_cleared"), + }); + }; + + // Format local override for display + const formatLocalOverride = ( + key: "fontSize" | "lineHeight" | "fontFamily", + ) => { + const value = localOverrides[key]; + if (value === undefined) return null; + if (key === "fontSize") return formatFontSize(value as number); + if (key === "lineHeight") return formatLineHeight(value as number); + if (key === "fontFamily") { + switch (value) { + case "serif": + return t("settings.info.reader_settings.serif"); + case "sans": + return t("settings.info.reader_settings.sans"); + case "mono": + return t("settings.info.reader_settings.mono"); + } + } + return String(value); + }; + + return ( + + + + + {t("settings.info.reader_settings.title")} + + + {t("settings.info.reader_settings.description")} + + + + {/* Local Overrides Warning */} + {hasLocalOverrides && ( + + + +
+

+ {t("settings.info.reader_settings.local_overrides_title")} +

+

+ {t( + "settings.info.reader_settings.local_overrides_description", + )} +

+
    + {localOverrides.fontFamily !== undefined && ( +
  • + {t("settings.info.reader_settings.font_family")}:{" "} + {formatLocalOverride("fontFamily")} +
  • + )} + {localOverrides.fontSize !== undefined && ( +
  • + {t("settings.info.reader_settings.font_size")}:{" "} + {formatLocalOverride("fontSize")} +
  • + )} + {localOverrides.lineHeight !== undefined && ( +
  • + {t("settings.info.reader_settings.line_height")}:{" "} + {formatLocalOverride("lineHeight")} +
  • + )} +
+
+ +
+
+ )} + + {/* Font Family */} +
+ + + {serverSettings.fontFamily === null && ( +

+ {t("settings.info.reader_settings.using_default")}:{" "} + {READER_DEFAULTS.fontFamily} +

+ )} +
+ + {/* Font Size */} +
+
+ + + {formatFontSize(draggingFontSize ?? settings.fontSize)} + {serverSettings.fontSize === null && + draggingFontSize === null && + ` (${t("common.default").toLowerCase()})`} + +
+ setDraggingFontSize(value)} + onValueCommit={([value]) => { + updateServerSetting({ fontSize: value }); + setDraggingFontSize(null); + }} + max={READER_SETTING_CONSTRAINTS.fontSize.max} + min={READER_SETTING_CONSTRAINTS.fontSize.min} + step={READER_SETTING_CONSTRAINTS.fontSize.step} + /> +
+ + {/* Line Height */} +
+
+ + + {formatLineHeight(draggingLineHeight ?? settings.lineHeight)} + {serverSettings.lineHeight === null && + draggingLineHeight === null && + ` (${t("common.default").toLowerCase()})`} + +
+ setDraggingLineHeight(value)} + onValueCommit={([value]) => { + updateServerSetting({ lineHeight: value }); + setDraggingLineHeight(null); + }} + max={READER_SETTING_CONSTRAINTS.lineHeight.max} + min={READER_SETTING_CONSTRAINTS.lineHeight.min} + step={READER_SETTING_CONSTRAINTS.lineHeight.step} + /> +
+ + {/* Clear Defaults Button */} + {hasServerSettings && ( + + )} + + {/* Preview */} +
+

+ {t("settings.info.reader_settings.preview")} +

+

+ {t("settings.info.reader_settings.preview_text")} +
+ {t("settings.info.reader_settings.preview_text")} +
+ {t("settings.info.reader_settings.preview_text")} +

+
+
+
+ ); +} 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 + >; +} + +const SessionOverridesContext = + createContext(null); + +export function ReaderSettingsProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [sessionOverrides, setSessionOverrides] = + useState({}); + + 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 ( + + + {children} + + + ); +} + +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({ backupsEnabled: false, backupsFrequency: "daily", backupsRetentionDays: 7, + readerFontSize: null, + readerLineHeight: null, + readerFontFamily: null, }); export function UserSettingsContextProvider({ -- cgit v1.2.3-70-g09d2