From 7f4202afd73105b850498b55ad66922b3505f0e3 Mon Sep 17 00:00:00 2001
From: Evan Simkowitz <esimkowitz@users.noreply.github.com>
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/web/components/settings/ReaderSettings.tsx | 288 ++++++++++++++++++++++++
 1 file changed, 288 insertions(+)
 create mode 100644 apps/web/components/settings/ReaderSettings.tsx

(limited to 'apps/web/components/settings/ReaderSettings.tsx')

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<number | null>(null);
+  const [draggingLineHeight, setDraggingLineHeight] = useState<number | null>(
+    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 (
+    <Card>
+      <CardHeader>
+        <CardTitle className="flex items-center gap-2 text-xl">
+          <BookOpen className="h-5 w-5" />
+          {t("settings.info.reader_settings.title")}
+        </CardTitle>
+        <CardDescription>
+          {t("settings.info.reader_settings.description")}
+        </CardDescription>
+      </CardHeader>
+      <CardContent className="space-y-6">
+        {/* Local Overrides Warning */}
+        {hasLocalOverrides && (
+          <Alert>
+            <AlertTriangle className="h-4 w-4" />
+            <AlertDescription className="flex flex-col gap-3">
+              <div>
+                <p className="font-medium">
+                  {t("settings.info.reader_settings.local_overrides_title")}
+                </p>
+                <p className="mt-1 text-sm text-muted-foreground">
+                  {t(
+                    "settings.info.reader_settings.local_overrides_description",
+                  )}
+                </p>
+                <ul className="mt-2 text-sm text-muted-foreground">
+                  {localOverrides.fontFamily !== undefined && (
+                    <li>
+                      {t("settings.info.reader_settings.font_family")}:{" "}
+                      {formatLocalOverride("fontFamily")}
+                    </li>
+                  )}
+                  {localOverrides.fontSize !== undefined && (
+                    <li>
+                      {t("settings.info.reader_settings.font_size")}:{" "}
+                      {formatLocalOverride("fontSize")}
+                    </li>
+                  )}
+                  {localOverrides.lineHeight !== undefined && (
+                    <li>
+                      {t("settings.info.reader_settings.line_height")}:{" "}
+                      {formatLocalOverride("lineHeight")}
+                    </li>
+                  )}
+                </ul>
+              </div>
+              <Button
+                variant="outline"
+                size="sm"
+                onClick={handleClearLocalOverrides}
+                className="w-fit"
+              >
+                <Laptop className="mr-2 h-4 w-4" />
+                {t("settings.info.reader_settings.clear_local_overrides")}
+              </Button>
+            </AlertDescription>
+          </Alert>
+        )}
+
+        {/* Font Family */}
+        <div className="space-y-2">
+          <Label className="text-sm font-medium">
+            {t("settings.info.reader_settings.font_family")}
+          </Label>
+          <Select
+            disabled={!!clientConfig.demoMode}
+            value={serverSettings.fontFamily ?? "not-set"}
+            onValueChange={(value) => {
+              if (value !== "not-set") {
+                updateServerSetting({
+                  fontFamily: value as "serif" | "sans" | "mono",
+                });
+              }
+            }}
+          >
+            <SelectTrigger className="h-11">
+              <SelectValue
+                placeholder={t("settings.info.reader_settings.not_set")}
+              />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value="not-set" disabled>
+                {t("settings.info.reader_settings.not_set")} (
+                {t("common.default")}: {READER_DEFAULTS.fontFamily})
+              </SelectItem>
+              <SelectItem value="serif">
+                {t("settings.info.reader_settings.serif")}
+              </SelectItem>
+              <SelectItem value="sans">
+                {t("settings.info.reader_settings.sans")}
+              </SelectItem>
+              <SelectItem value="mono">
+                {t("settings.info.reader_settings.mono")}
+              </SelectItem>
+            </SelectContent>
+          </Select>
+          {serverSettings.fontFamily === null && (
+            <p className="text-xs text-muted-foreground">
+              {t("settings.info.reader_settings.using_default")}:{" "}
+              {READER_DEFAULTS.fontFamily}
+            </p>
+          )}
+        </div>
+
+        {/* Font Size */}
+        <div className="space-y-2">
+          <div className="flex items-center justify-between">
+            <Label className="text-sm font-medium">
+              {t("settings.info.reader_settings.font_size")}
+            </Label>
+            <span className="text-sm text-muted-foreground">
+              {formatFontSize(draggingFontSize ?? settings.fontSize)}
+              {serverSettings.fontSize === null &&
+                draggingFontSize === null &&
+                ` (${t("common.default").toLowerCase()})`}
+            </span>
+          </div>
+          <Slider
+            disabled={!!clientConfig.demoMode}
+            value={[draggingFontSize ?? settings.fontSize]}
+            onValueChange={([value]) => 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}
+          />
+        </div>
+
+        {/* Line Height */}
+        <div className="space-y-2">
+          <div className="flex items-center justify-between">
+            <Label className="text-sm font-medium">
+              {t("settings.info.reader_settings.line_height")}
+            </Label>
+            <span className="text-sm text-muted-foreground">
+              {formatLineHeight(draggingLineHeight ?? settings.lineHeight)}
+              {serverSettings.lineHeight === null &&
+                draggingLineHeight === null &&
+                ` (${t("common.default").toLowerCase()})`}
+            </span>
+          </div>
+          <Slider
+            disabled={!!clientConfig.demoMode}
+            value={[draggingLineHeight ?? settings.lineHeight]}
+            onValueChange={([value]) => 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}
+          />
+        </div>
+
+        {/* Clear Defaults Button */}
+        {hasServerSettings && (
+          <Button
+            variant="outline"
+            onClick={handleClearDefaults}
+            className="w-full"
+            disabled={!!clientConfig.demoMode}
+          >
+            <RotateCcw className="mr-2 h-4 w-4" />
+            {t("settings.info.reader_settings.clear_defaults")}
+          </Button>
+        )}
+
+        {/* Preview */}
+        <div className="rounded-lg border p-4">
+          <p className="mb-2 text-sm font-medium text-muted-foreground">
+            {t("settings.info.reader_settings.preview")}
+          </p>
+          <p
+            style={{
+              fontFamily: READER_FONT_FAMILIES[settings.fontFamily],
+              fontSize: `${draggingFontSize ?? settings.fontSize}px`,
+              lineHeight: draggingLineHeight ?? settings.lineHeight,
+            }}
+          >
+            {t("settings.info.reader_settings.preview_text")}
+            <br />
+            {t("settings.info.reader_settings.preview_text")}
+            <br />
+            {t("settings.info.reader_settings.preview_text")}
+          </p>
+        </div>
+      </CardContent>
+    </Card>
+  );
+}
-- 
cgit v1.2.3-70-g09d2

