Library Switching functionality

This commit is contained in:
Stefan Hardegger
2025-08-20 15:10:40 +02:00
parent 5e347f2e2e
commit 6128d61349
24 changed files with 2934 additions and 94 deletions

View File

@@ -6,6 +6,7 @@ import { useTheme } from '../../lib/theme';
import Button from '../../components/ui/Button';
import { storyApi, authorApi, databaseApi } from '../../lib/api';
import { useLibraryLayout, LibraryLayoutType } from '../../hooks/useLibraryLayout';
import LibrarySettings from '../../components/library/LibrarySettings';
type FontFamily = 'serif' | 'sans' | 'mono';
type FontSize = 'small' | 'medium' | 'large' | 'extra-large';
@@ -774,6 +775,9 @@ export default function SettingsPage() {
</div>
</div>
{/* Library Settings */}
<LibrarySettings />
{/* Tag Management */}
<div className="theme-card theme-shadow rounded-lg p-6">
<h2 className="text-xl font-semibold theme-header mb-4">Tag Management</h2>

View File

@@ -0,0 +1,602 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Button from '../ui/Button';
import { Input } from '../ui/Input';
import LibrarySwitchLoader from '../ui/LibrarySwitchLoader';
import { useLibrarySwitch } from '../../hooks/useLibrarySwitch';
interface Library {
id: string;
name: string;
description: string;
isActive: boolean;
isInitialized: boolean;
}
export default function LibrarySettings() {
const router = useRouter();
const { state: switchState, switchLibrary, clearError, reset } = useLibrarySwitch();
const [libraries, setLibraries] = useState<Library[]>([]);
const [currentLibrary, setCurrentLibrary] = useState<Library | null>(null);
const [loading, setLoading] = useState(true);
const [switchPassword, setSwitchPassword] = useState('');
const [showSwitchForm, setShowSwitchForm] = useState(false);
const [passwordChangeForm, setPasswordChangeForm] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const [showPasswordChangeForm, setShowPasswordChangeForm] = useState(false);
const [passwordChangeLoading, setPasswordChangeLoading] = useState(false);
const [passwordChangeMessage, setPasswordChangeMessage] = useState<{type: 'success' | 'error', text: string} | null>(null);
const [createLibraryForm, setCreateLibraryForm] = useState({
name: '',
description: '',
password: '',
confirmPassword: ''
});
const [showCreateLibraryForm, setShowCreateLibraryForm] = useState(false);
const [createLibraryLoading, setCreateLibraryLoading] = useState(false);
const [createLibraryMessage, setCreateLibraryMessage] = useState<{type: 'success' | 'error', text: string} | null>(null);
useEffect(() => {
loadLibraries();
loadCurrentLibrary();
}, []);
const loadLibraries = async () => {
try {
const response = await fetch('/api/libraries');
if (response.ok) {
const data = await response.json();
setLibraries(data);
}
} catch (error) {
console.error('Failed to load libraries:', error);
}
};
const loadCurrentLibrary = async () => {
try {
const response = await fetch('/api/libraries/current');
if (response.ok) {
const data = await response.json();
setCurrentLibrary(data);
}
} catch (error) {
console.error('Failed to load current library:', error);
} finally {
setLoading(false);
}
};
const handleSwitchLibrary = async (e: React.FormEvent) => {
e.preventDefault();
if (!switchPassword.trim()) {
return;
}
const success = await switchLibrary(switchPassword);
if (success) {
// The LibrarySwitchLoader will handle the rest
}
};
const handleSwitchComplete = () => {
// Refresh the page to reload with new library context
router.refresh();
window.location.reload();
};
const handleSwitchError = (error: string) => {
console.error('Library switch error:', error);
reset();
};
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault();
if (passwordChangeForm.newPassword !== passwordChangeForm.confirmPassword) {
setPasswordChangeMessage({type: 'error', text: 'New passwords do not match'});
return;
}
if (passwordChangeForm.newPassword.length < 8) {
setPasswordChangeMessage({type: 'error', text: 'Password must be at least 8 characters long'});
return;
}
setPasswordChangeLoading(true);
setPasswordChangeMessage(null);
try {
const response = await fetch('/api/libraries/password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
currentPassword: passwordChangeForm.currentPassword,
newPassword: passwordChangeForm.newPassword,
}),
});
const data = await response.json();
if (response.ok && data.success) {
setPasswordChangeMessage({type: 'success', text: 'Password changed successfully'});
setPasswordChangeForm({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
setShowPasswordChangeForm(false);
} else {
setPasswordChangeMessage({type: 'error', text: data.error || 'Failed to change password'});
}
} catch (error) {
setPasswordChangeMessage({type: 'error', text: 'Network error occurred'});
} finally {
setPasswordChangeLoading(false);
}
};
const handleCreateLibrary = async (e: React.FormEvent) => {
e.preventDefault();
if (createLibraryForm.password !== createLibraryForm.confirmPassword) {
setCreateLibraryMessage({type: 'error', text: 'Passwords do not match'});
return;
}
if (createLibraryForm.password.length < 8) {
setCreateLibraryMessage({type: 'error', text: 'Password must be at least 8 characters long'});
return;
}
if (createLibraryForm.name.trim().length < 2) {
setCreateLibraryMessage({type: 'error', text: 'Library name must be at least 2 characters long'});
return;
}
setCreateLibraryLoading(true);
setCreateLibraryMessage(null);
try {
const response = await fetch('/api/libraries/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: createLibraryForm.name.trim(),
description: createLibraryForm.description.trim(),
password: createLibraryForm.password,
}),
});
const data = await response.json();
if (response.ok && data.success) {
setCreateLibraryMessage({
type: 'success',
text: `Library "${data.library.name}" created successfully! You can now log out and log in with the new password to access it.`
});
setCreateLibraryForm({
name: '',
description: '',
password: '',
confirmPassword: ''
});
setShowCreateLibraryForm(false);
loadLibraries(); // Refresh the library list
} else {
setCreateLibraryMessage({type: 'error', text: data.error || 'Failed to create library'});
}
} catch (error) {
setCreateLibraryMessage({type: 'error', text: 'Network error occurred'});
} finally {
setCreateLibraryLoading(false);
}
};
if (loading) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Library Settings
</h2>
<div className="animate-pulse">
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-1/4 mb-2"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-1/2"></div>
</div>
</div>
);
}
return (
<>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Library Settings
</h2>
{/* Current Library Info */}
{currentLibrary && (
<div className="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<h3 className="font-medium text-blue-900 dark:text-blue-100 mb-1">
Active Library
</h3>
<p className="text-blue-700 dark:text-blue-300 text-sm">
<strong>{currentLibrary.name}</strong>
</p>
<p className="text-blue-600 dark:text-blue-400 text-xs mt-1">
{currentLibrary.description}
</p>
</div>
)}
{/* Change Password Section */}
<div className="mb-6 border-t pt-4">
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
Change Library Password
</h3>
{passwordChangeMessage && (
<div className={`p-3 rounded-lg mb-4 ${
passwordChangeMessage.type === 'success'
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
}`}>
<p className={`text-sm ${
passwordChangeMessage.type === 'success'
? 'text-green-700 dark:text-green-300'
: 'text-red-700 dark:text-red-300'
}`}>
{passwordChangeMessage.text}
</p>
</div>
)}
{!showPasswordChangeForm ? (
<div>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
Change the password for the current library ({currentLibrary?.name}).
</p>
<Button
onClick={() => setShowPasswordChangeForm(true)}
variant="secondary"
>
Change Password
</Button>
</div>
) : (
<form onSubmit={handlePasswordChange} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Current Password
</label>
<Input
type="password"
value={passwordChangeForm.currentPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setPasswordChangeForm(prev => ({ ...prev, currentPassword: e.target.value }))
}
placeholder="Enter current password"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
New Password
</label>
<Input
type="password"
value={passwordChangeForm.newPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setPasswordChangeForm(prev => ({ ...prev, newPassword: e.target.value }))
}
placeholder="Enter new password (min 8 characters)"
required
minLength={8}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Confirm New Password
</label>
<Input
type="password"
value={passwordChangeForm.confirmPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setPasswordChangeForm(prev => ({ ...prev, confirmPassword: e.target.value }))
}
placeholder="Confirm new password"
required
minLength={8}
/>
</div>
<div className="flex space-x-3">
<Button
type="submit"
disabled={passwordChangeLoading}
loading={passwordChangeLoading}
>
{passwordChangeLoading ? 'Changing...' : 'Change Password'}
</Button>
<Button
type="button"
variant="secondary"
onClick={() => {
setShowPasswordChangeForm(false);
setPasswordChangeForm({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
setPasswordChangeMessage(null);
}}
>
Cancel
</Button>
</div>
</form>
)}
</div>
{/* Available Libraries */}
<div className="mb-6">
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
Available Libraries
</h3>
<div className="space-y-2">
{libraries.map((library) => (
<div
key={library.id}
className={`p-3 rounded-lg border ${
library.isActive
? 'border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-900/20'
: 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900/50'
}`}
>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900 dark:text-white">
{library.name}
{library.isActive && (
<span className="ml-2 text-xs px-2 py-1 bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-200 rounded-full">
Active
</span>
)}
</p>
<p className="text-sm text-gray-600 dark:text-gray-300">
{library.description}
</p>
</div>
{!library.isActive && (
<div className="text-xs text-gray-500 dark:text-gray-400">
ID: {library.id}
</div>
)}
</div>
</div>
))}
</div>
</div>
{/* Switch Library Section */}
<div className="border-t pt-4">
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
Switch Library
</h3>
{!showSwitchForm ? (
<div>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
Enter the password for a different library to switch to it.
</p>
<Button
onClick={() => setShowSwitchForm(true)}
variant="secondary"
>
Switch to Different Library
</Button>
</div>
) : (
<form onSubmit={handleSwitchLibrary} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Library Password
</label>
<Input
type="password"
value={switchPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSwitchPassword(e.target.value)}
placeholder="Enter password for the library you want to access"
required
/>
</div>
{switchState.error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-300">
{switchState.error}
</p>
</div>
)}
<div className="flex space-x-3">
<Button type="submit" disabled={switchState.isLoading}>
{switchState.isLoading ? 'Switching...' : 'Switch Library'}
</Button>
<Button
type="button"
variant="secondary"
onClick={() => {
setShowSwitchForm(false);
setSwitchPassword('');
clearError();
}}
>
Cancel
</Button>
</div>
</form>
)}
</div>
{/* Create New Library Section */}
<div className="border-t pt-4 mb-6">
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
Create New Library
</h3>
{createLibraryMessage && (
<div className={`p-3 rounded-lg mb-4 ${
createLibraryMessage.type === 'success'
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
}`}>
<p className={`text-sm ${
createLibraryMessage.type === 'success'
? 'text-green-700 dark:text-green-300'
: 'text-red-700 dark:text-red-300'
}`}>
{createLibraryMessage.text}
</p>
</div>
)}
{!showCreateLibraryForm ? (
<div>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
Create a completely separate library with its own stories, authors, and password.
</p>
<Button
onClick={() => setShowCreateLibraryForm(true)}
variant="secondary"
>
Create New Library
</Button>
</div>
) : (
<form onSubmit={handleCreateLibrary} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Library Name *
</label>
<Input
type="text"
value={createLibraryForm.name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setCreateLibraryForm(prev => ({ ...prev, name: e.target.value }))
}
placeholder="e.g., Private Stories, Work Collection"
required
minLength={2}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<Input
type="text"
value={createLibraryForm.description}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setCreateLibraryForm(prev => ({ ...prev, description: e.target.value }))
}
placeholder="Optional description for this library"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password *
</label>
<Input
type="password"
value={createLibraryForm.password}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setCreateLibraryForm(prev => ({ ...prev, password: e.target.value }))
}
placeholder="Enter password (min 8 characters)"
required
minLength={8}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Confirm Password *
</label>
<Input
type="password"
value={createLibraryForm.confirmPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setCreateLibraryForm(prev => ({ ...prev, confirmPassword: e.target.value }))
}
placeholder="Confirm password"
required
minLength={8}
/>
</div>
<div className="flex space-x-3">
<Button
type="submit"
disabled={createLibraryLoading}
loading={createLibraryLoading}
>
{createLibraryLoading ? 'Creating...' : 'Create Library'}
</Button>
<Button
type="button"
variant="secondary"
onClick={() => {
setShowCreateLibraryForm(false);
setCreateLibraryForm({
name: '',
description: '',
password: '',
confirmPassword: ''
});
setCreateLibraryMessage(null);
}}
>
Cancel
</Button>
</div>
</form>
)}
</div>
{/* Info Box */}
<div className="mt-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
<strong>Note:</strong> Libraries are completely separate datasets. Switching libraries
will reload the application with a different set of stories, authors, and settings.
Each library has its own password for security.
</p>
</div>
</div>
{/* Library Switch Loader */}
<LibrarySwitchLoader
isVisible={switchState.isLoading}
targetLibraryName={switchState.targetLibraryName || undefined}
onComplete={handleSwitchComplete}
onError={handleSwitchError}
/>
</>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import React, { useEffect, useState } from 'react';
import LoadingSpinner from './LoadingSpinner';
interface LibrarySwitchLoaderProps {
isVisible: boolean;
targetLibraryName?: string;
onComplete: () => void;
onError: (error: string) => void;
}
export default function LibrarySwitchLoader({
isVisible,
targetLibraryName,
onComplete,
onError
}: LibrarySwitchLoaderProps) {
const [dots, setDots] = useState('');
const [timeElapsed, setTimeElapsed] = useState(0);
useEffect(() => {
if (!isVisible) return;
// Animate dots
const dotsInterval = setInterval(() => {
setDots(prev => prev.length >= 3 ? '' : prev + '.');
}, 500);
// Track time elapsed
const timeInterval = setInterval(() => {
setTimeElapsed(prev => prev + 1);
}, 1000);
// Poll for completion
const pollInterval = setInterval(async () => {
try {
const response = await fetch('/api/libraries/switch/status');
if (response.ok) {
const data = await response.json();
if (data.ready) {
clearInterval(pollInterval);
clearInterval(dotsInterval);
clearInterval(timeInterval);
onComplete();
}
}
} catch (error) {
console.error('Error polling switch status:', error);
}
}, 1000);
// Timeout after 30 seconds
const timeout = setTimeout(() => {
clearInterval(pollInterval);
clearInterval(dotsInterval);
clearInterval(timeInterval);
onError('Library switch timed out. Please try again.');
}, 30000);
return () => {
clearInterval(dotsInterval);
clearInterval(timeInterval);
clearInterval(pollInterval);
clearTimeout(timeout);
};
}, [isVisible, onComplete, onError]);
if (!isVisible) return null;
return (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 max-w-sm w-full mx-4 text-center shadow-2xl">
<div className="mb-6">
<LoadingSpinner size="lg" />
</div>
<h2 className="text-xl font-semibold mb-2 text-gray-900 dark:text-white">
Switching Libraries
</h2>
<p className="text-gray-600 dark:text-gray-300 mb-4">
{targetLibraryName ?
`Loading "${targetLibraryName}"${dots}` :
`Preparing your library${dots}`
}
</p>
<div className="text-sm text-gray-500 dark:text-gray-400">
<p>This may take a few seconds...</p>
{timeElapsed > 5 && (
<p className="mt-2 text-orange-600 dark:text-orange-400">
Still working ({timeElapsed}s)
</p>
)}
</div>
<div className="mt-6 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p className="text-xs text-blue-700 dark:text-blue-300">
💡 Libraries are completely separate datasets with their own stories, authors, and settings.
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import { useState, useCallback } from 'react';
interface LibrarySwitchState {
isLoading: boolean;
targetLibraryName: string | null;
error: string | null;
}
interface LibrarySwitchResult {
state: LibrarySwitchState;
switchLibrary: (password: string) => Promise<boolean>;
clearError: () => void;
reset: () => void;
}
export function useLibrarySwitch(): LibrarySwitchResult {
const [state, setState] = useState<LibrarySwitchState>({
isLoading: false,
targetLibraryName: null,
error: null,
});
const switchLibrary = useCallback(async (password: string): Promise<boolean> => {
setState({
isLoading: true,
targetLibraryName: null,
error: null,
});
try {
const response = await fetch('/api/libraries/switch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ password }),
});
const data = await response.json();
if (!response.ok) {
setState(prev => ({
...prev,
isLoading: false,
error: data.error || 'Failed to switch library',
}));
return false;
}
if (data.status === 'already_active') {
setState(prev => ({
...prev,
isLoading: false,
error: data.message,
}));
return false;
}
if (data.status === 'switching') {
// Get library name if available
try {
const librariesResponse = await fetch('/api/libraries');
if (librariesResponse.ok) {
const libraries = await librariesResponse.json();
const targetLibrary = libraries.find((lib: any) => lib.id === data.targetLibrary);
setState(prev => ({
...prev,
targetLibraryName: targetLibrary?.name || data.targetLibrary,
}));
}
} catch (e) {
// Continue without library name
}
return true; // Switch initiated successfully
}
setState(prev => ({
...prev,
isLoading: false,
error: 'Unexpected response from server',
}));
return false;
} catch (error) {
setState(prev => ({
...prev,
isLoading: false,
error: 'Network error occurred',
}));
return false;
}
}, []);
const clearError = useCallback(() => {
setState(prev => ({
...prev,
error: null,
}));
}, []);
const reset = useCallback(() => {
setState({
isLoading: false,
targetLibraryName: null,
error: null,
});
}, []);
return {
state,
switchLibrary,
clearError,
reset,
};
}