Files
readlater/src/components/SettingsPanel.tsx
Gemini Agent 611d57770e Add TTS proxy to fix CORS issues
- /api/tts proxies requests to Edge TTS and Kokoro
- Uses Docker container names for internal networking
- Removes URL config from settings (handled server-side)
- Fixes localStorage merge for new settings fields

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 02:05:38 +00:00

296 lines
11 KiB
TypeScript

"use client";
import { ReaderSettings, TTSSettings } from "@/lib/types";
import { X, Sun, Moon, BookOpen } from "lucide-react";
interface SettingsPanelProps {
isOpen: boolean;
onClose: () => void;
readerSettings: ReaderSettings;
onReaderSettingsChange: (settings: ReaderSettings) => void;
ttsSettings: TTSSettings;
onTTSSettingsChange: (settings: TTSSettings) => void;
availableVoices: SpeechSynthesisVoice[];
}
export function SettingsPanel({
isOpen,
onClose,
readerSettings,
onReaderSettingsChange,
ttsSettings,
onTTSSettingsChange,
availableVoices,
}: SettingsPanelProps) {
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={onClose}
/>
{/* Panel */}
<div className="fixed right-0 top-0 h-full w-full max-w-md bg-[var(--background)] border-l border-[var(--border)] z-50 overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold">Settings</h2>
<button
onClick={onClose}
className="p-2 rounded hover:bg-[var(--surface)] transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Theme */}
<section className="mb-8">
<h3 className="text-sm font-medium text-[var(--muted)] uppercase tracking-wide mb-4">
Theme
</h3>
<div className="flex gap-2">
{[
{ value: "dark", icon: Moon, label: "Dark" },
{ value: "light", icon: Sun, label: "Light" },
{ value: "sepia", icon: BookOpen, label: "Sepia" },
].map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() =>
onReaderSettingsChange({
...readerSettings,
theme: value as ReaderSettings["theme"],
})
}
className={`flex-1 flex flex-col items-center gap-2 p-4 rounded-lg border transition-colors ${
readerSettings.theme === value
? "border-[var(--accent)] bg-[var(--accent)]/10"
: "border-[var(--border)] hover:border-[var(--muted)]"
}`}
>
<Icon className="w-5 h-5" />
<span className="text-sm">{label}</span>
</button>
))}
</div>
</section>
{/* Font Size */}
<section className="mb-8">
<h3 className="text-sm font-medium text-[var(--muted)] uppercase tracking-wide mb-4">
Font Size: {readerSettings.fontSize}px
</h3>
<input
type="range"
min="14"
max="32"
value={readerSettings.fontSize}
onChange={(e) =>
onReaderSettingsChange({
...readerSettings,
fontSize: parseInt(e.target.value),
})
}
className="w-full accent-[var(--accent)]"
/>
<div className="flex justify-between text-xs text-[var(--muted)] mt-1">
<span>14px</span>
<span>32px</span>
</div>
</section>
{/* Font Family */}
<section className="mb-8">
<h3 className="text-sm font-medium text-[var(--muted)] uppercase tracking-wide mb-4">
Font
</h3>
<div className="grid grid-cols-2 gap-2">
{[
{ value: "serif", label: "Serif", sample: "Georgia" },
{ value: "sans", label: "Sans", sample: "Helvetica" },
{ value: "mono", label: "Mono", sample: "Monaco" },
{ value: "system", label: "System", sample: "Default" },
].map(({ value, label, sample }) => (
<button
key={value}
onClick={() =>
onReaderSettingsChange({
...readerSettings,
fontFamily: value as ReaderSettings["fontFamily"],
})
}
className={`p-3 rounded-lg border text-left transition-colors ${
readerSettings.fontFamily === value
? "border-[var(--accent)] bg-[var(--accent)]/10"
: "border-[var(--border)] hover:border-[var(--muted)]"
}`}
>
<div className={`font-${value} text-lg`}>{label}</div>
<div className="text-xs text-[var(--muted)]">{sample}</div>
</button>
))}
</div>
</section>
{/* Line Height */}
<section className="mb-8">
<h3 className="text-sm font-medium text-[var(--muted)] uppercase tracking-wide mb-4">
Line Height: {readerSettings.lineHeight}
</h3>
<input
type="range"
min="1.4"
max="2.2"
step="0.1"
value={readerSettings.lineHeight}
onChange={(e) =>
onReaderSettingsChange({
...readerSettings,
lineHeight: parseFloat(e.target.value),
})
}
className="w-full accent-[var(--accent)]"
/>
<div className="flex justify-between text-xs text-[var(--muted)] mt-1">
<span>Tight</span>
<span>Loose</span>
</div>
</section>
{/* Content Width */}
<section className="mb-8">
<h3 className="text-sm font-medium text-[var(--muted)] uppercase tracking-wide mb-4">
Content Width: {readerSettings.maxWidth}px
</h3>
<input
type="range"
min="500"
max="900"
step="50"
value={readerSettings.maxWidth}
onChange={(e) =>
onReaderSettingsChange({
...readerSettings,
maxWidth: parseInt(e.target.value),
})
}
className="w-full accent-[var(--accent)]"
/>
<div className="flex justify-between text-xs text-[var(--muted)] mt-1">
<span>Narrow</span>
<span>Wide</span>
</div>
</section>
<hr className="border-[var(--border)] my-8" />
{/* TTS Settings */}
<section className="mb-8">
<h3 className="text-sm font-medium text-[var(--muted)] uppercase tracking-wide mb-4">
Text-to-Speech Engine
</h3>
<div className="grid grid-cols-3 gap-2">
{[
{ value: "edge", label: "Edge", desc: "Fast, natural" },
{ value: "kokoro", label: "Kokoro", desc: "High quality" },
{ value: "browser", label: "Browser", desc: "Built-in" },
].map(({ value, label, desc }) => (
<button
key={value}
onClick={() =>
onTTSSettingsChange({
...ttsSettings,
engine: value as TTSSettings["engine"],
})
}
className={`p-3 rounded-lg border transition-colors text-left ${
ttsSettings.engine === value
? "border-[var(--accent)] bg-[var(--accent)]/10"
: "border-[var(--border)] hover:border-[var(--muted)]"
}`}
>
<div className="font-medium">{label}</div>
<div className="text-xs text-[var(--muted)]">{desc}</div>
</button>
))}
</div>
</section>
{/* TTS Speed */}
<section className="mb-8">
<h3 className="text-sm font-medium text-[var(--muted)] uppercase tracking-wide mb-4">
TTS Speed: {ttsSettings.speed}x
</h3>
<input
type="range"
min="0.5"
max="3"
step="0.1"
value={ttsSettings.speed}
onChange={(e) =>
onTTSSettingsChange({
...ttsSettings,
speed: parseFloat(e.target.value),
})
}
className="w-full accent-[var(--accent)]"
/>
<div className="flex justify-between text-xs text-[var(--muted)] mt-1">
<span>0.5x</span>
<span>3x</span>
</div>
</section>
{/* Browser Voice Selection */}
{ttsSettings.engine === "browser" && availableVoices.length > 0 && (
<section className="mb-8">
<h3 className="text-sm font-medium text-[var(--muted)] uppercase tracking-wide mb-4">
Voice
</h3>
<select
value={ttsSettings.voice}
onChange={(e) =>
onTTSSettingsChange({
...ttsSettings,
voice: e.target.value,
})
}
className="w-full p-3 rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
>
<option value="">Default</option>
{availableVoices.map((voice) => (
<option key={voice.voiceURI} value={voice.voiceURI}>
{voice.name} ({voice.lang})
</option>
))}
</select>
</section>
)}
{/* Edge TTS info */}
{ttsSettings.engine === "edge" && (
<section className="mb-8">
<p className="text-sm text-[var(--muted)]">
Uses Microsoft neural voices via Edge TTS. Fast and natural sounding.
Requires the edge-tts Docker container running on the server.
</p>
</section>
)}
{/* Kokoro info */}
{ttsSettings.engine === "kokoro" && (
<section className="mb-8">
<p className="text-sm text-[var(--muted)]">
High quality local TTS. Slower as it generates full audio before playing.
Requires the kokoro Docker container running on the server.
</p>
</section>
)}
</div>
</div>
</>
);
}