Files
storycove/frontend/src/app/settings/page.tsx
Stefan Hardegger 51e3d20c24 various fixes
2025-08-11 08:15:20 +02:00

743 lines
30 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect } from 'react';
import AppLayout from '../../components/layout/AppLayout';
import { useTheme } from '../../lib/theme';
import Button from '../../components/ui/Button';
import { storyApi, authorApi, databaseApi } from '../../lib/api';
type FontFamily = 'serif' | 'sans' | 'mono';
type FontSize = 'small' | 'medium' | 'large' | 'extra-large';
type ReadingWidth = 'narrow' | 'medium' | 'wide';
interface Settings {
theme: 'light' | 'dark';
fontFamily: FontFamily;
fontSize: FontSize;
readingWidth: ReadingWidth;
readingSpeed: number; // words per minute
}
const defaultSettings: Settings = {
theme: 'light',
fontFamily: 'serif',
fontSize: 'medium',
readingWidth: 'medium',
readingSpeed: 200,
};
export default function SettingsPage() {
const { theme, setTheme } = useTheme();
const [settings, setSettings] = useState<Settings>(defaultSettings);
const [saved, setSaved] = useState(false);
const [typesenseStatus, setTypesenseStatus] = useState<{
stories: { loading: boolean; message: string; success?: boolean };
authors: { loading: boolean; message: string; success?: boolean };
}>({
stories: { loading: false, message: '' },
authors: { loading: false, message: '' }
});
const [authorsSchema, setAuthorsSchema] = useState<any>(null);
const [showSchema, setShowSchema] = useState(false);
const [databaseStatus, setDatabaseStatus] = useState<{
completeBackup: { loading: boolean; message: string; success?: boolean };
completeRestore: { loading: boolean; message: string; success?: boolean };
completeClear: { loading: boolean; message: string; success?: boolean };
}>({
completeBackup: { loading: false, message: '' },
completeRestore: { loading: false, message: '' },
completeClear: { loading: false, message: '' }
});
// Load settings from localStorage on mount
useEffect(() => {
const savedSettings = localStorage.getItem('storycove-settings');
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings);
setSettings({ ...defaultSettings, ...parsed, theme });
} catch (error) {
console.error('Failed to parse saved settings:', error);
setSettings({ ...defaultSettings, theme });
}
} else {
setSettings({ ...defaultSettings, theme });
}
}, [theme]);
// Save settings to localStorage
const saveSettings = () => {
localStorage.setItem('storycove-settings', JSON.stringify(settings));
// Apply theme change
setTheme(settings.theme);
// Apply font settings to CSS custom properties
const root = document.documentElement;
const fontFamilyMap = {
serif: 'Georgia, Times, serif',
sans: 'Inter, system-ui, sans-serif',
mono: 'Monaco, Consolas, monospace',
};
const fontSizeMap = {
small: '14px',
medium: '16px',
large: '18px',
'extra-large': '20px',
};
const readingWidthMap = {
narrow: '600px',
medium: '800px',
wide: '1000px',
};
root.style.setProperty('--reading-font-family', fontFamilyMap[settings.fontFamily]);
root.style.setProperty('--reading-font-size', fontSizeMap[settings.fontSize]);
root.style.setProperty('--reading-max-width', readingWidthMap[settings.readingWidth]);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
const updateSetting = <K extends keyof Settings>(key: K, value: Settings[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const handleTypesenseOperation = async (
type: 'stories' | 'authors',
operation: 'reindex' | 'recreate',
apiCall: () => Promise<{ success: boolean; message: string; count?: number; error?: string }>
) => {
setTypesenseStatus(prev => ({
...prev,
[type]: { loading: true, message: 'Processing...', success: undefined }
}));
try {
const result = await apiCall();
setTypesenseStatus(prev => ({
...prev,
[type]: {
loading: false,
message: result.success ? result.message : result.error || 'Operation failed',
success: result.success
}
}));
// Clear message after 5 seconds
setTimeout(() => {
setTypesenseStatus(prev => ({
...prev,
[type]: { loading: false, message: '', success: undefined }
}));
}, 5000);
} catch (error) {
setTypesenseStatus(prev => ({
...prev,
[type]: {
loading: false,
message: 'Network error occurred',
success: false
}
}));
setTimeout(() => {
setTypesenseStatus(prev => ({
...prev,
[type]: { loading: false, message: '', success: undefined }
}));
}, 5000);
}
};
const fetchAuthorsSchema = async () => {
try {
const result = await authorApi.getTypesenseSchema();
if (result.success) {
setAuthorsSchema(result.schema);
} else {
setAuthorsSchema({ error: result.error });
}
} catch (error) {
setAuthorsSchema({ error: 'Failed to fetch schema' });
}
};
const handleCompleteBackup = async () => {
setDatabaseStatus(prev => ({
...prev,
completeBackup: { loading: true, message: 'Creating complete backup...', success: undefined }
}));
try {
const backupBlob = await databaseApi.backupComplete();
// Create download link
const url = window.URL.createObjectURL(backupBlob);
const link = document.createElement('a');
link.href = url;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
link.download = `storycove_complete_backup_${timestamp}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
setDatabaseStatus(prev => ({
...prev,
completeBackup: { loading: false, message: 'Complete backup downloaded successfully', success: true }
}));
} catch (error: any) {
setDatabaseStatus(prev => ({
...prev,
completeBackup: { loading: false, message: error.message || 'Complete backup failed', success: false }
}));
}
// Clear message after 5 seconds
setTimeout(() => {
setDatabaseStatus(prev => ({
...prev,
completeBackup: { loading: false, message: '', success: undefined }
}));
}, 5000);
};
const handleCompleteRestore = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Reset the input so the same file can be selected again
event.target.value = '';
if (!file.name.endsWith('.zip')) {
setDatabaseStatus(prev => ({
...prev,
completeRestore: { loading: false, message: 'Please select a .zip file', success: false }
}));
return;
}
const confirmed = window.confirm(
'Are you sure you want to restore the complete backup? This will PERMANENTLY DELETE all current data AND files (cover images, avatars) and replace them with the backup data. This action cannot be undone!'
);
if (!confirmed) return;
setDatabaseStatus(prev => ({
...prev,
completeRestore: { loading: true, message: 'Restoring complete backup...', success: undefined }
}));
try {
const result = await databaseApi.restoreComplete(file);
setDatabaseStatus(prev => ({
...prev,
completeRestore: {
loading: false,
message: result.success ? result.message : result.message,
success: result.success
}
}));
} catch (error: any) {
setDatabaseStatus(prev => ({
...prev,
completeRestore: { loading: false, message: error.message || 'Complete restore failed', success: false }
}));
}
// Clear message after 10 seconds for restore (longer because it's important)
setTimeout(() => {
setDatabaseStatus(prev => ({
...prev,
completeRestore: { loading: false, message: '', success: undefined }
}));
}, 10000);
};
const handleCompleteClear = async () => {
const confirmed = window.confirm(
'Are you ABSOLUTELY SURE you want to clear the entire database AND all files? This will PERMANENTLY DELETE ALL stories, authors, series, tags, collections, AND all uploaded images (covers, avatars). This action cannot be undone!'
);
if (!confirmed) return;
const doubleConfirmed = window.confirm(
'This is your final warning! Clicking OK will DELETE EVERYTHING in your StoryCove database AND all uploaded files. Are you completely certain you want to proceed?'
);
if (!doubleConfirmed) return;
setDatabaseStatus(prev => ({
...prev,
completeClear: { loading: true, message: 'Clearing database and files...', success: undefined }
}));
try {
const result = await databaseApi.clearComplete();
setDatabaseStatus(prev => ({
...prev,
completeClear: {
loading: false,
message: result.success
? `Database and files cleared successfully. Deleted ${result.deletedRecords} records.`
: result.message,
success: result.success
}
}));
} catch (error: any) {
setDatabaseStatus(prev => ({
...prev,
completeClear: { loading: false, message: error.message || 'Clear operation failed', success: false }
}));
}
// Clear message after 10 seconds for clear (longer because it's important)
setTimeout(() => {
setDatabaseStatus(prev => ({
...prev,
completeClear: { loading: false, message: '', success: undefined }
}));
}, 10000);
};
return (
<AppLayout>
<div className="max-w-2xl mx-auto space-y-8">
<div>
<h1 className="text-3xl font-bold theme-header">Settings</h1>
<p className="theme-text mt-2">
Customize your StoryCove reading experience
</p>
</div>
<div className="space-y-6">
{/* Theme Settings */}
<div className="theme-card theme-shadow rounded-lg p-6">
<h2 className="text-xl font-semibold theme-header mb-4">Appearance</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium theme-header mb-2">
Theme
</label>
<div className="flex gap-4">
<button
onClick={() => updateSetting('theme', 'light')}
className={`px-4 py-2 rounded-lg border transition-colors ${
settings.theme === 'light'
? 'theme-accent-bg text-white border-transparent'
: 'theme-card theme-text theme-border hover:border-gray-400'
}`}
>
Light
</button>
<button
onClick={() => updateSetting('theme', 'dark')}
className={`px-4 py-2 rounded-lg border transition-colors ${
settings.theme === 'dark'
? 'theme-accent-bg text-white border-transparent'
: 'theme-card theme-text theme-border hover:border-gray-400'
}`}
>
🌙 Dark
</button>
</div>
</div>
</div>
</div>
{/* Reading Settings */}
<div className="theme-card theme-shadow rounded-lg p-6">
<h2 className="text-xl font-semibold theme-header mb-4">Reading Experience</h2>
<div className="space-y-6">
{/* Font Family */}
<div>
<label className="block text-sm font-medium theme-header mb-2">
Font Family
</label>
<div className="flex gap-4 flex-wrap">
<button
onClick={() => updateSetting('fontFamily', 'serif')}
className={`px-4 py-2 rounded-lg border transition-colors font-serif ${
settings.fontFamily === 'serif'
? 'theme-accent-bg text-white border-transparent'
: 'theme-card theme-text theme-border hover:border-gray-400'
}`}
>
Serif
</button>
<button
onClick={() => updateSetting('fontFamily', 'sans')}
className={`px-4 py-2 rounded-lg border transition-colors font-sans ${
settings.fontFamily === 'sans'
? 'theme-accent-bg text-white border-transparent'
: 'theme-card theme-text theme-border hover:border-gray-400'
}`}
>
Sans Serif
</button>
<button
onClick={() => updateSetting('fontFamily', 'mono')}
className={`px-4 py-2 rounded-lg border transition-colors font-mono ${
settings.fontFamily === 'mono'
? 'theme-accent-bg text-white border-transparent'
: 'theme-card theme-text theme-border hover:border-gray-400'
}`}
>
Monospace
</button>
</div>
</div>
{/* Font Size */}
<div>
<label className="block text-sm font-medium theme-header mb-2">
Font Size
</label>
<div className="flex gap-4 flex-wrap">
{(['small', 'medium', 'large', 'extra-large'] as FontSize[]).map((size) => (
<button
key={size}
onClick={() => updateSetting('fontSize', size)}
className={`px-4 py-2 rounded-lg border transition-colors capitalize ${
settings.fontSize === size
? 'theme-accent-bg text-white border-transparent'
: 'theme-card theme-text theme-border hover:border-gray-400'
}`}
>
{size.replace('-', ' ')}
</button>
))}
</div>
</div>
{/* Reading Width */}
<div>
<label className="block text-sm font-medium theme-header mb-2">
Reading Width
</label>
<div className="flex gap-4">
{(['narrow', 'medium', 'wide'] as ReadingWidth[]).map((width) => (
<button
key={width}
onClick={() => updateSetting('readingWidth', width)}
className={`px-4 py-2 rounded-lg border transition-colors capitalize ${
settings.readingWidth === width
? 'theme-accent-bg text-white border-transparent'
: 'theme-card theme-text theme-border hover:border-gray-400'
}`}
>
{width}
</button>
))}
</div>
</div>
{/* Reading Speed */}
<div>
<label className="block text-sm font-medium theme-header mb-2">
Reading Speed (words per minute)
</label>
<div className="flex items-center gap-4">
<input
type="range"
min="100"
max="400"
step="25"
value={settings.readingSpeed}
onChange={(e) => updateSetting('readingSpeed', parseInt(e.target.value))}
className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
<div className="min-w-[80px] text-center">
<span className="text-lg font-medium theme-header">{settings.readingSpeed}</span>
<div className="text-xs theme-text">WPM</div>
</div>
</div>
<div className="flex justify-between text-xs theme-text mt-1">
<span>Slow (100)</span>
<span>Average (200)</span>
<span>Fast (400)</span>
</div>
</div>
</div>
</div>
{/* Preview */}
<div className="theme-card theme-shadow rounded-lg p-6">
<h2 className="text-xl font-semibold theme-header mb-4">Preview</h2>
<div
className="p-4 theme-card border theme-border rounded-lg"
style={{
fontFamily: settings.fontFamily === 'serif' ? 'Georgia, Times, serif'
: settings.fontFamily === 'sans' ? 'Inter, system-ui, sans-serif'
: 'Monaco, Consolas, monospace',
fontSize: settings.fontSize === 'small' ? '14px'
: settings.fontSize === 'medium' ? '16px'
: settings.fontSize === 'large' ? '18px'
: '20px',
maxWidth: settings.readingWidth === 'narrow' ? '600px'
: settings.readingWidth === 'medium' ? '800px'
: '1000px',
}}
>
<h3 className="text-xl font-bold theme-header mb-2">Sample Story Title</h3>
<p className="theme-text mb-4">by Sample Author</p>
<p className="theme-text leading-relaxed">
This is how your story text will look with the current settings.
The quick brown fox jumps over the lazy dog. Lorem ipsum dolor sit amet,
consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore
et dolore magna aliqua.
</p>
</div>
</div>
{/* Typesense Search Management */}
<div className="theme-card theme-shadow rounded-lg p-6">
<h2 className="text-xl font-semibold theme-header mb-4">Search Index Management</h2>
<p className="theme-text mb-6">
Manage the Typesense search indexes for stories and authors. Use these tools if search functionality isn't working properly.
</p>
<div className="space-y-6">
{/* Stories Section */}
<div className="border theme-border rounded-lg p-4">
<h3 className="text-lg font-semibold theme-header mb-3">Stories Index</h3>
<div className="flex flex-col sm:flex-row gap-3 mb-3">
<Button
onClick={() => handleTypesenseOperation('stories', 'reindex', storyApi.reindexTypesense)}
disabled={typesenseStatus.stories.loading}
loading={typesenseStatus.stories.loading}
variant="ghost"
className="flex-1"
>
{typesenseStatus.stories.loading ? 'Reindexing...' : 'Reindex Stories'}
</Button>
<Button
onClick={() => handleTypesenseOperation('stories', 'recreate', storyApi.recreateTypesenseCollection)}
disabled={typesenseStatus.stories.loading}
loading={typesenseStatus.stories.loading}
variant="secondary"
className="flex-1"
>
{typesenseStatus.stories.loading ? 'Recreating...' : 'Recreate Collection'}
</Button>
</div>
{typesenseStatus.stories.message && (
<div className={`text-sm p-2 rounded ${
typesenseStatus.stories.success
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
}`}>
{typesenseStatus.stories.message}
</div>
)}
</div>
{/* Authors Section */}
<div className="border theme-border rounded-lg p-4">
<h3 className="text-lg font-semibold theme-header mb-3">Authors Index</h3>
<div className="flex flex-col sm:flex-row gap-3 mb-3">
<Button
onClick={() => handleTypesenseOperation('authors', 'reindex', authorApi.reindexTypesense)}
disabled={typesenseStatus.authors.loading}
loading={typesenseStatus.authors.loading}
variant="ghost"
className="flex-1"
>
{typesenseStatus.authors.loading ? 'Reindexing...' : 'Reindex Authors'}
</Button>
<Button
onClick={() => handleTypesenseOperation('authors', 'recreate', authorApi.recreateTypesenseCollection)}
disabled={typesenseStatus.authors.loading}
loading={typesenseStatus.authors.loading}
variant="secondary"
className="flex-1"
>
{typesenseStatus.authors.loading ? 'Recreating...' : 'Recreate Collection'}
</Button>
</div>
{typesenseStatus.authors.message && (
<div className={`text-sm p-2 rounded ${
typesenseStatus.authors.success
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
}`}>
{typesenseStatus.authors.message}
</div>
)}
{/* Debug Schema Section */}
<div className="border-t theme-border pt-3">
<div className="flex items-center gap-2 mb-2">
<Button
onClick={fetchAuthorsSchema}
variant="ghost"
className="text-xs"
>
Inspect Schema
</Button>
<Button
onClick={() => setShowSchema(!showSchema)}
variant="ghost"
className="text-xs"
disabled={!authorsSchema}
>
{showSchema ? 'Hide' : 'Show'} Schema
</Button>
</div>
{showSchema && authorsSchema && (
<div className="text-xs theme-text bg-gray-50 dark:bg-gray-800 p-3 rounded border overflow-auto max-h-48">
<pre>{JSON.stringify(authorsSchema, null, 2)}</pre>
</div>
)}
</div>
</div>
<div className="text-sm theme-text bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
<p className="font-medium mb-1">When to use these tools:</p>
<ul className="text-xs space-y-1 ml-4">
<li>• <strong>Reindex:</strong> Refresh search data while keeping the existing schema</li>
<li>• <strong>Recreate Collection:</strong> Delete and rebuild the entire search index (fixes schema issues)</li>
</ul>
</div>
</div>
</div>
{/* Database Management */}
<div className="theme-card theme-shadow rounded-lg p-6">
<h2 className="text-xl font-semibold theme-header mb-4">Database Management</h2>
<p className="theme-text mb-6">
Backup, restore, or clear your StoryCove database and files. These comprehensive operations include both your data and uploaded images.
</p>
<div className="space-y-6">
{/* Complete Backup Section */}
<div className="border theme-border rounded-lg p-4 border-blue-200 dark:border-blue-800">
<h3 className="text-lg font-semibold theme-header mb-3">📦 Create Backup</h3>
<p className="text-sm theme-text mb-3">
Download a complete backup as a ZIP file. This includes your database AND all uploaded files (cover images, avatars). This is a comprehensive backup of your entire StoryCove installation.
</p>
<Button
onClick={handleCompleteBackup}
disabled={databaseStatus.completeBackup.loading}
loading={databaseStatus.completeBackup.loading}
variant="primary"
className="w-full sm:w-auto"
>
{databaseStatus.completeBackup.loading ? 'Creating Backup...' : 'Download Backup'}
</Button>
{databaseStatus.completeBackup.message && (
<div className={`text-sm p-2 rounded mt-3 ${
databaseStatus.completeBackup.success
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
}`}>
{databaseStatus.completeBackup.message}
</div>
)}
</div>
{/* Restore Section */}
<div className="border theme-border rounded-lg p-4 border-orange-200 dark:border-orange-800">
<h3 className="text-lg font-semibold theme-header mb-3">📥 Restore Backup</h3>
<p className="text-sm theme-text mb-3">
<strong className="text-orange-600 dark:text-orange-400">⚠️ Warning:</strong> This will completely replace your current database AND all files with the backup. All existing data and uploaded files will be permanently deleted.
</p>
<div className="flex items-center gap-3">
<input
type="file"
accept=".zip"
onChange={handleCompleteRestore}
disabled={databaseStatus.completeRestore.loading}
className="flex-1 text-sm theme-text file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:theme-accent-bg file:text-white hover:file:bg-opacity-90 file:cursor-pointer"
/>
</div>
{databaseStatus.completeRestore.message && (
<div className={`text-sm p-2 rounded mt-3 ${
databaseStatus.completeRestore.success
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
}`}>
{databaseStatus.completeRestore.message}
</div>
)}
{databaseStatus.completeRestore.loading && (
<div className="text-sm theme-text mt-3 flex items-center gap-2">
<div className="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
Restoring backup...
</div>
)}
</div>
{/* Clear Everything Section */}
<div className="border theme-border rounded-lg p-4 border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/10">
<h3 className="text-lg font-semibold theme-header mb-3">🗑️ Clear Everything</h3>
<p className="text-sm theme-text mb-3">
<strong className="text-red-600 dark:text-red-400">⚠️ Danger Zone:</strong> This will permanently delete ALL data from your database AND all uploaded files (cover images, avatars). Everything will be completely removed. This action cannot be undone!
</p>
<Button
onClick={handleCompleteClear}
disabled={databaseStatus.completeClear.loading}
loading={databaseStatus.completeClear.loading}
variant="secondary"
className="w-full sm:w-auto bg-red-700 hover:bg-red-800 text-white border-red-700"
>
{databaseStatus.completeClear.loading ? 'Clearing Everything...' : 'Clear Everything'}
</Button>
{databaseStatus.completeClear.message && (
<div className={`text-sm p-2 rounded mt-3 ${
databaseStatus.completeClear.success
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
}`}>
{databaseStatus.completeClear.message}
</div>
)}
</div>
<div className="text-sm theme-text bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
<p className="font-medium mb-1">💡 Best Practices:</p>
<ul className="text-xs space-y-1 ml-4">
<li>• <strong>Always backup</strong> before performing restore or clear operations</li>
<li>• <strong>Store backups safely</strong> in multiple locations for important data</li>
<li>• <strong>Test restores</strong> in a development environment when possible</li>
<li>• <strong>Backup files (.zip)</strong> contain both database and all uploaded files</li>
<li>• <strong>Verify backup files</strong> are complete before relying on them</li>
</ul>
</div>
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-4">
<Button
variant="ghost"
onClick={() => {
setSettings({ ...defaultSettings, theme });
}}
>
Reset to Defaults
</Button>
<Button
onClick={saveSettings}
className={saved ? 'bg-green-600 hover:bg-green-700' : ''}
>
{saved ? ' Saved!' : 'Save Settings'}
</Button>
</div>
</div>
</div>
</AppLayout>
);
}