mirror of
https://github.com/Tony0410/readlater.git
synced 2026-05-24 22:01:41 +08:00
Initial commit: ReadLater v1.0
- Save articles via URL or bookmarklet - Clean dark reader with customizable fonts/sizing - Text-to-speech with browser + Kokoro support - Speed control up to 3x - Favorites and archive - SQLite database with Drizzle ORM - Docker deployment ready Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
297
src/components/SettingsPanel.tsx
Normal file
297
src/components/SettingsPanel.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
"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="flex gap-2">
|
||||
{[
|
||||
{ value: "browser", label: "Browser" },
|
||||
{ value: "kokoro", label: "Kokoro" },
|
||||
].map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() =>
|
||||
onTTSSettingsChange({
|
||||
...ttsSettings,
|
||||
engine: value as TTSSettings["engine"],
|
||||
})
|
||||
}
|
||||
className={`flex-1 p-3 rounded-lg border transition-colors ${
|
||||
ttsSettings.engine === value
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/10"
|
||||
: "border-[var(--border)] hover:border-[var(--muted)]"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Kokoro URL */}
|
||||
{ttsSettings.engine === "kokoro" && (
|
||||
<section className="mb-8">
|
||||
<h3 className="text-sm font-medium text-[var(--muted)] uppercase tracking-wide mb-4">
|
||||
Kokoro API URL
|
||||
</h3>
|
||||
<input
|
||||
type="text"
|
||||
value={ttsSettings.kokoroUrl}
|
||||
onChange={(e) =>
|
||||
onTTSSettingsChange({
|
||||
...ttsSettings,
|
||||
kokoroUrl: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="http://localhost:8880"
|
||||
className="w-full p-3 rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted)] mt-2">
|
||||
URL of your Kokoro-FastAPI server
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user