Archived
252 lines
7.9 KiB
TypeScript
252 lines
7.9 KiB
TypeScript
/**
|
|
* ReadingSettingsPanel — slide-in drawer from the right for customizing
|
|
* the reading experience: theme, font, sizing, orientation.
|
|
*/
|
|
|
|
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
|
import { useTranslation } from "react-i18n-lite";
|
|
import type {
|
|
FontFamily,
|
|
OrientationLock,
|
|
ReadingSettings,
|
|
ThemePreset,
|
|
} from "../../types/reader";
|
|
import type { SettingsPersistMode } from "../../hooks/useReadingSettings";
|
|
import { PanelCloseButton } from "./PanelCloseButton";
|
|
|
|
interface ReadingSettingsPanelProps {
|
|
format?: "epub" | "pdf";
|
|
settings: ReadingSettings;
|
|
pdfScale?: number;
|
|
onPdfScaleChange?: (scale: number) => void;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onUpdate: (
|
|
partial: Partial<ReadingSettings>,
|
|
persist?: SettingsPersistMode,
|
|
) => void;
|
|
onFlush: () => Promise<void>;
|
|
}
|
|
|
|
const THEME_OPTIONS: { value: ThemePreset; labelKey: string }[] = [
|
|
{ value: "sepia", labelKey: "reader.themeSepia" },
|
|
{ value: "dark", labelKey: "reader.themeDark" },
|
|
{ value: "light", labelKey: "reader.themeLight" },
|
|
{ value: "paper", labelKey: "reader.themePaper" },
|
|
];
|
|
|
|
const FONT_OPTIONS: { value: FontFamily; labelKey: string }[] = [
|
|
{ value: "sans-serif", labelKey: "reader.fontSans" },
|
|
{ value: "serif", labelKey: "reader.fontSerif" },
|
|
{ value: "monospace", labelKey: "reader.fontMonospace" },
|
|
];
|
|
|
|
const ORIENTATION_OPTIONS: { value: OrientationLock; labelKey: string }[] = [
|
|
{ value: "auto", labelKey: "reader.orientationAuto" },
|
|
{ value: "portrait", labelKey: "reader.orientationPortrait" },
|
|
{ value: "landscape", labelKey: "reader.orientationLandscape" },
|
|
];
|
|
|
|
export default function ReadingSettingsPanel({
|
|
format = "epub",
|
|
settings,
|
|
pdfScale = 1.25,
|
|
onPdfScaleChange,
|
|
isOpen,
|
|
onClose,
|
|
onUpdate,
|
|
onFlush,
|
|
}: ReadingSettingsPanelProps) {
|
|
const { t } = useTranslation();
|
|
|
|
const handleClose = () => {
|
|
void onFlush();
|
|
onClose();
|
|
};
|
|
|
|
const handleSlider = (key: keyof ReadingSettings, value: number) => {
|
|
onUpdate({ [key]: value } as Partial<ReadingSettings>, "debounced");
|
|
};
|
|
|
|
const handleDiscrete = (key: keyof ReadingSettings, value: string | number) => {
|
|
onUpdate({ [key]: value } as Partial<ReadingSettings>, "immediate");
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{isOpen && (
|
|
<div
|
|
className="settings-overlay"
|
|
onClick={handleClose}
|
|
onKeyDown={(e: ReactKeyboardEvent) => {
|
|
if (e.key === "Escape") handleClose();
|
|
}}
|
|
role="presentation"
|
|
/>
|
|
)}
|
|
|
|
<aside
|
|
className={`settings-drawer ${isOpen ? "settings-drawer--open" : ""}`}
|
|
>
|
|
<div className="settings-header">
|
|
<h2 className="settings-title">{t("reader.settingsTitle")}</h2>
|
|
<PanelCloseButton
|
|
className="settings-close-btn"
|
|
onClick={handleClose}
|
|
ariaLabel={t("reader.closeSettingsAria")}
|
|
/>
|
|
</div>
|
|
|
|
<div className="settings-body">
|
|
<section className="settings-section">
|
|
<h3 className="settings-section-title">{t("reader.theme")}</h3>
|
|
<div className="theme-grid">
|
|
{THEME_OPTIONS.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
className={`theme-btn ${
|
|
settings.theme === opt.value ? "theme-btn--active" : ""
|
|
}`}
|
|
onClick={() => handleDiscrete("theme", opt.value)}
|
|
>
|
|
{t(opt.labelKey)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
{format === "pdf" && onPdfScaleChange && (
|
|
<section className="settings-section">
|
|
<h3 className="settings-section-title">
|
|
{t("reader.pdfZoom", { value: String(Math.round(pdfScale * 100)) })}
|
|
</h3>
|
|
<input
|
|
type="range"
|
|
min="0.75"
|
|
max="2.5"
|
|
step="0.05"
|
|
value={pdfScale}
|
|
onChange={(e) => onPdfScaleChange(Number(e.target.value))}
|
|
className="settings-slider"
|
|
aria-label={t("reader.pdfZoomAria")}
|
|
/>
|
|
</section>
|
|
)}
|
|
|
|
{format === "epub" && (
|
|
<>
|
|
<section className="settings-section">
|
|
<h3 className="settings-section-title">{t("reader.font")}</h3>
|
|
<div className="font-grid">
|
|
{FONT_OPTIONS.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
className={`font-btn ${
|
|
settings.font_family === opt.value ? "font-btn--active" : ""
|
|
}`}
|
|
onClick={() => handleDiscrete("font_family", opt.value)}
|
|
>
|
|
{t(opt.labelKey)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="settings-section">
|
|
<h3 className="settings-section-title">
|
|
{t("reader.fontSize", { size: String(settings.font_size) })}
|
|
</h3>
|
|
<input
|
|
type="range"
|
|
min="12"
|
|
max="32"
|
|
value={settings.font_size}
|
|
onChange={(e) =>
|
|
handleSlider("font_size", Number(e.target.value))
|
|
}
|
|
className="settings-slider"
|
|
aria-label={t("reader.fontSizeAria")}
|
|
/>
|
|
</section>
|
|
|
|
<section className="settings-section">
|
|
<h3 className="settings-section-title">
|
|
{t("reader.lineHeight", { value: settings.line_height.toFixed(1) })}
|
|
</h3>
|
|
<input
|
|
type="range"
|
|
min="1.2"
|
|
max="2.0"
|
|
step="0.1"
|
|
value={settings.line_height}
|
|
onChange={(e) =>
|
|
handleSlider("line_height", Number(e.target.value))
|
|
}
|
|
className="settings-slider"
|
|
aria-label={t("reader.lineHeightAria")}
|
|
/>
|
|
</section>
|
|
|
|
<section className="settings-section">
|
|
<h3 className="settings-section-title">
|
|
{t("reader.margins", { value: String(settings.margin_width) })}
|
|
</h3>
|
|
<input
|
|
type="range"
|
|
min="8"
|
|
max="48"
|
|
value={settings.margin_width}
|
|
onChange={(e) =>
|
|
handleSlider("margin_width", Number(e.target.value))
|
|
}
|
|
className="settings-slider"
|
|
aria-label={t("reader.marginWidthAria")}
|
|
/>
|
|
</section>
|
|
</>
|
|
)}
|
|
|
|
<section className="settings-section">
|
|
<h3 className="settings-section-title">
|
|
{t("reader.brightness", { value: String(settings.brightness) })}
|
|
</h3>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="100"
|
|
value={settings.brightness}
|
|
onChange={(e) =>
|
|
handleSlider("brightness", Number(e.target.value))
|
|
}
|
|
className="settings-slider"
|
|
aria-label={t("reader.brightnessAria")}
|
|
/>
|
|
</section>
|
|
|
|
<section className="settings-section">
|
|
<h3 className="settings-section-title">{t("reader.orientation")}</h3>
|
|
<div className="orientation-grid">
|
|
{ORIENTATION_OPTIONS.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
className={`orientation-btn ${
|
|
settings.orientation_lock === opt.value
|
|
? "orientation-btn--active"
|
|
: ""
|
|
}`}
|
|
onClick={() => handleDiscrete("orientation_lock", opt.value)}
|
|
>
|
|
{t(opt.labelKey)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</aside>
|
|
</>
|
|
);
|
|
}
|