3 Commits

Author SHA1 Message Date
Stefan Hardegger
64f97f5648 Settings reorganization 2025-09-17 15:06:35 +02:00
Stefan Hardegger
c0b3ae3b72 embedded image finishing 2025-09-17 10:28:35 +02:00
Stefan Hardegger
e5596b5a17 fix port mapping 2025-09-16 15:06:40 +02:00
16 changed files with 1714 additions and 746 deletions

View File

@@ -2,6 +2,7 @@ package com.storycove.controller;
import com.storycove.dto.HtmlSanitizationConfigDto; import com.storycove.dto.HtmlSanitizationConfigDto;
import com.storycove.service.HtmlSanitizationService; import com.storycove.service.HtmlSanitizationService;
import com.storycove.service.ImageService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -14,13 +15,15 @@ import java.util.Map;
public class ConfigController { public class ConfigController {
private final HtmlSanitizationService htmlSanitizationService; private final HtmlSanitizationService htmlSanitizationService;
private final ImageService imageService;
@Value("${app.reading.speed.default:200}") @Value("${app.reading.speed.default:200}")
private int defaultReadingSpeed; private int defaultReadingSpeed;
@Autowired @Autowired
public ConfigController(HtmlSanitizationService htmlSanitizationService) { public ConfigController(HtmlSanitizationService htmlSanitizationService, ImageService imageService) {
this.htmlSanitizationService = htmlSanitizationService; this.htmlSanitizationService = htmlSanitizationService;
this.imageService = imageService;
} }
/** /**
@@ -51,4 +54,64 @@ public class ConfigController {
public ResponseEntity<Map<String, Integer>> getReadingSpeed() { public ResponseEntity<Map<String, Integer>> getReadingSpeed() {
return ResponseEntity.ok(Map.of("wordsPerMinute", defaultReadingSpeed)); return ResponseEntity.ok(Map.of("wordsPerMinute", defaultReadingSpeed));
} }
/**
* Preview orphaned content images cleanup (dry run)
*/
@PostMapping("/cleanup/images/preview")
public ResponseEntity<Map<String, Object>> previewImageCleanup() {
try {
ImageService.ContentImageCleanupResult result = imageService.cleanupOrphanedContentImages(true);
Map<String, Object> response = Map.of(
"success", true,
"orphanedCount", result.getOrphanedImages().size(),
"totalSizeBytes", result.getTotalSizeBytes(),
"formattedSize", result.getFormattedSize(),
"foldersToDelete", result.getFoldersToDelete(),
"referencedImagesCount", result.getTotalReferencedImages(),
"errors", result.getErrors(),
"hasErrors", result.hasErrors(),
"dryRun", true
);
return ResponseEntity.ok(response);
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"error", "Failed to preview image cleanup: " + e.getMessage()
));
}
}
/**
* Execute orphaned content images cleanup
*/
@PostMapping("/cleanup/images/execute")
public ResponseEntity<Map<String, Object>> executeImageCleanup() {
try {
ImageService.ContentImageCleanupResult result = imageService.cleanupOrphanedContentImages(false);
Map<String, Object> response = Map.of(
"success", true,
"deletedCount", result.getOrphanedImages().size(),
"totalSizeBytes", result.getTotalSizeBytes(),
"formattedSize", result.getFormattedSize(),
"foldersDeleted", result.getFoldersToDelete(),
"referencedImagesCount", result.getTotalReferencedImages(),
"errors", result.getErrors(),
"hasErrors", result.hasErrors(),
"dryRun", false
);
return ResponseEntity.ok(response);
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"error", "Failed to execute image cleanup: " + e.getMessage()
));
}
}
} }

View File

@@ -74,6 +74,34 @@ public class EPUBImportService {
Story savedStory = storyService.create(story); Story savedStory = storyService.create(story);
// Process embedded images if content contains any
String originalContent = story.getContentHtml();
if (originalContent != null && originalContent.contains("<img")) {
try {
ImageService.ContentImageProcessingResult imageResult =
imageService.processContentImages(originalContent, savedStory.getId());
// Update story content with processed images if changed
if (!imageResult.getProcessedContent().equals(originalContent)) {
savedStory.setContentHtml(imageResult.getProcessedContent());
savedStory = storyService.update(savedStory.getId(), savedStory);
// Log the image processing results
System.out.println("EPUB Import - Image processing completed for story " + savedStory.getId() +
". Downloaded " + imageResult.getDownloadedImages().size() + " images.");
if (imageResult.hasWarnings()) {
System.out.println("EPUB Import - Image processing warnings: " +
String.join(", ", imageResult.getWarnings()));
}
}
} catch (Exception e) {
// Log error but don't fail the import
System.err.println("EPUB Import - Failed to process embedded images for story " +
savedStory.getId() + ": " + e.getMessage());
}
}
EPUBImportResponse response = EPUBImportResponse.success(savedStory.getId(), savedStory.getTitle()); EPUBImportResponse response = EPUBImportResponse.success(savedStory.getId(), savedStory.getTitle());
response.setWordCount(savedStory.getWordCount()); response.setWordCount(savedStory.getWordCount());
response.setTotalChapters(book.getSpine().size()); response.setTotalChapters(book.getSpine().size());

View File

@@ -40,6 +40,9 @@ public class ImageService {
@Autowired @Autowired
private LibraryService libraryService; private LibraryService libraryService;
@Autowired
private StoryService storyService;
private String getUploadDir() { private String getUploadDir() {
String libraryPath = libraryService.getCurrentImagePath(); String libraryPath = libraryService.getCurrentImagePath();
return baseUploadDir + libraryPath; return baseUploadDir + libraryPath;
@@ -421,6 +424,249 @@ public class ImageService {
return null; return null;
} }
/**
* Cleanup orphaned content images that are no longer referenced in any story
*/
public ContentImageCleanupResult cleanupOrphanedContentImages(boolean dryRun) {
logger.info("Starting orphaned content image cleanup (dryRun: {})", dryRun);
final Set<String> referencedImages;
List<String> orphanedImages = new ArrayList<>();
List<String> errors = new ArrayList<>();
long totalSizeBytes = 0;
int foldersToDelete = 0;
// Step 1: Collect all image references from all story content
logger.info("Scanning all story content for image references...");
referencedImages = collectAllImageReferences();
logger.info("Found {} unique image references in story content", referencedImages.size());
try {
// Step 2: Scan the content images directory
Path contentImagesDir = Paths.get(getUploadDir(), ImageType.CONTENT.getDirectory());
if (!Files.exists(contentImagesDir)) {
logger.info("Content images directory does not exist: {}", contentImagesDir);
return new ContentImageCleanupResult(orphanedImages, 0, 0, referencedImages.size(), errors, dryRun);
}
logger.info("Scanning content images directory: {}", contentImagesDir);
// Walk through all story directories
Files.walk(contentImagesDir, 2)
.filter(Files::isDirectory)
.filter(path -> !path.equals(contentImagesDir)) // Skip the root content directory
.forEach(storyDir -> {
try {
String storyId = storyDir.getFileName().toString();
logger.debug("Checking story directory: {}", storyId);
// Check if this story still exists
boolean storyExists = storyService.findByIdOptional(UUID.fromString(storyId)).isPresent();
if (!storyExists) {
logger.info("Found orphaned story directory (story deleted): {}", storyId);
// Mark entire directory for deletion
try {
Files.walk(storyDir)
.filter(Files::isRegularFile)
.forEach(file -> {
try {
long size = Files.size(file);
orphanedImages.add(file.toString());
// Add to total size (will be updated in main scope)
} catch (IOException e) {
errors.add("Failed to get size for " + file + ": " + e.getMessage());
}
});
} catch (IOException e) {
errors.add("Failed to scan orphaned story directory " + storyDir + ": " + e.getMessage());
}
return;
}
// Check individual files in the story directory
try {
Files.walk(storyDir)
.filter(Files::isRegularFile)
.forEach(imageFile -> {
try {
String imagePath = getRelativeImagePath(imageFile);
if (!referencedImages.contains(imagePath)) {
logger.debug("Found orphaned image: {}", imagePath);
orphanedImages.add(imageFile.toString());
}
} catch (Exception e) {
errors.add("Error checking image file " + imageFile + ": " + e.getMessage());
}
});
} catch (IOException e) {
errors.add("Failed to scan story directory " + storyDir + ": " + e.getMessage());
}
} catch (Exception e) {
errors.add("Error processing story directory " + storyDir + ": " + e.getMessage());
}
});
// Calculate total size and count empty directories
for (String orphanedImage : orphanedImages) {
try {
Path imagePath = Paths.get(orphanedImage);
if (Files.exists(imagePath)) {
totalSizeBytes += Files.size(imagePath);
}
} catch (IOException e) {
errors.add("Failed to get size for " + orphanedImage + ": " + e.getMessage());
}
}
// Count empty directories that would be removed
try {
foldersToDelete = (int) Files.walk(contentImagesDir)
.filter(Files::isDirectory)
.filter(path -> !path.equals(contentImagesDir))
.filter(this::isDirectoryEmptyOrWillBeEmpty)
.count();
} catch (IOException e) {
errors.add("Failed to count empty directories: " + e.getMessage());
}
// Step 3: Delete orphaned files if not dry run
if (!dryRun && !orphanedImages.isEmpty()) {
logger.info("Deleting {} orphaned images...", orphanedImages.size());
Set<Path> directoriesToCheck = new HashSet<>();
for (String orphanedImage : orphanedImages) {
try {
Path imagePath = Paths.get(orphanedImage);
if (Files.exists(imagePath)) {
directoriesToCheck.add(imagePath.getParent());
Files.delete(imagePath);
logger.debug("Deleted orphaned image: {}", imagePath);
}
} catch (IOException e) {
errors.add("Failed to delete " + orphanedImage + ": " + e.getMessage());
}
}
// Clean up empty directories
for (Path dir : directoriesToCheck) {
try {
if (Files.exists(dir) && isDirEmpty(dir)) {
Files.delete(dir);
logger.info("Deleted empty story directory: {}", dir);
}
} catch (IOException e) {
errors.add("Failed to delete empty directory " + dir + ": " + e.getMessage());
}
}
}
logger.info("Orphaned content image cleanup completed. Found {} orphaned files ({} bytes)",
orphanedImages.size(), totalSizeBytes);
} catch (Exception e) {
logger.error("Error during orphaned content image cleanup", e);
errors.add("General cleanup error: " + e.getMessage());
}
return new ContentImageCleanupResult(orphanedImages, totalSizeBytes, foldersToDelete, referencedImages.size(), errors, dryRun);
}
/**
* Collect all image references from all story content
*/
private Set<String> collectAllImageReferences() {
Set<String> referencedImages = new HashSet<>();
try {
// Get all stories
List<com.storycove.entity.Story> allStories = storyService.findAllWithAssociations();
// Pattern to match local image URLs in content
Pattern imagePattern = Pattern.compile("src=[\"']([^\"']*(?:content/[^\"']*\\.(jpg|jpeg|png)))[\"']", Pattern.CASE_INSENSITIVE);
for (com.storycove.entity.Story story : allStories) {
if (story.getContentHtml() != null) {
Matcher matcher = imagePattern.matcher(story.getContentHtml());
while (matcher.find()) {
String imageSrc = matcher.group(1);
// Convert to relative path format that matches our file system
String relativePath = convertSrcToRelativePath(imageSrc);
if (relativePath != null) {
referencedImages.add(relativePath);
logger.debug("Found image reference in story {}: {}", story.getId(), relativePath);
}
}
}
}
} catch (Exception e) {
logger.error("Error collecting image references from stories", e);
}
return referencedImages;
}
/**
* Convert an image src attribute to relative file path
*/
private String convertSrcToRelativePath(String src) {
try {
// Handle both /api/files/images/libraryId/content/... and relative content/... paths
if (src.contains("/content/")) {
int contentIndex = src.indexOf("/content/");
return src.substring(contentIndex + 1); // Remove leading slash, keep "content/..."
}
} catch (Exception e) {
logger.debug("Failed to convert src to relative path: {}", src);
}
return null;
}
/**
* Get relative image path from absolute file path
*/
private String getRelativeImagePath(Path imageFile) {
try {
Path uploadDir = Paths.get(getUploadDir());
Path relativePath = uploadDir.relativize(imageFile);
return relativePath.toString().replace('\\', '/'); // Normalize path separators
} catch (Exception e) {
logger.debug("Failed to get relative path for: {}", imageFile);
return imageFile.toString();
}
}
/**
* Check if directory is empty or will be empty after cleanup
*/
private boolean isDirectoryEmptyOrWillBeEmpty(Path dir) {
try {
return Files.walk(dir)
.filter(Files::isRegularFile)
.count() == 0;
} catch (IOException e) {
return false;
}
}
/**
* Check if directory is empty
*/
private boolean isDirEmpty(Path dir) {
try {
return Files.list(dir).count() == 0;
} catch (IOException e) {
return false;
}
}
/** /**
* Clean up content images for a story * Clean up content images for a story
*/ */
@@ -458,4 +704,41 @@ public class ImageService {
public List<String> getDownloadedImages() { return downloadedImages; } public List<String> getDownloadedImages() { return downloadedImages; }
public boolean hasWarnings() { return !warnings.isEmpty(); } public boolean hasWarnings() { return !warnings.isEmpty(); }
} }
/**
* Result class for orphaned image cleanup
*/
public static class ContentImageCleanupResult {
private final List<String> orphanedImages;
private final long totalSizeBytes;
private final int foldersToDelete;
private final int totalReferencedImages;
private final List<String> errors;
private final boolean dryRun;
public ContentImageCleanupResult(List<String> orphanedImages, long totalSizeBytes, int foldersToDelete,
int totalReferencedImages, List<String> errors, boolean dryRun) {
this.orphanedImages = orphanedImages;
this.totalSizeBytes = totalSizeBytes;
this.foldersToDelete = foldersToDelete;
this.totalReferencedImages = totalReferencedImages;
this.errors = errors;
this.dryRun = dryRun;
}
public List<String> getOrphanedImages() { return orphanedImages; }
public long getTotalSizeBytes() { return totalSizeBytes; }
public int getFoldersToDelete() { return foldersToDelete; }
public int getTotalReferencedImages() { return totalReferencedImages; }
public List<String> getErrors() { return errors; }
public boolean isDryRun() { return dryRun; }
public boolean hasErrors() { return !errors.isEmpty(); }
public String getFormattedSize() {
if (totalSizeBytes < 1024) return totalSizeBytes + " B";
if (totalSizeBytes < 1024 * 1024) return String.format("%.1f KB", totalSizeBytes / 1024.0);
if (totalSizeBytes < 1024 * 1024 * 1024) return String.format("%.1f MB", totalSizeBytes / (1024.0 * 1024.0));
return String.format("%.1f GB", totalSizeBytes / (1024.0 * 1024.0 * 1024.0));
}
}
} }

View File

@@ -52,8 +52,8 @@ services:
postgres: postgres:
image: postgres:15-alpine image: postgres:15-alpine
# No port mapping - only accessible within the Docker network # No port mapping - only accessible within the Docker network
ports: #ports:
- "5432:5432" # - "5432:5432"
environment: environment:
- POSTGRES_DB=storycove - POSTGRES_DB=storycove
- POSTGRES_USER=storycove - POSTGRES_USER=storycove

View File

@@ -193,24 +193,42 @@ async function processCombinedMode(
console.log(`Combined content character length: ${combinedContentString.length}`); console.log(`Combined content character length: ${combinedContentString.length}`);
console.log(`Combined content parts count: ${combinedContent.length}`); console.log(`Combined content parts count: ${combinedContent.length}`);
// Handle content truncation if needed
let finalContent = contentSizeInMB > 10 ?
combinedContentString.substring(0, Math.floor(combinedContentString.length * (10 / contentSizeInMB))) + '\n\n<!-- Content truncated due to size limit -->' :
combinedContentString;
let finalSummary = contentSizeInMB > 10 ? baseSummary + ' (Content truncated due to size limit)' : baseSummary;
// Check if combined content has images and mark for processing
const hasImages = /<img[^>]+src=['"'][^'"']*['"][^>]*>/i.test(finalContent);
if (hasImages) {
finalSummary += ' (Contains embedded images - will be processed after story creation)';
console.log(`Combined story contains embedded images - will need processing after creation`);
}
// Return the combined story data via progress update // Return the combined story data via progress update
const combinedStory = { const combinedStory = {
title: baseTitle, title: baseTitle,
author: baseAuthor, author: baseAuthor,
content: contentSizeInMB > 10 ? content: finalContent,
combinedContentString.substring(0, Math.floor(combinedContentString.length * (10 / contentSizeInMB))) + '\n\n<!-- Content truncated due to size limit -->' : summary: finalSummary,
combinedContentString,
summary: contentSizeInMB > 10 ? baseSummary + ' (Content truncated due to size limit)' : baseSummary,
sourceUrl: baseSourceUrl, sourceUrl: baseSourceUrl,
tags: Array.from(combinedTags) tags: Array.from(combinedTags),
hasImages: hasImages
}; };
// Send completion notification for combine mode // Send completion notification for combine mode
let completionMessage = `Combined scraping completed: ${totalWordCount.toLocaleString()} words from ${importedCount} stories`;
if (hasImages) {
completionMessage += ` (embedded images will be processed when story is created)`;
}
await sendProgressUpdate(sessionId, { await sendProgressUpdate(sessionId, {
type: 'completed', type: 'completed',
current: urls.length, current: urls.length,
total: urls.length, total: urls.length,
message: `Combined scraping completed: ${totalWordCount.toLocaleString()} words from ${importedCount} stories`, message: completionMessage,
totalWordCount: totalWordCount, totalWordCount: totalWordCount,
combinedStory: combinedStory combinedStory: combinedStory
}); });
@@ -347,6 +365,61 @@ async function processIndividualMode(
const createdStory = await createResponse.json(); const createdStory = await createResponse.json();
// Process embedded images if content contains images
let imageProcessingWarnings: string[] = [];
const hasImages = /<img[^>]+src=['"'][^'"']*['"][^>]*>/i.test(scrapedStory.content);
if (hasImages) {
try {
console.log(`Processing embedded images for story: ${createdStory.id}`);
const imageProcessUrl = `http://backend:8080/api/stories/${createdStory.id}/process-content-images`;
const imageProcessResponse = await fetch(imageProcessUrl, {
method: 'POST',
headers: {
'Authorization': authorization,
'Content-Type': 'application/json',
},
body: JSON.stringify({ htmlContent: scrapedStory.content }),
});
if (imageProcessResponse.ok) {
const imageResult = await imageProcessResponse.json();
if (imageResult.hasWarnings && imageResult.warnings) {
imageProcessingWarnings = imageResult.warnings;
console.log(`Image processing completed with warnings for story ${createdStory.id}:`, imageResult.warnings);
} else {
console.log(`Image processing completed successfully for story ${createdStory.id}. Downloaded ${imageResult.downloadedImages?.length || 0} images.`);
}
// Update story content with processed images
if (imageResult.processedContent && imageResult.processedContent !== scrapedStory.content) {
const updateUrl = `http://backend:8080/api/stories/${createdStory.id}`;
const updateResponse = await fetch(updateUrl, {
method: 'PUT',
headers: {
'Authorization': authorization,
'Content-Type': 'application/json',
},
body: JSON.stringify({
contentHtml: imageResult.processedContent
}),
});
if (!updateResponse.ok) {
console.warn(`Failed to update story content after image processing for ${createdStory.id}`);
imageProcessingWarnings.push('Failed to update story content with processed images');
}
}
} else {
console.warn(`Image processing failed for story ${createdStory.id}:`, imageProcessResponse.status);
imageProcessingWarnings.push('Image processing failed');
}
} catch (error) {
console.error(`Error processing images for story ${createdStory.id}:`, error);
imageProcessingWarnings.push(`Image processing error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
results.push({ results.push({
url: trimmedUrl, url: trimmedUrl,
status: 'imported', status: 'imported',
@@ -356,17 +429,24 @@ async function processIndividualMode(
}); });
importedCount++; importedCount++;
console.log(`Successfully imported: ${scrapedStory.title} by ${scrapedStory.author} (ID: ${createdStory.id})`); console.log(`Successfully imported: ${scrapedStory.title} by ${scrapedStory.author} (ID: ${createdStory.id})${hasImages ? ` with ${imageProcessingWarnings.length > 0 ? 'warnings' : 'successful image processing'}` : ''}`);
// Send progress update for successful import // Send progress update for successful import
let progressMessage = `Imported "${scrapedStory.title}" by ${scrapedStory.author}`;
if (hasImages) {
progressMessage += imageProcessingWarnings.length > 0 ? ' (with image warnings)' : ' (with images)';
}
await sendProgressUpdate(sessionId, { await sendProgressUpdate(sessionId, {
type: 'progress', type: 'progress',
current: i + 1, current: i + 1,
total: urls.length, total: urls.length,
message: `Imported "${scrapedStory.title}" by ${scrapedStory.author}`, message: progressMessage,
url: trimmedUrl, url: trimmedUrl,
title: scrapedStory.title, title: scrapedStory.title,
author: scrapedStory.author author: scrapedStory.author,
hasImages: hasImages,
imageWarnings: imageProcessingWarnings
}); });
} catch (error) { } catch (error) {

View File

@@ -19,6 +19,9 @@ export async function POST(request: NextRequest) {
const scraper = new StoryScraper(); const scraper = new StoryScraper();
const story = await scraper.scrapeStory(url); const story = await scraper.scrapeStory(url);
// Check if scraped content contains embedded images
const hasImages = story.content ? /<img[^>]+src=['"'][^'"']*['"][^>]*>/i.test(story.content) : false;
// Debug logging // Debug logging
console.log('Scraped story data:', { console.log('Scraped story data:', {
url: url, url: url,
@@ -28,10 +31,15 @@ export async function POST(request: NextRequest) {
contentLength: story.content?.length || 0, contentLength: story.content?.length || 0,
contentPreview: story.content?.substring(0, 200) + '...', contentPreview: story.content?.substring(0, 200) + '...',
tags: story.tags, tags: story.tags,
coverImage: story.coverImage coverImage: story.coverImage,
hasEmbeddedImages: hasImages
}); });
return NextResponse.json(story); // Add image processing flag to response for frontend handling
return NextResponse.json({
...story,
hasEmbeddedImages: hasImages
});
} catch (error) { } catch (error) {
console.error('Story scraping error:', error); console.error('Story scraping error:', error);

View File

@@ -1,12 +1,14 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import AppLayout from '../../components/layout/AppLayout'; import AppLayout from '../../components/layout/AppLayout';
import { useTheme } from '../../lib/theme'; import TabNavigation from '../../components/ui/TabNavigation';
import AppearanceSettings from '../../components/settings/AppearanceSettings';
import ContentSettings from '../../components/settings/ContentSettings';
import SystemSettings from '../../components/settings/SystemSettings';
import Button from '../../components/ui/Button'; import Button from '../../components/ui/Button';
import { storyApi, authorApi, databaseApi } from '../../lib/api'; import { useTheme } from '../../lib/theme';
import { useLibraryLayout, LibraryLayoutType } from '../../hooks/useLibraryLayout';
import LibrarySettings from '../../components/library/LibrarySettings';
type FontFamily = 'serif' | 'sans' | 'mono'; type FontFamily = 'serif' | 'sans' | 'mono';
type FontSize = 'small' | 'medium' | 'large' | 'extra-large'; type FontSize = 'small' | 'medium' | 'large' | 'extra-large';
@@ -28,29 +30,27 @@ const defaultSettings: Settings = {
readingSpeed: 200, readingSpeed: 200,
}; };
const tabs = [
{ id: 'appearance', label: 'Appearance', icon: '🎨' },
{ id: 'content', label: 'Content', icon: '🏷️' },
{ id: 'system', label: 'System', icon: '🔧' },
];
export default function SettingsPage() { export default function SettingsPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const { layout, setLayout } = useLibraryLayout();
const [settings, setSettings] = useState<Settings>(defaultSettings); const [settings, setSettings] = useState<Settings>(defaultSettings);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [typesenseStatus, setTypesenseStatus] = useState<{ const [activeTab, setActiveTab] = useState('appearance');
stories: { loading: boolean; message: string; success?: boolean };
authors: { loading: boolean; message: string; success?: boolean }; // Initialize tab from URL parameter
}>({ useEffect(() => {
stories: { loading: false, message: '' }, const tabFromUrl = searchParams.get('tab');
authors: { loading: false, message: '' } if (tabFromUrl && tabs.some(tab => tab.id === tabFromUrl)) {
}); setActiveTab(tabFromUrl);
const [authorsSchema, setAuthorsSchema] = useState<any>(null); }
const [showSchema, setShowSchema] = useState(false); }, [searchParams]);
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 // Load settings from localStorage on mount
useEffect(() => { useEffect(() => {
@@ -68,6 +68,13 @@ export default function SettingsPage() {
} }
}, [theme]); }, [theme]);
// Update URL when tab changes
const handleTabChange = (tabId: string) => {
setActiveTab(tabId);
const newUrl = `/settings?tab=${tabId}`;
router.replace(newUrl, { scroll: false });
};
// Save settings to localStorage // Save settings to localStorage
const saveSettings = () => { const saveSettings = () => {
localStorage.setItem('storycove-settings', JSON.stringify(settings)); localStorage.setItem('storycove-settings', JSON.stringify(settings));
@@ -109,697 +116,58 @@ export default function SettingsPage() {
setSettings(prev => ({ ...prev, [key]: value })); setSettings(prev => ({ ...prev, [key]: value }));
}; };
const handleTypesenseOperation = async ( const resetToDefaults = () => {
type: 'stories' | 'authors', setSettings({ ...defaultSettings, theme });
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 () => { const renderTabContent = () => {
try { switch (activeTab) {
const result = await authorApi.getTypesenseSchema(); case 'appearance':
if (result.success) { return (
setAuthorsSchema(result.schema); <AppearanceSettings
} else { settings={settings}
setAuthorsSchema({ error: result.error }); onSettingChange={updateSetting}
} />
} 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!'
); );
case 'content':
if (!confirmed) return; return <ContentSettings />;
case 'system':
setDatabaseStatus(prev => ({ return <SystemSettings />;
...prev, default:
completeRestore: { loading: true, message: 'Restoring complete backup...', success: undefined } return <AppearanceSettings settings={settings} onSettingChange={updateSetting} />;
}));
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 ( return (
<AppLayout> <AppLayout>
<div className="max-w-2xl mx-auto space-y-8"> <div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div> <div>
<h1 className="text-3xl font-bold theme-header">Settings</h1> <h1 className="text-3xl font-bold theme-header">Settings</h1>
<p className="theme-text mt-2"> <p className="theme-text mt-2">
Customize your StoryCove reading experience Customize your StoryCove experience and manage system settings
</p> </p>
</div> </div>
<div className="space-y-6"> {/* Tab Navigation */}
{/* Theme Settings */} <TabNavigation
<div className="theme-card theme-shadow rounded-lg p-6"> tabs={tabs}
<h2 className="text-xl font-semibold theme-header mb-4">Appearance</h2> activeTab={activeTab}
onTabChange={handleTabChange}
<div className="space-y-4"> className="mb-6"
<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>
{/* Library Layout */}
<div>
<label className="block text-sm font-medium theme-header mb-2">
Library Layout
</label>
<div className="space-y-3">
<div className="flex gap-4 flex-wrap">
<button
onClick={() => setLayout('sidebar')}
className={`px-4 py-2 rounded-lg border transition-colors ${
layout === 'sidebar'
? 'theme-accent-bg text-white border-transparent'
: 'theme-card theme-text theme-border hover:border-gray-400'
}`}
>
📋 Sidebar Layout
</button>
<button
onClick={() => setLayout('toolbar')}
className={`px-4 py-2 rounded-lg border transition-colors ${
layout === 'toolbar'
? 'theme-accent-bg text-white border-transparent'
: 'theme-card theme-text theme-border hover:border-gray-400'
}`}
>
🛠 Toolbar Layout
</button>
<button
onClick={() => setLayout('minimal')}
className={`px-4 py-2 rounded-lg border transition-colors ${
layout === 'minimal'
? 'theme-accent-bg text-white border-transparent'
: 'theme-card theme-text theme-border hover:border-gray-400'
}`}
>
Minimal Layout
</button>
</div>
<div className="text-sm theme-text">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mt-3">
<div className="text-xs">
<strong>Sidebar:</strong> Filters and controls in a side panel, maximum space for stories
</div>
<div className="text-xs">
<strong>Toolbar:</strong> Everything visible at once with integrated search and tag filters
</div>
<div className="text-xs">
<strong>Minimal:</strong> Clean, content-focused design with floating controls
</div>
</div>
</div>
</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> {/* Tab Content */}
<div className="text-xs theme-text">WPM</div> <div className="min-h-[400px]">
</div> {renderTabContent()}
</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> </div>
{/* Preview */} {/* Save Actions - Only show for Appearance tab */}
<div className="theme-card theme-shadow rounded-lg p-6"> {activeTab === 'appearance' && (
<h2 className="text-xl font-semibold theme-header mb-4">Preview</h2> <div className="flex justify-end gap-4 pt-6 border-t theme-border">
<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>
{/* 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>
<p className="theme-text mb-6">
Manage your story tags with colors, descriptions, and aliases. Use the Tag Maintenance page to organize and customize your tags.
</p>
<Button
href="/settings/tag-maintenance"
variant="secondary"
className="w-full sm:w-auto"
>
🏷️ Open Tag Maintenance
</Button>
</div>
{/* Actions */}
<div className="flex justify-end gap-4">
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={resetToDefaults}
setSettings({ ...defaultSettings, theme });
}}
> >
Reset to Defaults Reset to Defaults
</Button> </Button>
@@ -811,7 +179,7 @@ export default function SettingsPage() {
{saved ? '✓ Saved!' : 'Save Settings'} {saved ? '✓ Saved!' : 'Save Settings'}
</Button> </Button>
</div> </div>
</div> )}
</div> </div>
</AppLayout> </AppLayout>
); );

View File

@@ -20,6 +20,7 @@ export default function StoryReadingPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [readingProgress, setReadingProgress] = useState(0); const [readingProgress, setReadingProgress] = useState(0);
const [readingPercentage, setReadingPercentage] = useState(0);
const [sanitizedContent, setSanitizedContent] = useState<string>(''); const [sanitizedContent, setSanitizedContent] = useState<string>('');
const [hasScrolledToPosition, setHasScrolledToPosition] = useState(false); const [hasScrolledToPosition, setHasScrolledToPosition] = useState(false);
const [showToc, setShowToc] = useState(false); const [showToc, setShowToc] = useState(false);
@@ -52,6 +53,16 @@ export default function StoryReadingPage() {
return Math.floor(scrollRatio * textLength); return Math.floor(scrollRatio * textLength);
}, [story]); }, [story]);
// Calculate reading percentage from character position
const calculateReadingPercentage = useCallback((currentPosition: number): number => {
if (!story) return 0;
const totalLength = story.contentPlain?.length || story.contentHtml.length;
if (totalLength === 0) return 0;
return Math.round((currentPosition / totalLength) * 100);
}, [story]);
// Convert character position back to scroll position for auto-scroll // Convert character position back to scroll position for auto-scroll
const scrollToCharacterPosition = useCallback((position: number) => { const scrollToCharacterPosition = useCallback((position: number) => {
if (!contentRef.current || !story || hasScrolledToPosition) return; if (!contentRef.current || !story || hasScrolledToPosition) return;
@@ -188,17 +199,20 @@ export default function StoryReadingPage() {
// Otherwise, use saved reading position // Otherwise, use saved reading position
if (story.readingPosition && story.readingPosition > 0) { if (story.readingPosition && story.readingPosition > 0) {
console.log('Auto-scrolling to saved position:', story.readingPosition); console.log('Auto-scrolling to saved position:', story.readingPosition);
const initialPercentage = calculateReadingPercentage(story.readingPosition);
setReadingPercentage(initialPercentage);
scrollToCharacterPosition(story.readingPosition); scrollToCharacterPosition(story.readingPosition);
} else { } else {
// Even if there's no saved position, mark as ready for tracking // Even if there's no saved position, mark as ready for tracking
console.log('No saved position, starting fresh tracking'); console.log('No saved position, starting fresh tracking');
setReadingPercentage(0);
setHasScrolledToPosition(true); setHasScrolledToPosition(true);
} }
}, 500); }, 500);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
} }
}, [story, sanitizedContent, scrollToCharacterPosition, hasScrolledToPosition]); }, [story, sanitizedContent, scrollToCharacterPosition, calculateReadingPercentage, hasScrolledToPosition]);
// Track reading progress and save position // Track reading progress and save position
useEffect(() => { useEffect(() => {
@@ -244,10 +258,12 @@ export default function StoryReadingPage() {
setShowEndOfStoryPopup(true); setShowEndOfStoryPopup(true);
} }
// Save reading position (debounced) // Save reading position and update percentage (debounced)
if (hasScrolledToPosition) { // Only save after initial auto-scroll if (hasScrolledToPosition) { // Only save after initial auto-scroll
const characterPosition = getCharacterPositionFromScroll(); const characterPosition = getCharacterPositionFromScroll();
console.log('Scroll detected, character position:', characterPosition); const percentage = calculateReadingPercentage(characterPosition);
console.log('Scroll detected, character position:', characterPosition, 'percentage:', percentage);
setReadingPercentage(percentage);
debouncedSavePosition(characterPosition); debouncedSavePosition(characterPosition);
} else { } else {
console.log('Scroll detected but not ready for tracking yet'); console.log('Scroll detected but not ready for tracking yet');
@@ -263,7 +279,7 @@ export default function StoryReadingPage() {
clearTimeout(saveTimeoutRef.current); clearTimeout(saveTimeoutRef.current);
} }
}; };
}, [story, hasScrolledToPosition, getCharacterPositionFromScroll, debouncedSavePosition, hasReachedEnd]); }, [story, hasScrolledToPosition, getCharacterPositionFromScroll, calculateReadingPercentage, debouncedSavePosition, hasReachedEnd]);
const handleRatingUpdate = async (newRating: number) => { const handleRatingUpdate = async (newRating: number) => {
if (!story) return; if (!story) return;
@@ -359,6 +375,11 @@ export default function StoryReadingPage() {
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Reading percentage indicator */}
<div className="text-sm theme-text font-mono bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">
{readingPercentage}%
</div>
{hasHeadings && ( {hasHeadings && (
<button <button
onClick={() => setShowToc(!showToc)} onClick={() => setShowToc(!showToc)}

View File

@@ -20,6 +20,7 @@ export default function CollectionReadingView({
}: CollectionReadingViewProps) { }: CollectionReadingViewProps) {
const { story, collection } = data; const { story, collection } = data;
const [hasScrolledToPosition, setHasScrolledToPosition] = useState(false); const [hasScrolledToPosition, setHasScrolledToPosition] = useState(false);
const [readingPercentage, setReadingPercentage] = useState(0);
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null); const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -43,6 +44,16 @@ export default function CollectionReadingView({
return Math.floor(scrollRatio * textLength); return Math.floor(scrollRatio * textLength);
}, [story]); }, [story]);
// Calculate reading percentage from character position
const calculateReadingPercentage = useCallback((currentPosition: number): number => {
if (!story) return 0;
const totalLength = story.contentPlain?.length || story.contentHtml.length;
if (totalLength === 0) return 0;
return Math.round((currentPosition / totalLength) * 100);
}, [story]);
// Convert character position back to scroll position for auto-scroll // Convert character position back to scroll position for auto-scroll
const scrollToCharacterPosition = useCallback((position: number) => { const scrollToCharacterPosition = useCallback((position: number) => {
if (!contentRef.current || !story || hasScrolledToPosition) return; if (!contentRef.current || !story || hasScrolledToPosition) return;
@@ -102,23 +113,28 @@ export default function CollectionReadingView({
console.log('Collection view - initializing reading position tracking, saved position:', story.readingPosition); console.log('Collection view - initializing reading position tracking, saved position:', story.readingPosition);
if (story.readingPosition && story.readingPosition > 0) { if (story.readingPosition && story.readingPosition > 0) {
console.log('Collection view - auto-scrolling to saved position:', story.readingPosition); console.log('Collection view - auto-scrolling to saved position:', story.readingPosition);
const initialPercentage = calculateReadingPercentage(story.readingPosition);
setReadingPercentage(initialPercentage);
scrollToCharacterPosition(story.readingPosition); scrollToCharacterPosition(story.readingPosition);
} else { } else {
console.log('Collection view - no saved position, starting fresh tracking'); console.log('Collection view - no saved position, starting fresh tracking');
setReadingPercentage(0);
setHasScrolledToPosition(true); setHasScrolledToPosition(true);
} }
}, 500); }, 500);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
} }
}, [story, scrollToCharacterPosition, hasScrolledToPosition]); }, [story, scrollToCharacterPosition, calculateReadingPercentage, hasScrolledToPosition]);
// Track reading progress and save position // Track reading progress and save position
useEffect(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {
if (hasScrolledToPosition) { if (hasScrolledToPosition) {
const characterPosition = getCharacterPositionFromScroll(); const characterPosition = getCharacterPositionFromScroll();
console.log('Collection view - scroll detected, character position:', characterPosition); const percentage = calculateReadingPercentage(characterPosition);
console.log('Collection view - scroll detected, character position:', characterPosition, 'percentage:', percentage);
setReadingPercentage(percentage);
debouncedSavePosition(characterPosition); debouncedSavePosition(characterPosition);
} else { } else {
console.log('Collection view - scroll detected but not ready for tracking yet'); console.log('Collection view - scroll detected but not ready for tracking yet');
@@ -132,7 +148,7 @@ export default function CollectionReadingView({
clearTimeout(saveTimeoutRef.current); clearTimeout(saveTimeoutRef.current);
} }
}; };
}, [hasScrolledToPosition, getCharacterPositionFromScroll, debouncedSavePosition]); }, [hasScrolledToPosition, getCharacterPositionFromScroll, calculateReadingPercentage, debouncedSavePosition]);
const handlePrevious = () => { const handlePrevious = () => {
if (collection.previousStoryId) { if (collection.previousStoryId) {
@@ -190,6 +206,11 @@ export default function CollectionReadingView({
{/* Progress Bar */} {/* Progress Bar */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Reading percentage indicator */}
<div className="text-sm text-blue-700 dark:text-blue-300 font-mono bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded">
{readingPercentage}%
</div>
<div className="w-32 bg-blue-200 dark:bg-blue-800 rounded-full h-2"> <div className="w-32 bg-blue-200 dark:bg-blue-800 rounded-full h-2">
<div <div
className="bg-blue-600 dark:bg-blue-400 h-2 rounded-full transition-all duration-300" className="bg-blue-600 dark:bg-blue-400 h-2 rounded-full transition-all duration-300"

View File

@@ -0,0 +1,265 @@
'use client';
import { useTheme } from '../../lib/theme';
import { useLibraryLayout, LibraryLayoutType } from '../../hooks/useLibraryLayout';
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
}
interface AppearanceSettingsProps {
settings: Settings;
onSettingChange: <K extends keyof Settings>(key: K, value: Settings[K]) => void;
}
export default function AppearanceSettings({
settings,
onSettingChange
}: AppearanceSettingsProps) {
const { layout, setLayout } = useLibraryLayout();
return (
<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">Theme</h2>
<div>
<label className="block text-sm font-medium theme-header mb-2">
Color Theme
</label>
<div className="flex gap-4">
<button
onClick={() => onSettingChange('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={() => onSettingChange('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>
{/* Library Layout */}
<div className="theme-card theme-shadow rounded-lg p-6">
<h2 className="text-xl font-semibold theme-header mb-4">Library Layout</h2>
<div className="space-y-3">
<div className="flex gap-4 flex-wrap">
<button
onClick={() => setLayout('sidebar')}
className={`px-4 py-2 rounded-lg border transition-colors ${
layout === 'sidebar'
? 'theme-accent-bg text-white border-transparent'
: 'theme-card theme-text theme-border hover:border-gray-400'
}`}
>
📋 Sidebar Layout
</button>
<button
onClick={() => setLayout('toolbar')}
className={`px-4 py-2 rounded-lg border transition-colors ${
layout === 'toolbar'
? 'theme-accent-bg text-white border-transparent'
: 'theme-card theme-text theme-border hover:border-gray-400'
}`}
>
🛠 Toolbar Layout
</button>
<button
onClick={() => setLayout('minimal')}
className={`px-4 py-2 rounded-lg border transition-colors ${
layout === 'minimal'
? 'theme-accent-bg text-white border-transparent'
: 'theme-card theme-text theme-border hover:border-gray-400'
}`}
>
Minimal Layout
</button>
</div>
<div className="text-sm theme-text">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mt-3">
<div className="text-xs">
<strong>Sidebar:</strong> Filters and controls in a side panel, maximum space for stories
</div>
<div className="text-xs">
<strong>Toolbar:</strong> Everything visible at once with integrated search and tag filters
</div>
<div className="text-xs">
<strong>Minimal:</strong> Clean, content-focused design with floating controls
</div>
</div>
</div>
</div>
</div>
{/* Reading Experience */}
<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={() => onSettingChange('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={() => onSettingChange('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={() => onSettingChange('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={() => onSettingChange('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={() => onSettingChange('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) => onSettingChange('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>
</div>
);
}

View File

@@ -0,0 +1,32 @@
'use client';
import Button from '../ui/Button';
import LibrarySettings from '../library/LibrarySettings';
interface ContentSettingsProps {
// No props needed - LibrarySettings manages its own state
}
export default function ContentSettings({}: ContentSettingsProps) {
return (
<div className="space-y-6">
{/* 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>
<p className="theme-text mb-6">
Manage your story tags with colors, descriptions, and aliases. Use the Tag Maintenance page to organize and customize your tags.
</p>
<Button
href="/settings/tag-maintenance"
variant="secondary"
className="w-full sm:w-auto"
>
🏷 Open Tag Maintenance
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,702 @@
'use client';
import { useState } from 'react';
import Button from '../ui/Button';
import { storyApi, authorApi, databaseApi, configApi } from '../../lib/api';
interface SystemSettingsProps {
// No props needed - this component manages its own state
}
export default function SystemSettings({}: SystemSettingsProps) {
const [typesenseStatus, setTypesenseStatus] = useState<{
reindex: { loading: boolean; message: string; success?: boolean };
recreate: { loading: boolean; message: string; success?: boolean };
}>({
reindex: { loading: false, message: '' },
recreate: { loading: false, message: '' }
});
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: '' }
});
const [cleanupStatus, setCleanupStatus] = useState<{
preview: { loading: boolean; message: string; success?: boolean; data?: any };
execute: { loading: boolean; message: string; success?: boolean };
}>({
preview: { loading: false, message: '' },
execute: { loading: false, message: '' }
});
const handleFullReindex = async () => {
setTypesenseStatus(prev => ({
...prev,
reindex: { loading: true, message: 'Reindexing all collections...', success: undefined }
}));
try {
// Run both story and author reindex in parallel
const [storiesResult, authorsResult] = await Promise.all([
storyApi.reindexTypesense(),
authorApi.reindexTypesense()
]);
const allSuccessful = storiesResult.success && authorsResult.success;
const messages: string[] = [];
if (storiesResult.success) {
messages.push(`Stories: ${storiesResult.message}`);
} else {
messages.push(`Stories failed: ${storiesResult.error || 'Unknown error'}`);
}
if (authorsResult.success) {
messages.push(`Authors: ${authorsResult.message}`);
} else {
messages.push(`Authors failed: ${authorsResult.error || 'Unknown error'}`);
}
setTypesenseStatus(prev => ({
...prev,
reindex: {
loading: false,
message: allSuccessful
? `Full reindex completed successfully. ${messages.join(', ')}`
: `Reindex completed with errors. ${messages.join(', ')}`,
success: allSuccessful
}
}));
// Clear message after 8 seconds (longer for combined operation)
setTimeout(() => {
setTypesenseStatus(prev => ({
...prev,
reindex: { loading: false, message: '', success: undefined }
}));
}, 8000);
} catch (error) {
setTypesenseStatus(prev => ({
...prev,
reindex: {
loading: false,
message: 'Network error occurred during reindex',
success: false
}
}));
setTimeout(() => {
setTypesenseStatus(prev => ({
...prev,
reindex: { loading: false, message: '', success: undefined }
}));
}, 8000);
}
};
const handleRecreateAllCollections = async () => {
setTypesenseStatus(prev => ({
...prev,
recreate: { loading: true, message: 'Recreating all collections...', success: undefined }
}));
try {
// Run both story and author recreation in parallel
const [storiesResult, authorsResult] = await Promise.all([
storyApi.recreateTypesenseCollection(),
authorApi.recreateTypesenseCollection()
]);
const allSuccessful = storiesResult.success && authorsResult.success;
const messages: string[] = [];
if (storiesResult.success) {
messages.push(`Stories: ${storiesResult.message}`);
} else {
messages.push(`Stories failed: ${storiesResult.error || 'Unknown error'}`);
}
if (authorsResult.success) {
messages.push(`Authors: ${authorsResult.message}`);
} else {
messages.push(`Authors failed: ${authorsResult.error || 'Unknown error'}`);
}
setTypesenseStatus(prev => ({
...prev,
recreate: {
loading: false,
message: allSuccessful
? `All collections recreated successfully. ${messages.join(', ')}`
: `Recreation completed with errors. ${messages.join(', ')}`,
success: allSuccessful
}
}));
// Clear message after 8 seconds (longer for combined operation)
setTimeout(() => {
setTypesenseStatus(prev => ({
...prev,
recreate: { loading: false, message: '', success: undefined }
}));
}, 8000);
} catch (error) {
setTypesenseStatus(prev => ({
...prev,
recreate: {
loading: false,
message: 'Network error occurred during recreation',
success: false
}
}));
setTimeout(() => {
setTypesenseStatus(prev => ({
...prev,
recreate: { loading: false, message: '', success: undefined }
}));
}, 8000);
}
};
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);
};
const handleImageCleanupPreview = async () => {
setCleanupStatus(prev => ({
...prev,
preview: { loading: true, message: 'Scanning for orphaned images...', success: undefined }
}));
try {
const result = await configApi.previewImageCleanup();
if (result.success) {
setCleanupStatus(prev => ({
...prev,
preview: {
loading: false,
message: `Found ${result.orphanedCount} orphaned images (${result.formattedSize}) and ${result.foldersToDelete} empty folders. Referenced images: ${result.referencedImagesCount}`,
success: true,
data: result
}
}));
} else {
setCleanupStatus(prev => ({
...prev,
preview: {
loading: false,
message: result.error || 'Preview failed',
success: false
}
}));
}
} catch (error: any) {
setCleanupStatus(prev => ({
...prev,
preview: {
loading: false,
message: error.message || 'Network error occurred',
success: false
}
}));
}
// Clear message after 10 seconds
setTimeout(() => {
setCleanupStatus(prev => ({
...prev,
preview: { loading: false, message: '', success: undefined }
}));
}, 10000);
};
const handleImageCleanupExecute = async () => {
if (!cleanupStatus.preview.data || cleanupStatus.preview.data.orphanedCount === 0) {
setCleanupStatus(prev => ({
...prev,
execute: {
loading: false,
message: 'Please run preview first to see what will be deleted',
success: false
}
}));
return;
}
const confirmed = window.confirm(
`Are you sure you want to delete ${cleanupStatus.preview.data.orphanedCount} orphaned images (${cleanupStatus.preview.data.formattedSize})? This action cannot be undone!`
);
if (!confirmed) return;
setCleanupStatus(prev => ({
...prev,
execute: { loading: true, message: 'Deleting orphaned images...', success: undefined }
}));
try {
const result = await configApi.executeImageCleanup();
if (result.success) {
setCleanupStatus(prev => ({
...prev,
execute: {
loading: false,
message: `Successfully deleted ${result.deletedCount} orphaned images (${result.formattedSize}) and ${result.foldersDeleted} empty folders`,
success: true
},
preview: { loading: false, message: '', success: undefined, data: undefined } // Clear preview after successful cleanup
}));
} else {
setCleanupStatus(prev => ({
...prev,
execute: {
loading: false,
message: result.error || 'Cleanup failed',
success: false
}
}));
}
} catch (error: any) {
setCleanupStatus(prev => ({
...prev,
execute: {
loading: false,
message: error.message || 'Network error occurred',
success: false
}
}));
}
// Clear message after 10 seconds
setTimeout(() => {
setCleanupStatus(prev => ({
...prev,
execute: { loading: false, message: '', success: undefined }
}));
}, 10000);
};
return (
<div className="space-y-6">
{/* 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 all Typesense search indexes (stories, authors, collections, etc.). Use these tools if search functionality isn't working properly.
</p>
<div className="space-y-6">
{/* Simplified Operations */}
<div className="border theme-border rounded-lg p-4">
<h3 className="text-lg font-semibold theme-header mb-3">Search Operations</h3>
<p className="text-sm theme-text mb-4">
Perform maintenance operations on all search indexes (stories, authors, collections, etc.).
</p>
<div className="flex flex-col sm:flex-row gap-3 mb-4">
<Button
onClick={handleFullReindex}
disabled={typesenseStatus.reindex.loading || typesenseStatus.recreate.loading}
loading={typesenseStatus.reindex.loading}
variant="ghost"
className="flex-1"
>
{typesenseStatus.reindex.loading ? 'Reindexing All...' : '🔄 Full Reindex'}
</Button>
<Button
onClick={handleRecreateAllCollections}
disabled={typesenseStatus.reindex.loading || typesenseStatus.recreate.loading}
loading={typesenseStatus.recreate.loading}
variant="secondary"
className="flex-1"
>
{typesenseStatus.recreate.loading ? 'Recreating All...' : '🏗 Recreate All Collections'}
</Button>
</div>
{/* Status Messages */}
{typesenseStatus.reindex.message && (
<div className={`text-sm p-3 rounded mb-3 ${
typesenseStatus.reindex.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.reindex.message}
</div>
)}
{typesenseStatus.recreate.message && (
<div className={`text-sm p-3 rounded mb-3 ${
typesenseStatus.recreate.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.recreate.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">When to use these tools:</p>
<ul className="text-xs space-y-1 ml-4">
<li>• <strong>Full Reindex:</strong> Refresh all search data while keeping existing schemas (fixes data sync issues)</li>
<li>• <strong>Recreate All Collections:</strong> Delete and rebuild all search indexes from scratch (fixes schema and structure issues)</li>
<li>• <strong>Operations run in parallel</strong> across all index types for better performance</li>
</ul>
</div>
</div>
</div>
{/* Storage Management */}
<div className="theme-card theme-shadow rounded-lg p-6">
<h2 className="text-xl font-semibold theme-header mb-4">Storage Management</h2>
<p className="theme-text mb-6">
Clean up orphaned content images that are no longer referenced in any story. This can help free up disk space.
</p>
<div className="space-y-6">
{/* Image Cleanup Section */}
<div className="border theme-border rounded-lg p-4">
<h3 className="text-lg font-semibold theme-header mb-3">🖼️ Content Images Cleanup</h3>
<p className="text-sm theme-text mb-4">
Scan for and remove orphaned content images that are no longer referenced in any story content. This includes images from deleted stories and unused downloaded images.
</p>
<div className="flex flex-col sm:flex-row gap-3 mb-3">
<Button
onClick={handleImageCleanupPreview}
disabled={cleanupStatus.preview.loading}
loading={cleanupStatus.preview.loading}
variant="ghost"
className="flex-1"
>
{cleanupStatus.preview.loading ? 'Scanning...' : 'Preview Cleanup'}
</Button>
<Button
onClick={handleImageCleanupExecute}
disabled={cleanupStatus.execute.loading || !cleanupStatus.preview.data || cleanupStatus.preview.data.orphanedCount === 0}
loading={cleanupStatus.execute.loading}
variant="secondary"
className="flex-1"
>
{cleanupStatus.execute.loading ? 'Cleaning...' : 'Execute Cleanup'}
</Button>
</div>
{/* Preview Results */}
{cleanupStatus.preview.message && (
<div className={`text-sm p-3 rounded mb-3 ${
cleanupStatus.preview.success
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
}`}>
{cleanupStatus.preview.message}
{cleanupStatus.preview.data && cleanupStatus.preview.data.hasErrors && (
<div className="mt-2 text-xs">
<details>
<summary className="cursor-pointer font-medium">View Errors ({cleanupStatus.preview.data.errors.length})</summary>
<ul className="mt-1 ml-4 space-y-1">
{cleanupStatus.preview.data.errors.map((error: string, index: number) => (
<li key={index} className="text-red-600 dark:text-red-400">• {error}</li>
))}
</ul>
</details>
</div>
)}
</div>
)}
{/* Execute Results */}
{cleanupStatus.execute.message && (
<div className={`text-sm p-3 rounded mb-3 ${
cleanupStatus.execute.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'
}`}>
{cleanupStatus.execute.message}
</div>
)}
{/* Detailed Preview Information */}
{cleanupStatus.preview.data && cleanupStatus.preview.success && (
<div className="text-sm theme-text bg-gray-50 dark:bg-gray-800 p-3 rounded border">
<div className="grid grid-cols-2 gap-3">
<div>
<span className="font-medium">Orphaned Images:</span> {cleanupStatus.preview.data.orphanedCount}
</div>
<div>
<span className="font-medium">Total Size:</span> {cleanupStatus.preview.data.formattedSize}
</div>
<div>
<span className="font-medium">Empty Folders:</span> {cleanupStatus.preview.data.foldersToDelete}
</div>
<div>
<span className="font-medium">Referenced Images:</span> {cleanupStatus.preview.data.referencedImagesCount}
</div>
</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">📝 How it works:</p>
<ul className="text-xs space-y-1 ml-4">
<li>• <strong>Preview:</strong> Scans all stories to find images no longer referenced in content</li>
<li>• <strong>Execute:</strong> Permanently deletes orphaned images and empty story directories</li>
<li>• <strong>Safe:</strong> Only removes images not found in any story content</li>
<li>• <strong>Backup recommended:</strong> Consider backing up before large cleanups</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>
</div>
);
}

View File

@@ -55,6 +55,17 @@ export default function StoryCard({
return new Date(dateString).toLocaleDateString(); return new Date(dateString).toLocaleDateString();
}; };
const calculateReadingPercentage = (story: Story): number => {
if (!story.readingPosition) return 0;
const totalLength = story.contentPlain?.length || story.contentHtml.length;
if (totalLength === 0) return 0;
return Math.round((story.readingPosition / totalLength) * 100);
};
const readingPercentage = calculateReadingPercentage(story);
if (viewMode === 'list') { if (viewMode === 'list') {
return ( return (
<div className="theme-card theme-shadow rounded-lg p-4 hover:shadow-lg transition-shadow"> <div className="theme-card theme-shadow rounded-lg p-4 hover:shadow-lg transition-shadow">
@@ -100,6 +111,11 @@ export default function StoryCard({
<div className="flex items-center gap-4 mt-2 text-sm theme-text"> <div className="flex items-center gap-4 mt-2 text-sm theme-text">
<span>{formatWordCount(story.wordCount)}</span> <span>{formatWordCount(story.wordCount)}</span>
<span>{formatDate(story.createdAt)}</span> <span>{formatDate(story.createdAt)}</span>
{readingPercentage > 0 && (
<span className="bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 px-2 py-1 rounded text-xs font-mono">
{readingPercentage}% read
</span>
)}
{story.seriesName && ( {story.seriesName && (
<span> <span>
{story.seriesName} #{story.volume} {story.seriesName} #{story.volume}
@@ -231,6 +247,11 @@ export default function StoryCard({
<div className="text-xs theme-text space-y-1"> <div className="text-xs theme-text space-y-1">
<div>{formatWordCount(story.wordCount)}</div> <div>{formatWordCount(story.wordCount)}</div>
<div>{formatDate(story.createdAt)}</div> <div>{formatDate(story.createdAt)}</div>
{readingPercentage > 0 && (
<div className="bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 px-2 py-1 rounded font-mono inline-block">
{readingPercentage}% read
</div>
)}
{story.seriesName && ( {story.seriesName && (
<div> <div>
{story.seriesName} #{story.volume} {story.seriesName} #{story.volume}

View File

@@ -0,0 +1,44 @@
'use client';
interface Tab {
id: string;
label: string;
icon: string;
}
interface TabNavigationProps {
tabs: Tab[];
activeTab: string;
onTabChange: (tabId: string) => void;
className?: string;
}
export default function TabNavigation({
tabs,
activeTab,
onTabChange,
className = ''
}: TabNavigationProps) {
return (
<div className={`border-b theme-border ${className}`}>
<nav className="-mb-px flex space-x-8">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`
whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors
${activeTab === tab.id
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent theme-text hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
}
`}
>
<span className="mr-2">{tab.icon}</span>
{tab.label}
</button>
))}
</nav>
</div>
);
}

View File

@@ -577,6 +577,38 @@ export const configApi = {
const response = await api.get('/config/html-sanitization'); const response = await api.get('/config/html-sanitization');
return response.data; return response.data;
}, },
previewImageCleanup: async (): Promise<{
success: boolean;
orphanedCount: number;
totalSizeBytes: number;
formattedSize: string;
foldersToDelete: number;
referencedImagesCount: number;
errors: string[];
hasErrors: boolean;
dryRun: boolean;
error?: string;
}> => {
const response = await api.post('/config/cleanup/images/preview');
return response.data;
},
executeImageCleanup: async (): Promise<{
success: boolean;
deletedCount: number;
totalSizeBytes: number;
formattedSize: string;
foldersDeleted: number;
referencedImagesCount: number;
errors: string[];
hasErrors: boolean;
dryRun: boolean;
error?: string;
}> => {
const response = await api.post('/config/cleanup/images/execute');
return response.data;
},
}; };
// Collection endpoints // Collection endpoints

File diff suppressed because one or more lines are too long