aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components
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/components
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/components')
-rw-r--r--apps/web/components/dashboard/preview/LinkContentSection.tsx34
-rw-r--r--apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx457
-rw-r--r--apps/web/components/settings/ReaderSettings.tsx288
3 files changed, 768 insertions, 11 deletions
diff --git a/apps/web/components/dashboard/preview/LinkContentSection.tsx b/apps/web/components/dashboard/preview/LinkContentSection.tsx
index 64b62df6..75b6df14 100644
--- a/apps/web/components/dashboard/preview/LinkContentSection.tsx
+++ b/apps/web/components/dashboard/preview/LinkContentSection.tsx
@@ -17,6 +17,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useTranslation } from "@/lib/i18n/client";
+import { useReaderSettings } from "@/lib/readerSettings";
import {
AlertTriangle,
Archive,
@@ -34,8 +35,10 @@ import {
ZBookmark,
ZBookmarkedLink,
} from "@karakeep/shared/types/bookmarks";
+import { READER_FONT_FAMILIES } from "@karakeep/shared/types/readers";
import { contentRendererRegistry } from "./content-renderers";
+import ReaderSettingsPopover from "./ReaderSettingsPopover";
import ReaderView from "./ReaderView";
function CustomRendererErrorFallback({ error }: { error: Error }) {
@@ -106,6 +109,7 @@ export default function LinkContentSection({
bookmark: ZBookmark;
}) {
const { t } = useTranslation();
+ const { settings } = useReaderSettings();
const availableRenderers = contentRendererRegistry.getRenderers(bookmark);
const defaultSection =
availableRenderers.length > 0 ? availableRenderers[0].id : "cached";
@@ -135,6 +139,11 @@ export default function LinkContentSection({
<ScrollArea className="h-full">
<ReaderView
className="prose mx-auto dark:prose-invert"
+ style={{
+ fontFamily: READER_FONT_FAMILIES[settings.fontFamily],
+ fontSize: `${settings.fontSize}px`,
+ lineHeight: settings.lineHeight,
+ }}
bookmarkId={bookmark.id}
readOnly={!isOwner}
/>
@@ -213,17 +222,20 @@ export default function LinkContentSection({
</SelectContent>
</Select>
{section === "cached" && (
- <Tooltip>
- <TooltipTrigger>
- <Link
- href={`/reader/${bookmark.id}`}
- className={buttonVariants({ variant: "outline" })}
- >
- <ExpandIcon className="h-4 w-4" />
- </Link>
- </TooltipTrigger>
- <TooltipContent side="bottom">FullScreen</TooltipContent>
- </Tooltip>
+ <>
+ <ReaderSettingsPopover />
+ <Tooltip>
+ <TooltipTrigger>
+ <Link
+ href={`/reader/${bookmark.id}`}
+ className={buttonVariants({ variant: "outline" })}
+ >
+ <ExpandIcon className="h-4 w-4" />
+ </Link>
+ </TooltipTrigger>
+ <TooltipContent side="bottom">FullScreen</TooltipContent>
+ </Tooltip>
+ </>
)}
</div>
{content}
diff --git a/apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx b/apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx
new file mode 100644
index 00000000..f37b8263
--- /dev/null
+++ b/apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx
@@ -0,0 +1,457 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+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 {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { useTranslation } from "@/lib/i18n/client";
+import { useReaderSettings } from "@/lib/readerSettings";
+import {
+ Globe,
+ Laptop,
+ Minus,
+ Plus,
+ RotateCcw,
+ Settings,
+ Type,
+ X,
+} from "lucide-react";
+
+import {
+ formatFontSize,
+ formatLineHeight,
+ READER_DEFAULTS,
+ READER_SETTING_CONSTRAINTS,
+} from "@karakeep/shared/types/readers";
+
+interface ReaderSettingsPopoverProps {
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ variant?: "outline" | "ghost";
+}
+
+export default function ReaderSettingsPopover({
+ open,
+ onOpenChange,
+ variant = "outline",
+}: ReaderSettingsPopoverProps) {
+ const { t } = useTranslation();
+ const {
+ settings,
+ serverSettings,
+ localOverrides,
+ sessionOverrides,
+ hasSessionChanges,
+ hasLocalOverrides,
+ isSaving,
+ updateSession,
+ clearSession,
+ saveToDevice,
+ clearLocalOverride,
+ saveToServer,
+ } = useReaderSettings();
+
+ // Helper to get the effective server value (server setting or default)
+ const getServerValue = <K extends keyof typeof serverSettings>(key: K) => {
+ return serverSettings[key] ?? READER_DEFAULTS[key];
+ };
+
+ // Helper to check if a setting has a local override
+ const hasLocalOverride = (key: keyof typeof localOverrides) => {
+ return localOverrides[key] !== undefined;
+ };
+
+ // Build tooltip message for the settings button
+ const getSettingsTooltip = () => {
+ if (hasSessionChanges && hasLocalOverrides) {
+ return t("settings.info.reader_settings.tooltip_preview_and_local");
+ }
+ if (hasSessionChanges) {
+ return t("settings.info.reader_settings.tooltip_preview");
+ }
+ if (hasLocalOverrides) {
+ return t("settings.info.reader_settings.tooltip_local");
+ }
+ return t("settings.info.reader_settings.tooltip_default");
+ };
+
+ return (
+ <Popover open={open} onOpenChange={onOpenChange}>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <PopoverTrigger asChild>
+ <Button variant={variant} size="icon" className="relative">
+ <Settings className="h-4 w-4" />
+ {(hasSessionChanges || hasLocalOverrides) && (
+ <span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-primary" />
+ )}
+ </Button>
+ </PopoverTrigger>
+ </TooltipTrigger>
+ <TooltipContent side="bottom">
+ <p>{getSettingsTooltip()}</p>
+ </TooltipContent>
+ </Tooltip>
+ <PopoverContent
+ side="bottom"
+ align="center"
+ collisionPadding={32}
+ className="flex w-80 flex-col overflow-hidden p-0"
+ style={{
+ maxHeight: "var(--radix-popover-content-available-height)",
+ }}
+ >
+ <div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
+ <div className="flex items-center justify-between pb-2">
+ <div className="flex items-center gap-2">
+ <Type className="h-4 w-4" />
+ <h3 className="font-semibold">
+ {t("settings.info.reader_settings.title")}
+ </h3>
+ </div>
+ {hasSessionChanges && (
+ <span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
+ {t("settings.info.reader_settings.preview")}
+ </span>
+ )}
+ </div>
+
+ <div className="space-y-4">
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <label className="text-sm font-medium">
+ {t("settings.info.reader_settings.font_family")}
+ </label>
+ <div className="flex items-center gap-1">
+ {sessionOverrides.fontFamily !== undefined && (
+ <span className="text-xs text-muted-foreground">
+ {t("settings.info.reader_settings.preview_inline")}
+ </span>
+ )}
+ {hasLocalOverride("fontFamily") &&
+ sessionOverrides.fontFamily === undefined && (
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-5 w-5 text-muted-foreground hover:text-foreground"
+ onClick={() => clearLocalOverride("fontFamily")}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>
+ {t(
+ "settings.info.reader_settings.clear_override_hint",
+ {
+ value: t(
+ `settings.info.reader_settings.${getServerValue("fontFamily")}` as const,
+ ),
+ },
+ )}
+ </p>
+ </TooltipContent>
+ </Tooltip>
+ )}
+ </div>
+ </div>
+ <Select
+ value={settings.fontFamily}
+ onValueChange={(value) =>
+ updateSession({
+ fontFamily: value as "serif" | "sans" | "mono",
+ })
+ }
+ >
+ <SelectTrigger
+ className={
+ hasLocalOverride("fontFamily") &&
+ sessionOverrides.fontFamily === undefined
+ ? "border-primary/50"
+ : ""
+ }
+ >
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <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>
+ </div>
+
+ <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>
+ <div className="flex items-center gap-1">
+ <span className="text-sm text-muted-foreground">
+ {formatFontSize(settings.fontSize)}
+ {sessionOverrides.fontSize !== undefined && (
+ <span className="ml-1 text-xs">
+ {t("settings.info.reader_settings.preview_inline")}
+ </span>
+ )}
+ </span>
+ {hasLocalOverride("fontSize") &&
+ sessionOverrides.fontSize === undefined && (
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-5 w-5 text-muted-foreground hover:text-foreground"
+ onClick={() => clearLocalOverride("fontSize")}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>
+ {t(
+ "settings.info.reader_settings.clear_override_hint",
+ {
+ value: formatFontSize(
+ getServerValue("fontSize"),
+ ),
+ },
+ )}
+ </p>
+ </TooltipContent>
+ </Tooltip>
+ )}
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="icon"
+ className="h-7 w-7 bg-transparent"
+ onClick={() =>
+ updateSession({
+ fontSize: Math.max(
+ READER_SETTING_CONSTRAINTS.fontSize.min,
+ settings.fontSize -
+ READER_SETTING_CONSTRAINTS.fontSize.step,
+ ),
+ })
+ }
+ >
+ <Minus className="h-3 w-3" />
+ </Button>
+ <Slider
+ value={[settings.fontSize]}
+ onValueChange={([value]) =>
+ 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"
+ : ""
+ }`}
+ />
+ <Button
+ variant="outline"
+ size="icon"
+ className="h-7 w-7 bg-transparent"
+ onClick={() =>
+ updateSession({
+ fontSize: Math.min(
+ READER_SETTING_CONSTRAINTS.fontSize.max,
+ settings.fontSize +
+ READER_SETTING_CONSTRAINTS.fontSize.step,
+ ),
+ })
+ }
+ >
+ <Plus className="h-3 w-3" />
+ </Button>
+ </div>
+ </div>
+
+ <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>
+ <div className="flex items-center gap-1">
+ <span className="text-sm text-muted-foreground">
+ {formatLineHeight(settings.lineHeight)}
+ {sessionOverrides.lineHeight !== undefined && (
+ <span className="ml-1 text-xs">
+ {t("settings.info.reader_settings.preview_inline")}
+ </span>
+ )}
+ </span>
+ {hasLocalOverride("lineHeight") &&
+ sessionOverrides.lineHeight === undefined && (
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-5 w-5 text-muted-foreground hover:text-foreground"
+ onClick={() => clearLocalOverride("lineHeight")}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>
+ {t(
+ "settings.info.reader_settings.clear_override_hint",
+ {
+ value: formatLineHeight(
+ getServerValue("lineHeight"),
+ ),
+ },
+ )}
+ </p>
+ </TooltipContent>
+ </Tooltip>
+ )}
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="icon"
+ className="h-7 w-7 bg-transparent"
+ onClick={() =>
+ updateSession({
+ lineHeight: Math.max(
+ READER_SETTING_CONSTRAINTS.lineHeight.min,
+ Math.round(
+ (settings.lineHeight -
+ READER_SETTING_CONSTRAINTS.lineHeight.step) *
+ 10,
+ ) / 10,
+ ),
+ })
+ }
+ >
+ <Minus className="h-3 w-3" />
+ </Button>
+ <Slider
+ value={[settings.lineHeight]}
+ onValueChange={([value]) =>
+ 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"
+ : ""
+ }`}
+ />
+ <Button
+ variant="outline"
+ size="icon"
+ className="h-7 w-7 bg-transparent"
+ onClick={() =>
+ updateSession({
+ lineHeight: Math.min(
+ READER_SETTING_CONSTRAINTS.lineHeight.max,
+ Math.round(
+ (settings.lineHeight +
+ READER_SETTING_CONSTRAINTS.lineHeight.step) *
+ 10,
+ ) / 10,
+ ),
+ })
+ }
+ >
+ <Plus className="h-3 w-3" />
+ </Button>
+ </div>
+ </div>
+
+ {hasSessionChanges && (
+ <>
+ <Separator />
+
+ <div className="space-y-2">
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-full"
+ onClick={() => clearSession()}
+ >
+ <RotateCcw className="mr-2 h-4 w-4" />
+ {t("settings.info.reader_settings.reset_preview")}
+ </Button>
+
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ className="flex-1"
+ disabled={isSaving}
+ onClick={() => saveToDevice()}
+ >
+ <Laptop className="mr-2 h-4 w-4" />
+ {t("settings.info.reader_settings.save_to_device")}
+ </Button>
+ <Button
+ variant="default"
+ size="sm"
+ className="flex-1"
+ disabled={isSaving}
+ onClick={() => saveToServer()}
+ >
+ <Globe className="mr-2 h-4 w-4" />
+ {t("settings.info.reader_settings.save_to_all_devices")}
+ </Button>
+ </div>
+
+ <p className="text-center text-xs text-muted-foreground">
+ {t("settings.info.reader_settings.save_hint")}
+ </p>
+ </div>
+ </>
+ )}
+
+ {!hasSessionChanges && (
+ <p className="text-center text-xs text-muted-foreground">
+ {t("settings.info.reader_settings.adjust_hint")}
+ </p>
+ )}
+ </div>
+ </div>
+ </PopoverContent>
+ </Popover>
+ );
+}
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>
+ );
+}