Compare commits
5 Commits
Advanced-F
...
64f97f5648
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64f97f5648 | ||
|
|
c0b3ae3b72 | ||
|
|
e5596b5a17 | ||
|
|
c7b516be31 | ||
|
|
c92308c24a |
@@ -2,6 +2,7 @@ package com.storycove.controller;
|
||||
|
||||
import com.storycove.dto.HtmlSanitizationConfigDto;
|
||||
import com.storycove.service.HtmlSanitizationService;
|
||||
import com.storycove.service.ImageService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -14,13 +15,15 @@ import java.util.Map;
|
||||
public class ConfigController {
|
||||
|
||||
private final HtmlSanitizationService htmlSanitizationService;
|
||||
|
||||
private final ImageService imageService;
|
||||
|
||||
@Value("${app.reading.speed.default:200}")
|
||||
private int defaultReadingSpeed;
|
||||
|
||||
@Autowired
|
||||
public ConfigController(HtmlSanitizationService htmlSanitizationService) {
|
||||
public ConfigController(HtmlSanitizationService htmlSanitizationService, ImageService imageService) {
|
||||
this.htmlSanitizationService = htmlSanitizationService;
|
||||
this.imageService = imageService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,4 +54,64 @@ public class ConfigController {
|
||||
public ResponseEntity<Map<String, Integer>> getReadingSpeed() {
|
||||
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()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,6 +228,38 @@ public class StoryController {
|
||||
Story story = storyService.updateReadingStatus(id, request.getIsRead());
|
||||
return ResponseEntity.ok(convertToDto(story));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/process-content-images")
|
||||
public ResponseEntity<Map<String, Object>> processContentImages(@PathVariable UUID id, @RequestBody ProcessContentImagesRequest request) {
|
||||
logger.info("Processing content images for story {}", id);
|
||||
|
||||
try {
|
||||
// Process the HTML content to download and replace image URLs
|
||||
ImageService.ContentImageProcessingResult result = imageService.processContentImages(request.getHtmlContent(), id);
|
||||
|
||||
// If there are warnings, let the client decide whether to proceed
|
||||
if (result.hasWarnings()) {
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"processedContent", result.getProcessedContent(),
|
||||
"warnings", result.getWarnings(),
|
||||
"downloadedImages", result.getDownloadedImages(),
|
||||
"hasWarnings", true
|
||||
));
|
||||
}
|
||||
|
||||
// Success - no warnings
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"processedContent", result.getProcessedContent(),
|
||||
"downloadedImages", result.getDownloadedImages(),
|
||||
"hasWarnings", false
|
||||
));
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to process content images for story {}", id, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to process content images: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/reindex")
|
||||
public ResponseEntity<String> manualReindex() {
|
||||
@@ -458,7 +490,14 @@ public class StoryController {
|
||||
story.setDescription(updateReq.getDescription());
|
||||
}
|
||||
if (updateReq.getContentHtml() != null) {
|
||||
story.setContentHtml(sanitizationService.sanitize(updateReq.getContentHtml()));
|
||||
logger.info("Content before sanitization (length: {}): {}",
|
||||
updateReq.getContentHtml().length(),
|
||||
updateReq.getContentHtml().substring(0, Math.min(500, updateReq.getContentHtml().length())));
|
||||
String sanitizedContent = sanitizationService.sanitize(updateReq.getContentHtml());
|
||||
logger.info("Content after sanitization (length: {}): {}",
|
||||
sanitizedContent.length(),
|
||||
sanitizedContent.substring(0, Math.min(500, sanitizedContent.length())));
|
||||
story.setContentHtml(sanitizedContent);
|
||||
}
|
||||
if (updateReq.getSourceUrl() != null) {
|
||||
story.setSourceUrl(updateReq.getSourceUrl());
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.storycove.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public class ProcessContentImagesRequest {
|
||||
|
||||
@NotBlank(message = "HTML content is required")
|
||||
private String htmlContent;
|
||||
|
||||
public ProcessContentImagesRequest() {}
|
||||
|
||||
public ProcessContentImagesRequest(String htmlContent) {
|
||||
this.htmlContent = htmlContent;
|
||||
}
|
||||
|
||||
public String getHtmlContent() {
|
||||
return htmlContent;
|
||||
}
|
||||
|
||||
public void setHtmlContent(String htmlContent) {
|
||||
this.htmlContent = htmlContent;
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,35 @@ public class EPUBImportService {
|
||||
Story story = createStoryFromEPUB(book, request);
|
||||
|
||||
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());
|
||||
response.setWordCount(savedStory.getWordCount());
|
||||
response.setTotalChapters(book.getSpine().size());
|
||||
|
||||
@@ -54,7 +54,7 @@ public class HtmlSanitizationService {
|
||||
"p", "br", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"b", "strong", "i", "em", "u", "s", "strike", "del", "ins",
|
||||
"sup", "sub", "small", "big", "mark", "pre", "code",
|
||||
"ul", "ol", "li", "dl", "dt", "dd", "a",
|
||||
"ul", "ol", "li", "dl", "dt", "dd", "a", "img",
|
||||
"table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption",
|
||||
"blockquote", "cite", "q", "hr"
|
||||
));
|
||||
@@ -65,13 +65,13 @@ public class HtmlSanitizationService {
|
||||
}
|
||||
|
||||
private void createSafelist() {
|
||||
this.allowlist = new Safelist();
|
||||
|
||||
this.allowlist = Safelist.relaxed();
|
||||
|
||||
// Add allowed tags
|
||||
if (config.getAllowedTags() != null) {
|
||||
config.getAllowedTags().forEach(allowlist::addTags);
|
||||
}
|
||||
|
||||
|
||||
// Add allowed attributes
|
||||
if (config.getAllowedAttributes() != null) {
|
||||
for (Map.Entry<String, List<String>> entry : config.getAllowedAttributes().entrySet()) {
|
||||
@@ -82,25 +82,33 @@ public class HtmlSanitizationService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Configure allowed protocols for specific attributes (e.g., href)
|
||||
|
||||
// Special handling for img tags - allow all src attributes and validate later
|
||||
allowlist.removeProtocols("img", "src", "http", "https");
|
||||
// This is the key: preserve relative URLs by not restricting them
|
||||
allowlist.preserveRelativeLinks(true);
|
||||
|
||||
// Configure allowed protocols for other attributes
|
||||
if (config.getAllowedProtocols() != null) {
|
||||
for (Map.Entry<String, Map<String, List<String>>> tagEntry : config.getAllowedProtocols().entrySet()) {
|
||||
String tag = tagEntry.getKey();
|
||||
Map<String, List<String>> attributeProtocols = tagEntry.getValue();
|
||||
|
||||
|
||||
if (attributeProtocols != null) {
|
||||
for (Map.Entry<String, List<String>> attrEntry : attributeProtocols.entrySet()) {
|
||||
String attribute = attrEntry.getKey();
|
||||
List<String> protocols = attrEntry.getValue();
|
||||
|
||||
if (protocols != null) {
|
||||
|
||||
if (protocols != null && !("img".equals(tag) && "src".equals(attribute))) {
|
||||
// Skip img src since we handled it above
|
||||
allowlist.addProtocols(tag, attribute, protocols.toArray(new String[0]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Configured Jsoup Safelist with preserveRelativeLinks=true for local image URLs");
|
||||
|
||||
// Remove specific attributes if needed (deprecated in favor of protocol control)
|
||||
if (config.getRemovedAttributes() != null) {
|
||||
@@ -133,8 +141,10 @@ public class HtmlSanitizationService {
|
||||
if (html == null || html.trim().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return Jsoup.clean(html, allowlist);
|
||||
logger.info("Content before sanitization: "+html);
|
||||
String saniztedHtml = Jsoup.clean(html, allowlist.preserveRelativeLinks(true));
|
||||
logger.info("Content after sanitization: "+saniztedHtml);
|
||||
return saniztedHtml;
|
||||
}
|
||||
|
||||
public String extractPlainText(String html) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.storycove.service;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -8,18 +10,22 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.*;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Service
|
||||
public class ImageService {
|
||||
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ImageService.class);
|
||||
|
||||
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
|
||||
"image/jpeg", "image/jpg", "image/png"
|
||||
);
|
||||
@@ -33,6 +39,9 @@ public class ImageService {
|
||||
|
||||
@Autowired
|
||||
private LibraryService libraryService;
|
||||
|
||||
@Autowired
|
||||
private StoryService storyService;
|
||||
|
||||
private String getUploadDir() {
|
||||
String libraryPath = libraryService.getCurrentImagePath();
|
||||
@@ -53,14 +62,15 @@ public class ImageService {
|
||||
|
||||
public enum ImageType {
|
||||
COVER("covers"),
|
||||
AVATAR("avatars");
|
||||
|
||||
AVATAR("avatars"),
|
||||
CONTENT("content");
|
||||
|
||||
private final String directory;
|
||||
|
||||
|
||||
ImageType(String directory) {
|
||||
this.directory = directory;
|
||||
}
|
||||
|
||||
|
||||
public String getDirectory() {
|
||||
return directory;
|
||||
}
|
||||
@@ -182,6 +192,9 @@ public class ImageService {
|
||||
maxWidth = avatarMaxSize;
|
||||
maxHeight = avatarMaxSize;
|
||||
break;
|
||||
case CONTENT:
|
||||
// Content images are not resized
|
||||
return new Dimension(originalWidth, originalHeight);
|
||||
default:
|
||||
return new Dimension(originalWidth, originalHeight);
|
||||
}
|
||||
@@ -228,4 +241,504 @@ public class ImageService {
|
||||
String extension = getFileExtension(filename);
|
||||
return ALLOWED_EXTENSIONS.contains(extension);
|
||||
}
|
||||
|
||||
// Content image processing methods
|
||||
|
||||
/**
|
||||
* Process HTML content and download all referenced images, replacing URLs with local paths
|
||||
*/
|
||||
public ContentImageProcessingResult processContentImages(String htmlContent, UUID storyId) {
|
||||
logger.info("Processing content images for story: {}, content length: {}", storyId,
|
||||
htmlContent != null ? htmlContent.length() : 0);
|
||||
|
||||
List<String> warnings = new ArrayList<>();
|
||||
List<String> downloadedImages = new ArrayList<>();
|
||||
|
||||
if (htmlContent == null || htmlContent.trim().isEmpty()) {
|
||||
logger.info("No content to process for story: {}", storyId);
|
||||
return new ContentImageProcessingResult(htmlContent, warnings, downloadedImages);
|
||||
}
|
||||
|
||||
// Find all img tags with src attributes
|
||||
Pattern imgPattern = Pattern.compile("<img[^>]+src=[\"']([^\"']+)[\"'][^>]*>", Pattern.CASE_INSENSITIVE);
|
||||
Matcher matcher = imgPattern.matcher(htmlContent);
|
||||
|
||||
int imageCount = 0;
|
||||
int externalImageCount = 0;
|
||||
|
||||
StringBuffer processedContent = new StringBuffer();
|
||||
|
||||
while (matcher.find()) {
|
||||
String fullImgTag = matcher.group(0);
|
||||
String imageUrl = matcher.group(1);
|
||||
imageCount++;
|
||||
|
||||
logger.info("Found image #{}: {} in tag: {}", imageCount, imageUrl, fullImgTag);
|
||||
|
||||
try {
|
||||
// Skip if it's already a local path or data URL
|
||||
if (imageUrl.startsWith("/") || imageUrl.startsWith("data:")) {
|
||||
logger.info("Skipping local/data URL: {}", imageUrl);
|
||||
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag));
|
||||
continue;
|
||||
}
|
||||
|
||||
externalImageCount++;
|
||||
logger.info("Processing external image #{}: {}", externalImageCount, imageUrl);
|
||||
|
||||
// Download and store the image
|
||||
String localPath = downloadImageFromUrl(imageUrl, storyId);
|
||||
downloadedImages.add(localPath);
|
||||
|
||||
// Generate local URL
|
||||
String localUrl = getLocalImageUrl(storyId, localPath);
|
||||
logger.info("Downloaded image: {} -> {}", imageUrl, localUrl);
|
||||
|
||||
// Replace the src attribute with the local path - handle both single and double quotes
|
||||
String newImgTag = fullImgTag
|
||||
.replaceFirst("src=\"" + Pattern.quote(imageUrl) + "\"", "src=\"" + localUrl + "\"")
|
||||
.replaceFirst("src='" + Pattern.quote(imageUrl) + "'", "src=\"" + localUrl + "\"");
|
||||
|
||||
// If replacement didn't work, try a more generic approach
|
||||
if (newImgTag.equals(fullImgTag)) {
|
||||
logger.warn("Standard replacement failed for image URL: {}, trying generic replacement", imageUrl);
|
||||
newImgTag = fullImgTag.replaceAll("src\\s*=\\s*[\"']?" + Pattern.quote(imageUrl) + "[\"']?", "src=\"" + localUrl + "\"");
|
||||
}
|
||||
|
||||
logger.info("Replaced img tag: {} -> {}", fullImgTag, newImgTag);
|
||||
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(newImgTag));
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to download image: {} - {}", imageUrl, e.getMessage(), e);
|
||||
warnings.add("Failed to download image: " + imageUrl + " - " + e.getMessage());
|
||||
// Keep original URL in case of failure
|
||||
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag));
|
||||
}
|
||||
}
|
||||
|
||||
matcher.appendTail(processedContent);
|
||||
|
||||
logger.info("Finished processing images for story: {}. Found {} total images, {} external. Downloaded {} images, {} warnings",
|
||||
storyId, imageCount, externalImageCount, downloadedImages.size(), warnings.size());
|
||||
|
||||
return new ContentImageProcessingResult(processedContent.toString(), warnings, downloadedImages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an image from a URL and store it locally
|
||||
*/
|
||||
private String downloadImageFromUrl(String imageUrl, UUID storyId) throws IOException {
|
||||
URL url = new URL(imageUrl);
|
||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||
|
||||
// Set a reasonable user agent to avoid blocks
|
||||
connection.setRequestProperty("User-Agent", "Mozilla/5.0 (StoryCove Image Processor)");
|
||||
connection.setConnectTimeout(30000); // 30 seconds
|
||||
connection.setReadTimeout(30000);
|
||||
|
||||
try (InputStream inputStream = connection.getInputStream()) {
|
||||
// Get content type to determine file extension
|
||||
String contentType = connection.getContentType();
|
||||
String extension = getExtensionFromContentType(contentType);
|
||||
|
||||
if (extension == null) {
|
||||
// Try to extract from URL
|
||||
extension = getExtensionFromUrl(imageUrl);
|
||||
}
|
||||
|
||||
if (extension == null || !ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) {
|
||||
throw new IllegalArgumentException("Unsupported image format: " + contentType);
|
||||
}
|
||||
|
||||
// Create directories for content images
|
||||
Path contentDir = Paths.get(getUploadDir(), ImageType.CONTENT.getDirectory(), storyId.toString());
|
||||
Files.createDirectories(contentDir);
|
||||
|
||||
// Generate unique filename
|
||||
String filename = UUID.randomUUID().toString() + "." + extension.toLowerCase();
|
||||
Path filePath = contentDir.resolve(filename);
|
||||
|
||||
// Read and validate the image
|
||||
byte[] imageData = inputStream.readAllBytes();
|
||||
ByteArrayInputStream bais = new ByteArrayInputStream(imageData);
|
||||
BufferedImage image = ImageIO.read(bais);
|
||||
|
||||
if (image == null) {
|
||||
throw new IOException("Invalid image format");
|
||||
}
|
||||
|
||||
// Save the image
|
||||
Files.write(filePath, imageData);
|
||||
|
||||
// Return relative path
|
||||
return ImageType.CONTENT.getDirectory() + "/" + storyId.toString() + "/" + filename;
|
||||
|
||||
} finally {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate local image URL for serving
|
||||
*/
|
||||
private String getLocalImageUrl(UUID storyId, String imagePath) {
|
||||
String currentLibraryId = libraryService.getCurrentLibraryId();
|
||||
if (currentLibraryId == null || currentLibraryId.trim().isEmpty()) {
|
||||
logger.warn("Current library ID is null or empty when generating local image URL for story: {}", storyId);
|
||||
return "/api/files/images/default/" + imagePath;
|
||||
}
|
||||
String localUrl = "/api/files/images/" + currentLibraryId + "/" + imagePath;
|
||||
logger.info("Generated local image URL: {} for story: {}", localUrl, storyId);
|
||||
return localUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension from content type
|
||||
*/
|
||||
private String getExtensionFromContentType(String contentType) {
|
||||
if (contentType == null) return null;
|
||||
|
||||
switch (contentType.toLowerCase()) {
|
||||
case "image/jpeg":
|
||||
case "image/jpg":
|
||||
return "jpg";
|
||||
case "image/png":
|
||||
return "png";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file extension from URL
|
||||
*/
|
||||
private String getExtensionFromUrl(String url) {
|
||||
try {
|
||||
String path = new URL(url).getPath();
|
||||
int lastDot = path.lastIndexOf('.');
|
||||
if (lastDot > 0 && lastDot < path.length() - 1) {
|
||||
return path.substring(lastDot + 1).toLowerCase();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
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
|
||||
*/
|
||||
public void deleteContentImages(UUID storyId) {
|
||||
try {
|
||||
Path contentDir = Paths.get(getUploadDir(), ImageType.CONTENT.getDirectory(), storyId.toString());
|
||||
if (Files.exists(contentDir)) {
|
||||
Files.walk(contentDir)
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.map(Path::toFile)
|
||||
.forEach(java.io.File::delete);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Log but don't throw - this is cleanup
|
||||
System.err.println("Failed to clean up content images for story " + storyId + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result class for content image processing
|
||||
*/
|
||||
public static class ContentImageProcessingResult {
|
||||
private final String processedContent;
|
||||
private final List<String> warnings;
|
||||
private final List<String> downloadedImages;
|
||||
|
||||
public ContentImageProcessingResult(String processedContent, List<String> warnings, List<String> downloadedImages) {
|
||||
this.processedContent = processedContent;
|
||||
this.warnings = warnings;
|
||||
this.downloadedImages = downloadedImages;
|
||||
}
|
||||
|
||||
public String getProcessedContent() { return processedContent; }
|
||||
public List<String> getWarnings() { return warnings; }
|
||||
public List<String> getDownloadedImages() { return downloadedImages; }
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"b", "strong", "i", "em", "u", "s", "strike", "del", "ins",
|
||||
"sup", "sub", "small", "big", "mark", "pre", "code", "kbd", "samp", "var",
|
||||
"ul", "ol", "li", "dl", "dt", "dd",
|
||||
"a", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col",
|
||||
"a", "img", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col",
|
||||
"blockquote", "cite", "q", "hr", "details", "summary"
|
||||
],
|
||||
"allowedAttributes": {
|
||||
@@ -18,6 +18,7 @@
|
||||
"h5": ["class", "style"],
|
||||
"h6": ["class", "style"],
|
||||
"a": ["class", "href", "title"],
|
||||
"img": ["src", "alt", "width", "height", "class", "style"],
|
||||
"table": ["class", "style"],
|
||||
"th": ["class", "style", "colspan", "rowspan"],
|
||||
"td": ["class", "style", "colspan", "rowspan"],
|
||||
@@ -41,6 +42,9 @@
|
||||
"allowedProtocols": {
|
||||
"a": {
|
||||
"href": ["http", "https", "#", "/"]
|
||||
},
|
||||
"img": {
|
||||
"src": ["http", "https", "data", "/", "cid"]
|
||||
}
|
||||
},
|
||||
"description": "HTML sanitization configuration for StoryCove story content. This configuration is shared between frontend (DOMPurify) and backend (Jsoup) to ensure consistency."
|
||||
|
||||
@@ -52,6 +52,8 @@ services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
# No port mapping - only accessible within the Docker network
|
||||
#ports:
|
||||
# - "5432:5432"
|
||||
environment:
|
||||
- POSTGRES_DB=storycove
|
||||
- POSTGRES_USER=storycove
|
||||
|
||||
@@ -29,6 +29,7 @@ export default function AddStoryPage() {
|
||||
|
||||
const [coverImage, setCoverImage] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [processingImages, setProcessingImages] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [duplicateWarning, setDuplicateWarning] = useState<{
|
||||
show: boolean;
|
||||
@@ -250,9 +251,28 @@ export default function AddStoryPage() {
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// Helper function to detect external images in HTML content
|
||||
const hasExternalImages = (htmlContent: string): boolean => {
|
||||
if (!htmlContent) return false;
|
||||
|
||||
// Create a temporary DOM element to parse HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = htmlContent;
|
||||
|
||||
const images = tempDiv.querySelectorAll('img');
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const img = images[i];
|
||||
const src = img.getAttribute('src');
|
||||
if (src && (src.startsWith('http://') || src.startsWith('https://'))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
@@ -275,7 +295,43 @@ export default function AddStoryPage() {
|
||||
};
|
||||
|
||||
const story = await storyApi.createStory(storyData);
|
||||
|
||||
|
||||
// Process images if there are external images in the content
|
||||
if (hasExternalImages(formData.contentHtml)) {
|
||||
try {
|
||||
setProcessingImages(true);
|
||||
const imageResult = await storyApi.processContentImages(story.id, formData.contentHtml);
|
||||
|
||||
// If images were processed and content was updated, save the updated content
|
||||
if (imageResult.processedContent !== formData.contentHtml) {
|
||||
await storyApi.updateStory(story.id, {
|
||||
title: formData.title,
|
||||
summary: formData.summary || undefined,
|
||||
contentHtml: imageResult.processedContent,
|
||||
sourceUrl: formData.sourceUrl || undefined,
|
||||
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
|
||||
...(formData.seriesId ? { seriesId: formData.seriesId } : { seriesName: formData.seriesName || undefined }),
|
||||
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
|
||||
tagNames: formData.tags.length > 0 ? formData.tags : undefined,
|
||||
});
|
||||
|
||||
// Show success message with image processing info
|
||||
if (imageResult.downloadedImages.length > 0) {
|
||||
console.log(`Successfully processed ${imageResult.downloadedImages.length} images`);
|
||||
}
|
||||
if (imageResult.warnings && imageResult.warnings.length > 0) {
|
||||
console.warn('Image processing warnings:', imageResult.warnings);
|
||||
}
|
||||
}
|
||||
} catch (imageError) {
|
||||
console.error('Failed to process images:', imageError);
|
||||
// Don't fail the entire operation if image processing fails
|
||||
// The story was created successfully, just without processed images
|
||||
} finally {
|
||||
setProcessingImages(false);
|
||||
}
|
||||
}
|
||||
|
||||
// If there's a cover image, upload it separately
|
||||
if (coverImage) {
|
||||
await storyApi.uploadCover(story.id, coverImage);
|
||||
@@ -404,7 +460,11 @@ export default function AddStoryPage() {
|
||||
onChange={handleContentChange}
|
||||
placeholder="Write or paste your story content here..."
|
||||
error={errors.contentHtml}
|
||||
enableImageProcessing={false}
|
||||
/>
|
||||
<p className="text-sm theme-text mt-2">
|
||||
💡 <strong>Tip:</strong> If you paste content with images, they'll be automatically downloaded and stored locally when you save the story.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
@@ -450,6 +510,18 @@ export default function AddStoryPage() {
|
||||
placeholder="https://example.com/original-story-url"
|
||||
/>
|
||||
|
||||
{/* Image Processing Indicator */}
|
||||
{processingImages && (
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
|
||||
<p className="text-blue-800 dark:text-blue-200">
|
||||
Processing and downloading images...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Error */}
|
||||
{errors.submit && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
@@ -473,7 +545,7 @@ export default function AddStoryPage() {
|
||||
loading={loading}
|
||||
disabled={!formData.title || !formData.authorName || !formData.contentHtml}
|
||||
>
|
||||
Add Story
|
||||
{processingImages ? 'Processing Images...' : 'Add Story'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -134,6 +134,27 @@
|
||||
@apply italic;
|
||||
}
|
||||
|
||||
/* Image styling for story content */
|
||||
.reading-content img {
|
||||
@apply max-w-full h-auto mx-auto my-6 rounded-lg shadow-sm;
|
||||
max-height: 80vh; /* Prevent images from being too tall */
|
||||
display: block;
|
||||
}
|
||||
|
||||
.reading-content img[align="left"] {
|
||||
@apply float-left mr-4 mb-4 ml-0;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.reading-content img[align="right"] {
|
||||
@apply float-right ml-4 mb-4 mr-0;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.reading-content img[align="center"] {
|
||||
@apply block mx-auto;
|
||||
}
|
||||
|
||||
/* Editor content styling - same as reading content but for the rich text editor */
|
||||
.editor-content h1 {
|
||||
@apply text-2xl font-bold mt-8 mb-4 theme-header;
|
||||
@@ -183,4 +204,36 @@
|
||||
.editor-content em {
|
||||
@apply italic;
|
||||
}
|
||||
|
||||
/* Image styling for editor content */
|
||||
.editor-content img {
|
||||
@apply max-w-full h-auto mx-auto my-4 rounded border;
|
||||
max-height: 60vh; /* Slightly smaller for editor */
|
||||
display: block;
|
||||
}
|
||||
|
||||
.editor-content img[align="left"] {
|
||||
@apply float-left mr-4 mb-4 ml-0;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.editor-content img[align="right"] {
|
||||
@apply float-right ml-4 mb-4 mr-0;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.editor-content img[align="center"] {
|
||||
@apply block mx-auto;
|
||||
}
|
||||
|
||||
/* Loading placeholder for images being processed */
|
||||
.image-processing-placeholder {
|
||||
@apply bg-gray-100 dark:bg-gray-800 animate-pulse rounded border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.image-processing-placeholder::before {
|
||||
content: "🖼️ Processing image...";
|
||||
@apply text-gray-500 dark:text-gray-400 text-sm;
|
||||
}
|
||||
}
|
||||
@@ -188,29 +188,47 @@ async function processCombinedMode(
|
||||
// Check content size to prevent response size issues
|
||||
const combinedContentString = combinedContent.join('\n');
|
||||
const contentSizeInMB = new Blob([combinedContentString]).size / (1024 * 1024);
|
||||
|
||||
|
||||
console.log(`Combined content size: ${contentSizeInMB.toFixed(2)} MB`);
|
||||
console.log(`Combined content character length: ${combinedContentString.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
|
||||
const combinedStory = {
|
||||
title: baseTitle,
|
||||
author: baseAuthor,
|
||||
content: contentSizeInMB > 10 ?
|
||||
combinedContentString.substring(0, Math.floor(combinedContentString.length * (10 / contentSizeInMB))) + '\n\n<!-- Content truncated due to size limit -->' :
|
||||
combinedContentString,
|
||||
summary: contentSizeInMB > 10 ? baseSummary + ' (Content truncated due to size limit)' : baseSummary,
|
||||
content: finalContent,
|
||||
summary: finalSummary,
|
||||
sourceUrl: baseSourceUrl,
|
||||
tags: Array.from(combinedTags)
|
||||
tags: Array.from(combinedTags),
|
||||
hasImages: hasImages
|
||||
};
|
||||
|
||||
// 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, {
|
||||
type: 'completed',
|
||||
current: urls.length,
|
||||
total: urls.length,
|
||||
message: `Combined scraping completed: ${totalWordCount.toLocaleString()} words from ${importedCount} stories`,
|
||||
message: completionMessage,
|
||||
totalWordCount: totalWordCount,
|
||||
combinedStory: combinedStory
|
||||
});
|
||||
@@ -346,7 +364,62 @@ async function processIndividualMode(
|
||||
}
|
||||
|
||||
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({
|
||||
url: trimmedUrl,
|
||||
status: 'imported',
|
||||
@@ -356,17 +429,24 @@ async function processIndividualMode(
|
||||
});
|
||||
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
|
||||
let progressMessage = `Imported "${scrapedStory.title}" by ${scrapedStory.author}`;
|
||||
if (hasImages) {
|
||||
progressMessage += imageProcessingWarnings.length > 0 ? ' (with image warnings)' : ' (with images)';
|
||||
}
|
||||
|
||||
await sendProgressUpdate(sessionId, {
|
||||
type: 'progress',
|
||||
current: i + 1,
|
||||
total: urls.length,
|
||||
message: `Imported "${scrapedStory.title}" by ${scrapedStory.author}`,
|
||||
message: progressMessage,
|
||||
url: trimmedUrl,
|
||||
title: scrapedStory.title,
|
||||
author: scrapedStory.author
|
||||
author: scrapedStory.author,
|
||||
hasImages: hasImages,
|
||||
imageWarnings: imageProcessingWarnings
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -19,6 +19,9 @@ export async function POST(request: NextRequest) {
|
||||
const scraper = new StoryScraper();
|
||||
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
|
||||
console.log('Scraped story data:', {
|
||||
url: url,
|
||||
@@ -28,10 +31,15 @@ export async function POST(request: NextRequest) {
|
||||
contentLength: story.content?.length || 0,
|
||||
contentPreview: story.content?.substring(0, 200) + '...',
|
||||
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) {
|
||||
console.error('Story scraping error:', error);
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
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 { storyApi, authorApi, databaseApi } from '../../lib/api';
|
||||
import { useLibraryLayout, LibraryLayoutType } from '../../hooks/useLibraryLayout';
|
||||
import LibrarySettings from '../../components/library/LibrarySettings';
|
||||
import { useTheme } from '../../lib/theme';
|
||||
|
||||
type FontFamily = 'serif' | 'sans' | 'mono';
|
||||
type FontSize = 'small' | 'medium' | 'large' | 'extra-large';
|
||||
@@ -28,29 +30,27 @@ const defaultSettings: Settings = {
|
||||
readingSpeed: 200,
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'appearance', label: 'Appearance', icon: '🎨' },
|
||||
{ id: 'content', label: 'Content', icon: '🏷️' },
|
||||
{ id: 'system', label: 'System', icon: '🔧' },
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { layout, setLayout } = useLibraryLayout();
|
||||
const [settings, setSettings] = useState<Settings>(defaultSettings);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [typesenseStatus, setTypesenseStatus] = useState<{
|
||||
stories: { loading: boolean; message: string; success?: boolean };
|
||||
authors: { loading: boolean; message: string; success?: boolean };
|
||||
}>({
|
||||
stories: { loading: false, message: '' },
|
||||
authors: { loading: false, message: '' }
|
||||
});
|
||||
const [authorsSchema, setAuthorsSchema] = useState<any>(null);
|
||||
const [showSchema, setShowSchema] = useState(false);
|
||||
const [databaseStatus, setDatabaseStatus] = useState<{
|
||||
completeBackup: { loading: boolean; message: string; success?: boolean };
|
||||
completeRestore: { loading: boolean; message: string; success?: boolean };
|
||||
completeClear: { loading: boolean; message: string; success?: boolean };
|
||||
}>({
|
||||
completeBackup: { loading: false, message: '' },
|
||||
completeRestore: { loading: false, message: '' },
|
||||
completeClear: { loading: false, message: '' }
|
||||
});
|
||||
const [activeTab, setActiveTab] = useState('appearance');
|
||||
|
||||
// Initialize tab from URL parameter
|
||||
useEffect(() => {
|
||||
const tabFromUrl = searchParams.get('tab');
|
||||
if (tabFromUrl && tabs.some(tab => tab.id === tabFromUrl)) {
|
||||
setActiveTab(tabFromUrl);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Load settings from localStorage on mount
|
||||
useEffect(() => {
|
||||
@@ -68,39 +68,46 @@ export default function SettingsPage() {
|
||||
}
|
||||
}, [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
|
||||
const saveSettings = () => {
|
||||
localStorage.setItem('storycove-settings', JSON.stringify(settings));
|
||||
|
||||
|
||||
// Apply theme change
|
||||
setTheme(settings.theme);
|
||||
|
||||
|
||||
// Apply font settings to CSS custom properties
|
||||
const root = document.documentElement;
|
||||
|
||||
|
||||
const fontFamilyMap = {
|
||||
serif: 'Georgia, Times, serif',
|
||||
sans: 'Inter, system-ui, sans-serif',
|
||||
mono: 'Monaco, Consolas, monospace',
|
||||
};
|
||||
|
||||
|
||||
const fontSizeMap = {
|
||||
small: '14px',
|
||||
medium: '16px',
|
||||
large: '18px',
|
||||
'extra-large': '20px',
|
||||
};
|
||||
|
||||
|
||||
const readingWidthMap = {
|
||||
narrow: '600px',
|
||||
medium: '800px',
|
||||
wide: '1000px',
|
||||
};
|
||||
|
||||
|
||||
root.style.setProperty('--reading-font-family', fontFamilyMap[settings.fontFamily]);
|
||||
root.style.setProperty('--reading-font-size', fontSizeMap[settings.fontSize]);
|
||||
root.style.setProperty('--reading-max-width', readingWidthMap[settings.readingWidth]);
|
||||
|
||||
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
};
|
||||
@@ -109,701 +116,62 @@ export default function SettingsPage() {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleTypesenseOperation = async (
|
||||
type: 'stories' | 'authors',
|
||||
operation: 'reindex' | 'recreate',
|
||||
apiCall: () => Promise<{ success: boolean; message: string; count?: number; error?: string }>
|
||||
) => {
|
||||
setTypesenseStatus(prev => ({
|
||||
...prev,
|
||||
[type]: { loading: true, message: 'Processing...', success: undefined }
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await apiCall();
|
||||
setTypesenseStatus(prev => ({
|
||||
...prev,
|
||||
[type]: {
|
||||
loading: false,
|
||||
message: result.success ? result.message : result.error || 'Operation failed',
|
||||
success: result.success
|
||||
}
|
||||
}));
|
||||
|
||||
// Clear message after 5 seconds
|
||||
setTimeout(() => {
|
||||
setTypesenseStatus(prev => ({
|
||||
...prev,
|
||||
[type]: { loading: false, message: '', success: undefined }
|
||||
}));
|
||||
}, 5000);
|
||||
} catch (error) {
|
||||
setTypesenseStatus(prev => ({
|
||||
...prev,
|
||||
[type]: {
|
||||
loading: false,
|
||||
message: 'Network error occurred',
|
||||
success: false
|
||||
}
|
||||
}));
|
||||
|
||||
setTimeout(() => {
|
||||
setTypesenseStatus(prev => ({
|
||||
...prev,
|
||||
[type]: { loading: false, message: '', success: undefined }
|
||||
}));
|
||||
}, 5000);
|
||||
}
|
||||
const resetToDefaults = () => {
|
||||
setSettings({ ...defaultSettings, theme });
|
||||
};
|
||||
|
||||
const fetchAuthorsSchema = async () => {
|
||||
try {
|
||||
const result = await authorApi.getTypesenseSchema();
|
||||
if (result.success) {
|
||||
setAuthorsSchema(result.schema);
|
||||
} else {
|
||||
setAuthorsSchema({ error: result.error });
|
||||
}
|
||||
} catch (error) {
|
||||
setAuthorsSchema({ error: 'Failed to fetch schema' });
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'appearance':
|
||||
return (
|
||||
<AppearanceSettings
|
||||
settings={settings}
|
||||
onSettingChange={updateSetting}
|
||||
/>
|
||||
);
|
||||
case 'content':
|
||||
return <ContentSettings />;
|
||||
case 'system':
|
||||
return <SystemSettings />;
|
||||
default:
|
||||
return <AppearanceSettings settings={settings} onSettingChange={updateSetting} />;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleCompleteBackup = async () => {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeBackup: { loading: true, message: 'Creating complete backup...', success: undefined }
|
||||
}));
|
||||
|
||||
try {
|
||||
const backupBlob = await databaseApi.backupComplete();
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(backupBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
link.download = `storycove_complete_backup_${timestamp}.zip`;
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeBackup: { loading: false, message: 'Complete backup downloaded successfully', success: true }
|
||||
}));
|
||||
} catch (error: any) {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeBackup: { loading: false, message: error.message || 'Complete backup failed', success: false }
|
||||
}));
|
||||
}
|
||||
|
||||
// Clear message after 5 seconds
|
||||
setTimeout(() => {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeBackup: { loading: false, message: '', success: undefined }
|
||||
}));
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const handleCompleteRestore = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Reset the input so the same file can be selected again
|
||||
event.target.value = '';
|
||||
|
||||
if (!file.name.endsWith('.zip')) {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeRestore: { loading: false, message: 'Please select a .zip file', success: false }
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to restore the complete backup? This will PERMANENTLY DELETE all current data AND files (cover images, avatars) and replace them with the backup data. This action cannot be undone!'
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeRestore: { loading: true, message: 'Restoring complete backup...', success: undefined }
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await databaseApi.restoreComplete(file);
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeRestore: {
|
||||
loading: false,
|
||||
message: result.success ? result.message : result.message,
|
||||
success: result.success
|
||||
}
|
||||
}));
|
||||
} catch (error: any) {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeRestore: { loading: false, message: error.message || 'Complete restore failed', success: false }
|
||||
}));
|
||||
}
|
||||
|
||||
// Clear message after 10 seconds for restore (longer because it's important)
|
||||
setTimeout(() => {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeRestore: { loading: false, message: '', success: undefined }
|
||||
}));
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
const handleCompleteClear = async () => {
|
||||
const confirmed = window.confirm(
|
||||
'Are you ABSOLUTELY SURE you want to clear the entire database AND all files? This will PERMANENTLY DELETE ALL stories, authors, series, tags, collections, AND all uploaded images (covers, avatars). This action cannot be undone!'
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
const doubleConfirmed = window.confirm(
|
||||
'This is your final warning! Clicking OK will DELETE EVERYTHING in your StoryCove database AND all uploaded files. Are you completely certain you want to proceed?'
|
||||
);
|
||||
|
||||
if (!doubleConfirmed) return;
|
||||
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeClear: { loading: true, message: 'Clearing database and files...', success: undefined }
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await databaseApi.clearComplete();
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeClear: {
|
||||
loading: false,
|
||||
message: result.success
|
||||
? `Database and files cleared successfully. Deleted ${result.deletedRecords} records.`
|
||||
: result.message,
|
||||
success: result.success
|
||||
}
|
||||
}));
|
||||
} catch (error: any) {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeClear: { loading: false, message: error.message || 'Clear operation failed', success: false }
|
||||
}));
|
||||
}
|
||||
|
||||
// Clear message after 10 seconds for clear (longer because it's important)
|
||||
setTimeout(() => {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeClear: { loading: false, message: '', success: undefined }
|
||||
}));
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="max-w-2xl mx-auto space-y-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold theme-header">Settings</h1>
|
||||
<p className="theme-text mt-2">
|
||||
Customize your StoryCove reading experience
|
||||
Customize your StoryCove experience and manage system settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Theme Settings */}
|
||||
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold theme-header mb-4">Appearance</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Theme
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => updateSetting('theme', 'light')}
|
||||
className={`px-4 py-2 rounded-lg border transition-colors ${
|
||||
settings.theme === 'light'
|
||||
? 'theme-accent-bg text-white border-transparent'
|
||||
: 'theme-card theme-text theme-border hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
☀️ Light
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateSetting('theme', 'dark')}
|
||||
className={`px-4 py-2 rounded-lg border transition-colors ${
|
||||
settings.theme === 'dark'
|
||||
? 'theme-accent-bg text-white border-transparent'
|
||||
: 'theme-card theme-text theme-border hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
🌙 Dark
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tab Navigation */}
|
||||
<TabNavigation
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
{/* Tab Content */}
|
||||
<div className="min-h-[400px]">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
|
||||
{/* Reading Settings */}
|
||||
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold theme-header mb-4">Reading Experience</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Font Family */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Font Family
|
||||
</label>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<button
|
||||
onClick={() => updateSetting('fontFamily', 'serif')}
|
||||
className={`px-4 py-2 rounded-lg border transition-colors font-serif ${
|
||||
settings.fontFamily === 'serif'
|
||||
? 'theme-accent-bg text-white border-transparent'
|
||||
: 'theme-card theme-text theme-border hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
Serif
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateSetting('fontFamily', 'sans')}
|
||||
className={`px-4 py-2 rounded-lg border transition-colors font-sans ${
|
||||
settings.fontFamily === 'sans'
|
||||
? 'theme-accent-bg text-white border-transparent'
|
||||
: 'theme-card theme-text theme-border hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
Sans Serif
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateSetting('fontFamily', 'mono')}
|
||||
className={`px-4 py-2 rounded-lg border transition-colors font-mono ${
|
||||
settings.fontFamily === 'mono'
|
||||
? 'theme-accent-bg text-white border-transparent'
|
||||
: 'theme-card theme-text theme-border hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
Monospace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font Size */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Font Size
|
||||
</label>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{(['small', 'medium', 'large', 'extra-large'] as FontSize[]).map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => updateSetting('fontSize', size)}
|
||||
className={`px-4 py-2 rounded-lg border transition-colors capitalize ${
|
||||
settings.fontSize === size
|
||||
? 'theme-accent-bg text-white border-transparent'
|
||||
: 'theme-card theme-text theme-border hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
{size.replace('-', ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reading Width */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Reading Width
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
{(['narrow', 'medium', 'wide'] as ReadingWidth[]).map((width) => (
|
||||
<button
|
||||
key={width}
|
||||
onClick={() => updateSetting('readingWidth', width)}
|
||||
className={`px-4 py-2 rounded-lg border transition-colors capitalize ${
|
||||
settings.readingWidth === width
|
||||
? 'theme-accent-bg text-white border-transparent'
|
||||
: 'theme-card theme-text theme-border hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
{width}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reading Speed */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium theme-header mb-2">
|
||||
Reading Speed (words per minute)
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min="100"
|
||||
max="400"
|
||||
step="25"
|
||||
value={settings.readingSpeed}
|
||||
onChange={(e) => updateSetting('readingSpeed', parseInt(e.target.value))}
|
||||
className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
|
||||
/>
|
||||
<div className="min-w-[80px] text-center">
|
||||
<span className="text-lg font-medium theme-header">{settings.readingSpeed}</span>
|
||||
<div className="text-xs theme-text">WPM</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs theme-text mt-1">
|
||||
<span>Slow (100)</span>
|
||||
<span>Average (200)</span>
|
||||
<span>Fast (400)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold theme-header mb-4">Preview</h2>
|
||||
|
||||
<div
|
||||
className="p-4 theme-card border theme-border rounded-lg"
|
||||
style={{
|
||||
fontFamily: settings.fontFamily === 'serif' ? 'Georgia, Times, serif'
|
||||
: settings.fontFamily === 'sans' ? 'Inter, system-ui, sans-serif'
|
||||
: 'Monaco, Consolas, monospace',
|
||||
fontSize: settings.fontSize === 'small' ? '14px'
|
||||
: settings.fontSize === 'medium' ? '16px'
|
||||
: settings.fontSize === 'large' ? '18px'
|
||||
: '20px',
|
||||
maxWidth: settings.readingWidth === 'narrow' ? '600px'
|
||||
: settings.readingWidth === 'medium' ? '800px'
|
||||
: '1000px',
|
||||
}}
|
||||
>
|
||||
<h3 className="text-xl font-bold theme-header mb-2">Sample Story Title</h3>
|
||||
<p className="theme-text mb-4">by Sample Author</p>
|
||||
<p className="theme-text leading-relaxed">
|
||||
This is how your story text will look with the current settings.
|
||||
The quick brown fox jumps over the lazy dog. Lorem ipsum dolor sit amet,
|
||||
consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore
|
||||
et dolore magna aliqua.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Typesense Search Management */}
|
||||
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold theme-header mb-4">Search Index Management</h2>
|
||||
<p className="theme-text mb-6">
|
||||
Manage the Typesense search indexes for stories and authors. Use these tools if search functionality isn't working properly.
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Stories Section */}
|
||||
<div className="border theme-border rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold theme-header mb-3">Stories Index</h3>
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-3">
|
||||
<Button
|
||||
onClick={() => handleTypesenseOperation('stories', 'reindex', storyApi.reindexTypesense)}
|
||||
disabled={typesenseStatus.stories.loading}
|
||||
loading={typesenseStatus.stories.loading}
|
||||
variant="ghost"
|
||||
className="flex-1"
|
||||
>
|
||||
{typesenseStatus.stories.loading ? 'Reindexing...' : 'Reindex Stories'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleTypesenseOperation('stories', 'recreate', storyApi.recreateTypesenseCollection)}
|
||||
disabled={typesenseStatus.stories.loading}
|
||||
loading={typesenseStatus.stories.loading}
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
>
|
||||
{typesenseStatus.stories.loading ? 'Recreating...' : 'Recreate Collection'}
|
||||
</Button>
|
||||
</div>
|
||||
{typesenseStatus.stories.message && (
|
||||
<div className={`text-sm p-2 rounded ${
|
||||
typesenseStatus.stories.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{typesenseStatus.stories.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Authors Section */}
|
||||
<div className="border theme-border rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold theme-header mb-3">Authors Index</h3>
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-3">
|
||||
<Button
|
||||
onClick={() => handleTypesenseOperation('authors', 'reindex', authorApi.reindexTypesense)}
|
||||
disabled={typesenseStatus.authors.loading}
|
||||
loading={typesenseStatus.authors.loading}
|
||||
variant="ghost"
|
||||
className="flex-1"
|
||||
>
|
||||
{typesenseStatus.authors.loading ? 'Reindexing...' : 'Reindex Authors'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleTypesenseOperation('authors', 'recreate', authorApi.recreateTypesenseCollection)}
|
||||
disabled={typesenseStatus.authors.loading}
|
||||
loading={typesenseStatus.authors.loading}
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
>
|
||||
{typesenseStatus.authors.loading ? 'Recreating...' : 'Recreate Collection'}
|
||||
</Button>
|
||||
</div>
|
||||
{typesenseStatus.authors.message && (
|
||||
<div className={`text-sm p-2 rounded ${
|
||||
typesenseStatus.authors.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{typesenseStatus.authors.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug Schema Section */}
|
||||
<div className="border-t theme-border pt-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Button
|
||||
onClick={fetchAuthorsSchema}
|
||||
variant="ghost"
|
||||
className="text-xs"
|
||||
>
|
||||
Inspect Schema
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowSchema(!showSchema)}
|
||||
variant="ghost"
|
||||
className="text-xs"
|
||||
disabled={!authorsSchema}
|
||||
>
|
||||
{showSchema ? 'Hide' : 'Show'} Schema
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showSchema && authorsSchema && (
|
||||
<div className="text-xs theme-text bg-gray-50 dark:bg-gray-800 p-3 rounded border overflow-auto max-h-48">
|
||||
<pre>{JSON.stringify(authorsSchema, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm theme-text bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
|
||||
<p className="font-medium mb-1">When to use these tools:</p>
|
||||
<ul className="text-xs space-y-1 ml-4">
|
||||
<li>• <strong>Reindex:</strong> Refresh search data while keeping the existing schema</li>
|
||||
<li>• <strong>Recreate Collection:</strong> Delete and rebuild the entire search index (fixes schema issues)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database Management */}
|
||||
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold theme-header mb-4">Database Management</h2>
|
||||
<p className="theme-text mb-6">
|
||||
Backup, restore, or clear your StoryCove database and files. These comprehensive operations include both your data and uploaded images.
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Complete Backup Section */}
|
||||
<div className="border theme-border rounded-lg p-4 border-blue-200 dark:border-blue-800">
|
||||
<h3 className="text-lg font-semibold theme-header mb-3">📦 Create Backup</h3>
|
||||
<p className="text-sm theme-text mb-3">
|
||||
Download a complete backup as a ZIP file. This includes your database AND all uploaded files (cover images, avatars). This is a comprehensive backup of your entire StoryCove installation.
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleCompleteBackup}
|
||||
disabled={databaseStatus.completeBackup.loading}
|
||||
loading={databaseStatus.completeBackup.loading}
|
||||
variant="primary"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{databaseStatus.completeBackup.loading ? 'Creating Backup...' : 'Download Backup'}
|
||||
</Button>
|
||||
{databaseStatus.completeBackup.message && (
|
||||
<div className={`text-sm p-2 rounded mt-3 ${
|
||||
databaseStatus.completeBackup.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{databaseStatus.completeBackup.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restore Section */}
|
||||
<div className="border theme-border rounded-lg p-4 border-orange-200 dark:border-orange-800">
|
||||
<h3 className="text-lg font-semibold theme-header mb-3">📥 Restore Backup</h3>
|
||||
<p className="text-sm theme-text mb-3">
|
||||
<strong className="text-orange-600 dark:text-orange-400">⚠️ Warning:</strong> This will completely replace your current database AND all files with the backup. All existing data and uploaded files will be permanently deleted.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleCompleteRestore}
|
||||
disabled={databaseStatus.completeRestore.loading}
|
||||
className="flex-1 text-sm theme-text file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:theme-accent-bg file:text-white hover:file:bg-opacity-90 file:cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
{databaseStatus.completeRestore.message && (
|
||||
<div className={`text-sm p-2 rounded mt-3 ${
|
||||
databaseStatus.completeRestore.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{databaseStatus.completeRestore.message}
|
||||
</div>
|
||||
)}
|
||||
{databaseStatus.completeRestore.loading && (
|
||||
<div className="text-sm theme-text mt-3 flex items-center gap-2">
|
||||
<div className="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
|
||||
Restoring backup...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clear Everything Section */}
|
||||
<div className="border theme-border rounded-lg p-4 border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/10">
|
||||
<h3 className="text-lg font-semibold theme-header mb-3">🗑️ Clear Everything</h3>
|
||||
<p className="text-sm theme-text mb-3">
|
||||
<strong className="text-red-600 dark:text-red-400">⚠️ Danger Zone:</strong> This will permanently delete ALL data from your database AND all uploaded files (cover images, avatars). Everything will be completely removed. This action cannot be undone!
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleCompleteClear}
|
||||
disabled={databaseStatus.completeClear.loading}
|
||||
loading={databaseStatus.completeClear.loading}
|
||||
variant="secondary"
|
||||
className="w-full sm:w-auto bg-red-700 hover:bg-red-800 text-white border-red-700"
|
||||
>
|
||||
{databaseStatus.completeClear.loading ? 'Clearing Everything...' : 'Clear Everything'}
|
||||
</Button>
|
||||
{databaseStatus.completeClear.message && (
|
||||
<div className={`text-sm p-2 rounded mt-3 ${
|
||||
databaseStatus.completeClear.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{databaseStatus.completeClear.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm theme-text bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
|
||||
<p className="font-medium mb-1">💡 Best Practices:</p>
|
||||
<ul className="text-xs space-y-1 ml-4">
|
||||
<li>• <strong>Always backup</strong> before performing restore or clear operations</li>
|
||||
<li>• <strong>Store backups safely</strong> in multiple locations for important data</li>
|
||||
<li>• <strong>Test restores</strong> in a development environment when possible</li>
|
||||
<li>• <strong>Backup files (.zip)</strong> contain both database and all uploaded files</li>
|
||||
<li>• <strong>Verify backup files</strong> are complete before relying on them</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{/* Save Actions - Only show for Appearance tab */}
|
||||
{activeTab === 'appearance' && (
|
||||
<div className="flex justify-end gap-4 pt-6 border-t theme-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSettings({ ...defaultSettings, theme });
|
||||
}}
|
||||
onClick={resetToDefaults}
|
||||
>
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
|
||||
|
||||
<Button
|
||||
onClick={saveSettings}
|
||||
className={saved ? 'bg-green-600 hover:bg-green-700' : ''}
|
||||
@@ -811,7 +179,7 @@ export default function SettingsPage() {
|
||||
{saved ? '✓ Saved!' : 'Save Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ export default function EditStoryPage() {
|
||||
const [story, setStory] = useState<Story | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [resetingPosition, setResetingPosition] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -201,6 +202,32 @@ export default function EditStoryPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetReadingPosition = async () => {
|
||||
if (!story || !confirm('Are you sure you want to reset the reading position to the beginning? This will remove your current place in the story.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setResetingPosition(true);
|
||||
await storyApi.updateReadingProgress(storyId, 0);
|
||||
setStory(prev => prev ? { ...prev, readingPosition: 0 } : null);
|
||||
// Show success feedback
|
||||
setErrors({ resetSuccess: 'Reading position reset! The story will start from the beginning next time you read it.' });
|
||||
// Clear success message after 4 seconds
|
||||
setTimeout(() => {
|
||||
setErrors(prev => {
|
||||
const { resetSuccess, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}, 4000);
|
||||
} catch (error) {
|
||||
console.error('Failed to reset reading position:', error);
|
||||
setErrors({ submit: 'Failed to reset reading position' });
|
||||
} finally {
|
||||
setResetingPosition(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!story || !confirm('Are you sure you want to delete this story? This action cannot be undone.')) {
|
||||
return;
|
||||
@@ -315,6 +342,8 @@ export default function EditStoryPage() {
|
||||
onChange={handleContentChange}
|
||||
placeholder="Edit your story content here..."
|
||||
error={errors.contentHtml}
|
||||
storyId={storyId}
|
||||
enableImageProcessing={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -374,6 +403,38 @@ export default function EditStoryPage() {
|
||||
placeholder="https://example.com/original-story-url"
|
||||
/>
|
||||
|
||||
{/* Reading Position Reset Section */}
|
||||
<div className="theme-card p-4 rounded-lg border theme-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium theme-header">Reading Position</h3>
|
||||
<p className="text-sm theme-text mt-1">
|
||||
{story?.readingPosition && story.readingPosition > 0
|
||||
? `Currently saved at position ${story.readingPosition.toLocaleString()}`
|
||||
: 'No reading position saved (story will start from the beginning)'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleResetReadingPosition}
|
||||
loading={resetingPosition}
|
||||
disabled={saving || !story?.readingPosition || story.readingPosition === 0}
|
||||
className="text-orange-600 hover:text-orange-700 dark:text-orange-400 dark:hover:text-orange-300"
|
||||
>
|
||||
Reset to Beginning
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
{errors.resetSuccess && (
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<p className="text-green-800 dark:text-green-200">{errors.resetSuccess}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Error */}
|
||||
{errors.submit && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
|
||||
@@ -20,10 +20,14 @@ export default function StoryReadingPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [readingProgress, setReadingProgress] = useState(0);
|
||||
const [readingPercentage, setReadingPercentage] = useState(0);
|
||||
const [sanitizedContent, setSanitizedContent] = useState<string>('');
|
||||
const [hasScrolledToPosition, setHasScrolledToPosition] = useState(false);
|
||||
const [showToc, setShowToc] = useState(false);
|
||||
const [hasHeadings, setHasHeadings] = useState(false);
|
||||
const [showEndOfStoryPopup, setShowEndOfStoryPopup] = useState(false);
|
||||
const [hasReachedEnd, setHasReachedEnd] = useState(false);
|
||||
const [resettingPosition, setResettingPosition] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
@@ -49,28 +53,38 @@ export default function StoryReadingPage() {
|
||||
return Math.floor(scrollRatio * textLength);
|
||||
}, [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
|
||||
const scrollToCharacterPosition = useCallback((position: number) => {
|
||||
if (!contentRef.current || !story || hasScrolledToPosition) return;
|
||||
|
||||
|
||||
const textLength = story.contentPlain?.length || story.contentHtml.length;
|
||||
if (textLength === 0 || position === 0) return;
|
||||
|
||||
|
||||
const ratio = position / textLength;
|
||||
const content = contentRef.current;
|
||||
const contentTop = content.offsetTop;
|
||||
const contentHeight = content.scrollHeight;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
|
||||
// Calculate target scroll position
|
||||
const targetScroll = contentTop + (ratio * contentHeight) - (windowHeight * 0.3);
|
||||
|
||||
|
||||
// Smooth scroll to position
|
||||
window.scrollTo({
|
||||
top: Math.max(0, targetScroll),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
|
||||
setHasScrolledToPosition(true);
|
||||
}, [story, hasScrolledToPosition]);
|
||||
|
||||
@@ -117,18 +131,30 @@ export default function StoryReadingPage() {
|
||||
|
||||
// Sanitize story content and add IDs to headings
|
||||
const sanitized = await sanitizeHtml(storyData.contentHtml || '');
|
||||
|
||||
// Parse and add IDs to headings for TOC functionality
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(sanitized, 'text/html');
|
||||
const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
|
||||
headings.forEach((heading, index) => {
|
||||
heading.id = `heading-${index}`;
|
||||
});
|
||||
|
||||
setSanitizedContent(doc.body.innerHTML);
|
||||
setHasHeadings(headings.length > 0);
|
||||
|
||||
// Add IDs to headings for TOC functionality using regex instead of DOMParser
|
||||
// This avoids potential browser-specific sanitization that might strip src attributes
|
||||
let processedContent = sanitized;
|
||||
const headingMatches = processedContent.match(/<h[1-6][^>]*>/gi);
|
||||
let headingCount = 0;
|
||||
|
||||
if (headingMatches) {
|
||||
processedContent = processedContent.replace(/<h([1-6])([^>]*)>/gi, (match, level, attrs) => {
|
||||
const headingId = `heading-${headingCount++}`;
|
||||
|
||||
// Check if id attribute already exists
|
||||
if (attrs.includes('id=')) {
|
||||
// Replace existing id
|
||||
return match.replace(/id=['"][^'"]*['"]/, `id="${headingId}"`);
|
||||
} else {
|
||||
// Add id attribute
|
||||
return `<h${level}${attrs} id="${headingId}">`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setSanitizedContent(processedContent);
|
||||
setHasHeadings(headingCount > 0);
|
||||
|
||||
// Load series stories if part of a series
|
||||
if (storyData.seriesId) {
|
||||
@@ -173,17 +199,20 @@ export default function StoryReadingPage() {
|
||||
// Otherwise, use saved reading position
|
||||
if (story.readingPosition && story.readingPosition > 0) {
|
||||
console.log('Auto-scrolling to saved position:', story.readingPosition);
|
||||
const initialPercentage = calculateReadingPercentage(story.readingPosition);
|
||||
setReadingPercentage(initialPercentage);
|
||||
scrollToCharacterPosition(story.readingPosition);
|
||||
} else {
|
||||
// Even if there's no saved position, mark as ready for tracking
|
||||
console.log('No saved position, starting fresh tracking');
|
||||
setReadingPercentage(0);
|
||||
setHasScrolledToPosition(true);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [story, sanitizedContent, scrollToCharacterPosition, hasScrolledToPosition]);
|
||||
}, [story, sanitizedContent, scrollToCharacterPosition, calculateReadingPercentage, hasScrolledToPosition]);
|
||||
|
||||
// Track reading progress and save position
|
||||
useEffect(() => {
|
||||
@@ -194,17 +223,47 @@ export default function StoryReadingPage() {
|
||||
const articleTop = article.offsetTop;
|
||||
const articleHeight = article.scrollHeight;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
const progress = Math.min(100, Math.max(0,
|
||||
|
||||
const progress = Math.min(100, Math.max(0,
|
||||
((scrolled - articleTop + windowHeight) / articleHeight) * 100
|
||||
));
|
||||
|
||||
|
||||
setReadingProgress(progress);
|
||||
|
||||
// Save reading position (debounced)
|
||||
// Multi-method end-of-story detection
|
||||
const documentHeight = document.documentElement.scrollHeight;
|
||||
const windowBottom = scrolled + windowHeight;
|
||||
const distanceFromBottom = documentHeight - windowBottom;
|
||||
|
||||
// Method 1: Distance from bottom (most reliable)
|
||||
const nearBottom = distanceFromBottom <= 200;
|
||||
|
||||
// Method 2: High progress but only as secondary check
|
||||
const highProgress = progress >= 98;
|
||||
|
||||
// Method 3: Check if story content itself is fully visible
|
||||
const storyContentElement = contentRef.current;
|
||||
let storyContentFullyVisible = false;
|
||||
if (storyContentElement) {
|
||||
const contentRect = storyContentElement.getBoundingClientRect();
|
||||
const contentBottom = scrolled + contentRect.bottom;
|
||||
const documentContentHeight = Math.max(documentHeight - 300, contentBottom); // Account for footer padding
|
||||
storyContentFullyVisible = windowBottom >= documentContentHeight;
|
||||
}
|
||||
|
||||
// Trigger end detection if user is near bottom AND (has high progress OR story content is fully visible)
|
||||
if (nearBottom && (highProgress || storyContentFullyVisible) && !hasReachedEnd && hasScrolledToPosition) {
|
||||
console.log('End of story detected:', { nearBottom, highProgress, storyContentFullyVisible, distanceFromBottom, progress });
|
||||
setHasReachedEnd(true);
|
||||
setShowEndOfStoryPopup(true);
|
||||
}
|
||||
|
||||
// Save reading position and update percentage (debounced)
|
||||
if (hasScrolledToPosition) { // Only save after initial auto-scroll
|
||||
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);
|
||||
} else {
|
||||
console.log('Scroll detected but not ready for tracking yet');
|
||||
@@ -220,11 +279,11 @@ export default function StoryReadingPage() {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [story, hasScrolledToPosition, getCharacterPositionFromScroll, debouncedSavePosition]);
|
||||
}, [story, hasScrolledToPosition, getCharacterPositionFromScroll, calculateReadingPercentage, debouncedSavePosition, hasReachedEnd]);
|
||||
|
||||
const handleRatingUpdate = async (newRating: number) => {
|
||||
if (!story) return;
|
||||
|
||||
|
||||
try {
|
||||
await storyApi.updateRating(story.id, newRating);
|
||||
setStory(prev => prev ? { ...prev, rating: newRating } : null);
|
||||
@@ -233,6 +292,25 @@ export default function StoryReadingPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetReadingPosition = async () => {
|
||||
if (!story) return;
|
||||
|
||||
try {
|
||||
setResettingPosition(true);
|
||||
await storyApi.updateReadingProgress(story.id, 0);
|
||||
setStory(prev => prev ? { ...prev, readingPosition: 0 } : null);
|
||||
setShowEndOfStoryPopup(false);
|
||||
setHasReachedEnd(false);
|
||||
|
||||
// DON'T scroll immediately - let user stay at current position
|
||||
// The reset will take effect when they next open the story
|
||||
} catch (error) {
|
||||
console.error('Failed to reset reading position:', error);
|
||||
} finally {
|
||||
setResettingPosition(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const findNextStory = (): Story | null => {
|
||||
if (!story?.seriesId || seriesStories.length <= 1) return null;
|
||||
@@ -297,6 +375,11 @@ export default function StoryReadingPage() {
|
||||
</div>
|
||||
|
||||
<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 && (
|
||||
<button
|
||||
onClick={() => setShowToc(!showToc)}
|
||||
@@ -306,12 +389,12 @@ export default function StoryReadingPage() {
|
||||
📋 TOC
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
<StoryRating
|
||||
rating={story.rating || 0}
|
||||
onRatingChange={handleRatingUpdate}
|
||||
/>
|
||||
|
||||
|
||||
<Link href={`/stories/${story.id}/edit`}>
|
||||
<Button size="sm" variant="ghost">
|
||||
Edit
|
||||
@@ -350,6 +433,47 @@ export default function StoryReadingPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* End of Story Popup */}
|
||||
{showEndOfStoryPopup && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-50"
|
||||
onClick={() => setShowEndOfStoryPopup(false)}
|
||||
/>
|
||||
|
||||
{/* Popup Modal */}
|
||||
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 max-w-md w-full mx-4">
|
||||
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold theme-header mb-3">
|
||||
🎉 Story Complete!
|
||||
</h3>
|
||||
<p className="theme-text mb-6">
|
||||
You've reached the end of "{story?.title}". Would you like to reset your reading position so the story starts from the beginning next time you open it?
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowEndOfStoryPopup(false)}
|
||||
>
|
||||
Keep Current Position
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleResetReadingPosition}
|
||||
loading={resettingPosition}
|
||||
>
|
||||
Reset for Next Time
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Story Content */}
|
||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||
<article data-reading-content>
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function CollectionReadingView({
|
||||
}: CollectionReadingViewProps) {
|
||||
const { story, collection } = data;
|
||||
const [hasScrolledToPosition, setHasScrolledToPosition] = useState(false);
|
||||
const [readingPercentage, setReadingPercentage] = useState(0);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
@@ -43,28 +44,38 @@ export default function CollectionReadingView({
|
||||
return Math.floor(scrollRatio * textLength);
|
||||
}, [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
|
||||
const scrollToCharacterPosition = useCallback((position: number) => {
|
||||
if (!contentRef.current || !story || hasScrolledToPosition) return;
|
||||
|
||||
|
||||
const textLength = story.contentPlain?.length || story.contentHtml.length;
|
||||
if (textLength === 0 || position === 0) return;
|
||||
|
||||
|
||||
const ratio = position / textLength;
|
||||
const content = contentRef.current;
|
||||
const contentTop = content.offsetTop;
|
||||
const contentHeight = content.scrollHeight;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
|
||||
// Calculate target scroll position
|
||||
const targetScroll = contentTop + (ratio * contentHeight) - (windowHeight * 0.3);
|
||||
|
||||
|
||||
// Smooth scroll to position
|
||||
window.scrollTo({
|
||||
top: Math.max(0, targetScroll),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
|
||||
setHasScrolledToPosition(true);
|
||||
}, [story, hasScrolledToPosition]);
|
||||
|
||||
@@ -102,23 +113,28 @@ export default function CollectionReadingView({
|
||||
console.log('Collection view - initializing reading position tracking, saved position:', story.readingPosition);
|
||||
if (story.readingPosition && story.readingPosition > 0) {
|
||||
console.log('Collection view - auto-scrolling to saved position:', story.readingPosition);
|
||||
const initialPercentage = calculateReadingPercentage(story.readingPosition);
|
||||
setReadingPercentage(initialPercentage);
|
||||
scrollToCharacterPosition(story.readingPosition);
|
||||
} else {
|
||||
console.log('Collection view - no saved position, starting fresh tracking');
|
||||
setReadingPercentage(0);
|
||||
setHasScrolledToPosition(true);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [story, scrollToCharacterPosition, hasScrolledToPosition]);
|
||||
}, [story, scrollToCharacterPosition, calculateReadingPercentage, hasScrolledToPosition]);
|
||||
|
||||
// Track reading progress and save position
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (hasScrolledToPosition) {
|
||||
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);
|
||||
} else {
|
||||
console.log('Collection view - scroll detected but not ready for tracking yet');
|
||||
@@ -132,7 +148,7 @@ export default function CollectionReadingView({
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [hasScrolledToPosition, getCharacterPositionFromScroll, debouncedSavePosition]);
|
||||
}, [hasScrolledToPosition, getCharacterPositionFromScroll, calculateReadingPercentage, debouncedSavePosition]);
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (collection.previousStoryId) {
|
||||
@@ -190,6 +206,11 @@ export default function CollectionReadingView({
|
||||
|
||||
{/* Progress Bar */}
|
||||
<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="bg-blue-600 dark:bg-blue-400 h-2 rounded-full transition-all duration-300"
|
||||
|
||||
@@ -237,9 +237,9 @@ export default function MinimalLayout({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 max-md:grid-cols-2">
|
||||
<div className="grid grid-cols-4 gap-2 max-md:grid-cols-2 max-sm:grid-cols-1">
|
||||
{filteredTags.length === 0 && tagSearch ? (
|
||||
<div className="col-span-4 text-center text-sm text-gray-500 py-4">
|
||||
<div className="col-span-4 max-md:col-span-2 max-sm:col-span-1 text-center text-sm text-gray-500 py-4">
|
||||
No tags match "{tagSearch}"
|
||||
</div>
|
||||
) : (
|
||||
@@ -251,9 +251,9 @@ export default function MinimalLayout({
|
||||
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : ''
|
||||
}`}
|
||||
>
|
||||
<TagDisplay
|
||||
tag={{...tag, name: `${tag.name} (${tag.storyCount})`}}
|
||||
size="sm"
|
||||
<TagDisplay
|
||||
tag={{...tag, name: `${tag.name} (${tag.storyCount})`}}
|
||||
size="sm"
|
||||
clickable={true}
|
||||
className={`w-full text-left ${selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 hover:border-blue-500'}`}
|
||||
/>
|
||||
|
||||
@@ -62,9 +62,113 @@ export default function SidebarLayout({
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Left Sidebar */}
|
||||
<div className="w-80 min-w-80 max-w-80 bg-white dark:bg-gray-800 p-4 border-r theme-border sticky top-0 h-screen overflow-y-auto overflow-x-hidden max-md:w-full max-md:min-w-full max-md:max-w-full max-md:h-auto max-md:static max-md:border-r-0 max-md:border-b max-md:max-h-96">
|
||||
<div className="flex min-h-screen max-md:flex-col">
|
||||
{/* Mobile Header - Only shown on mobile */}
|
||||
<div className="hidden max-md:block bg-white dark:bg-gray-800 p-4 border-b theme-border">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold theme-header">Your Library</h1>
|
||||
<p className="theme-text text-sm">{totalElements} stories total</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onRandomStory}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
🎲 Random
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Search */}
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search stories..."
|
||||
value={searchQuery}
|
||||
onChange={onSearchChange}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mobile Controls Row */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{/* View Toggle */}
|
||||
<div className="flex border theme-border rounded-lg overflow-hidden">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'primary' : 'ghost'}
|
||||
onClick={() => onViewModeChange('grid')}
|
||||
className="rounded-none border-0 flex-1 px-2 py-1 text-xs"
|
||||
>
|
||||
⊞
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'primary' : 'ghost'}
|
||||
onClick={() => onViewModeChange('list')}
|
||||
className="rounded-none border-0 flex-1 px-2 py-1 text-xs"
|
||||
>
|
||||
☰
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<select
|
||||
value={`${sortOption}_${sortDirection}`}
|
||||
onChange={(e) => {
|
||||
const [option, direction] = e.target.value.split('_');
|
||||
onSortChange(option);
|
||||
if (sortDirection !== direction) {
|
||||
onSortDirectionToggle();
|
||||
}
|
||||
}}
|
||||
className="px-2 py-1 border rounded-lg theme-card border-gray-300 dark:border-gray-600 text-xs"
|
||||
>
|
||||
<option value="lastRead_desc">Last Read ↓</option>
|
||||
<option value="lastRead_asc">Last Read ↑</option>
|
||||
<option value="createdAt_desc">Date Added ↓</option>
|
||||
<option value="createdAt_asc">Date Added ↑</option>
|
||||
<option value="title_asc">Title ↑</option>
|
||||
<option value="title_desc">Title ↓</option>
|
||||
<option value="authorName_asc">Author ↑</option>
|
||||
<option value="authorName_desc">Author ↓</option>
|
||||
<option value="rating_desc">Rating ↓</option>
|
||||
<option value="rating_asc">Rating ↑</option>
|
||||
</select>
|
||||
|
||||
{/* Filter Toggle */}
|
||||
<Button
|
||||
variant={showAdvancedFilters || selectedTags.length > 0 || activeAdvancedFiltersCount > 0 ? "primary" : "ghost"}
|
||||
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
||||
className="text-xs px-2 py-1"
|
||||
>
|
||||
Filters
|
||||
{(selectedTags.length + activeAdvancedFiltersCount) > 0 && (
|
||||
<span className="ml-1 bg-white text-blue-500 px-1 rounded text-xs">
|
||||
{selectedTags.length + activeAdvancedFiltersCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Tag Pills - Show selected tags */}
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{selectedTags.slice(0, 3).map((tagName) => {
|
||||
const tag = tags.find(t => t.name === tagName);
|
||||
return tag ? (
|
||||
<div key={tag.id} onClick={() => onTagToggle(tag.name)} className="cursor-pointer">
|
||||
<TagDisplay tag={tag} size="sm" clickable={true} className="bg-blue-500 text-white" />
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
{selectedTags.length > 3 && (
|
||||
<span className="text-xs text-gray-500 px-2 py-1">+{selectedTags.length - 3} more</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Left Sidebar - Hidden on mobile by default */}
|
||||
<div className="w-80 min-w-80 max-w-80 bg-white dark:bg-gray-800 p-4 border-r theme-border sticky top-0 h-screen overflow-y-auto overflow-x-hidden max-md:hidden">
|
||||
{/* Random Story Button */}
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
@@ -172,9 +276,9 @@ export default function SidebarLayout({
|
||||
onChange={() => onTagToggle(tag.name)}
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<TagDisplay
|
||||
tag={tag}
|
||||
size="sm"
|
||||
<TagDisplay
|
||||
tag={tag}
|
||||
size="sm"
|
||||
clickable={false}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
@@ -204,7 +308,7 @@ export default function SidebarLayout({
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
|
||||
|
||||
{/* Advanced Filters Toggle */}
|
||||
{onAdvancedFiltersChange && (
|
||||
<Button
|
||||
@@ -221,7 +325,7 @@ export default function SidebarLayout({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Advanced Filters Section */}
|
||||
{showAdvancedFilters && onAdvancedFiltersChange && (
|
||||
<div className="mt-4 pt-4 border-t theme-border">
|
||||
@@ -236,8 +340,95 @@ export default function SidebarLayout({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Filter Panel - Shows when filters expanded */}
|
||||
{showAdvancedFilters && (
|
||||
<div className="hidden max-md:block bg-white dark:bg-gray-800 border-b theme-border">
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="font-medium theme-header">Filters</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowAdvancedFilters(false)}
|
||||
size="sm"
|
||||
>
|
||||
✕ Close
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tag Grid */}
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-medium theme-header mb-2">Tags</h4>
|
||||
<div className="mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tags..."
|
||||
value={tagSearch}
|
||||
onChange={(e) => setTagSearch(e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm border rounded theme-card border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-32 overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<button
|
||||
onClick={() => onClearFilters()}
|
||||
className={`px-2 py-1 text-xs border rounded text-left ${
|
||||
selectedTags.length === 0 ? 'bg-blue-500 text-white border-blue-500' : 'theme-card border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
All ({totalElements})
|
||||
</button>
|
||||
{filteredTags.slice(0, 19).map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => onTagToggle(tag.name)}
|
||||
className={`px-2 py-1 text-xs border rounded text-left truncate ${
|
||||
selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : 'theme-card border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{tag.name} ({tag.storyCount})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Filters */}
|
||||
{onAdvancedFiltersChange && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium theme-header mb-2">Advanced Filters</h4>
|
||||
<AdvancedFilters
|
||||
filters={advancedFilters}
|
||||
onChange={onAdvancedFiltersChange}
|
||||
onReset={() => onAdvancedFiltersChange({})}
|
||||
className="space-y-3"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onClearFilters}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setShowAdvancedFilters(false)}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 p-4 max-md:p-4">
|
||||
<div className="flex-1 p-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function ToolbarLayout({
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<div>
|
||||
<div className="max-md:order-3">
|
||||
<select
|
||||
value={`${sortOption}_${sortDirection}`}
|
||||
onChange={(e) => {
|
||||
@@ -108,7 +108,7 @@ export default function ToolbarLayout({
|
||||
onSortDirectionToggle();
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 border rounded-lg theme-card border-gray-300 dark:border-gray-600"
|
||||
className="w-full px-3 py-2 border rounded-lg theme-card border-gray-300 dark:border-gray-600 max-md:text-sm"
|
||||
>
|
||||
<option value="lastRead_desc">Sort: Last Read ↓</option>
|
||||
<option value="lastRead_asc">Sort: Last Read ↑</option>
|
||||
@@ -123,27 +123,58 @@ export default function ToolbarLayout({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* View Toggle & Clear */}
|
||||
<div className="flex gap-2">
|
||||
{/* View Toggle, Advanced Filters & Clear */}
|
||||
<div className="flex gap-2 max-md:order-2">
|
||||
<div className="flex border theme-border rounded-lg overflow-hidden">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'primary' : 'ghost'}
|
||||
onClick={() => onViewModeChange('grid')}
|
||||
className="rounded-none border-0"
|
||||
className="rounded-none border-0 max-md:px-2 max-md:text-sm"
|
||||
>
|
||||
⊞ Grid
|
||||
<span className="max-md:hidden">⊞ Grid</span>
|
||||
<span className="md:hidden">⊞</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'primary' : 'ghost'}
|
||||
onClick={() => onViewModeChange('list')}
|
||||
className="rounded-none border-0"
|
||||
className="rounded-none border-0 max-md:px-2 max-md:text-sm"
|
||||
>
|
||||
☰ List
|
||||
<span className="max-md:hidden">☰ List</span>
|
||||
<span className="md:hidden">☰</span>
|
||||
</Button>
|
||||
</div>
|
||||
{(searchQuery || selectedTags.length > 0) && (
|
||||
<Button variant="ghost" onClick={onClearFilters}>
|
||||
Clear
|
||||
|
||||
{/* Advanced Filters Button */}
|
||||
<Button
|
||||
variant={filterExpanded && activeTab === 'advanced' ? 'primary' : 'ghost'}
|
||||
onClick={() => {
|
||||
if (!filterExpanded) {
|
||||
// Panel closed → open and switch to advanced tab
|
||||
setFilterExpanded(true);
|
||||
setActiveTab('advanced');
|
||||
} else if (activeTab !== 'advanced') {
|
||||
// Panel open but wrong tab → just switch to advanced tab
|
||||
setActiveTab('advanced');
|
||||
} else {
|
||||
// Panel open and on advanced tab → close panel
|
||||
setFilterExpanded(false);
|
||||
}
|
||||
}}
|
||||
className="max-md:text-sm max-md:px-2"
|
||||
>
|
||||
<span className="max-md:hidden">⚙️ Advanced</span>
|
||||
<span className="md:hidden">⚙️</span>
|
||||
{activeAdvancedFiltersCount > 0 && (
|
||||
<span className="ml-1 text-xs bg-blue-500 text-white px-1.5 py-0.5 rounded font-bold max-md:ml-0.5 max-md:px-1">
|
||||
{activeAdvancedFiltersCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{(searchQuery || selectedTags.length > 0 || activeAdvancedFiltersCount > 0) && (
|
||||
<Button variant="ghost" onClick={onClearFilters} className="max-md:text-sm max-md:px-2">
|
||||
<span className="max-md:hidden">Clear</span>
|
||||
<span className="md:hidden">✕</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -181,20 +212,31 @@ export default function ToolbarLayout({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Filter expand button with counts */}
|
||||
<button
|
||||
onClick={() => setFilterExpanded(!filterExpanded)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium border-2 border-dashed transition-colors ${
|
||||
filterExpanded || activeAdvancedFiltersCount > 0 || remainingTagsCount > 0
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-500 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-gray-50 dark:bg-gray-800 theme-text border-gray-300 dark:border-gray-600 hover:border-blue-500'
|
||||
}`}
|
||||
>
|
||||
{remainingTagsCount > 0 && `+${remainingTagsCount} tags`}
|
||||
{remainingTagsCount > 0 && activeAdvancedFiltersCount > 0 && ' • '}
|
||||
{activeAdvancedFiltersCount > 0 && `${activeAdvancedFiltersCount} filters`}
|
||||
{remainingTagsCount === 0 && activeAdvancedFiltersCount === 0 && 'More Filters'}
|
||||
</button>
|
||||
{/* More Tags Button */}
|
||||
{remainingTagsCount > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!filterExpanded) {
|
||||
// Panel closed → open and switch to tags tab
|
||||
setFilterExpanded(true);
|
||||
setActiveTab('tags');
|
||||
} else if (activeTab !== 'tags') {
|
||||
// Panel open but wrong tab → just switch to tags tab
|
||||
setActiveTab('tags');
|
||||
} else {
|
||||
// Panel open and on tags tab → close panel
|
||||
setFilterExpanded(false);
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium border-2 transition-colors ${
|
||||
filterExpanded && activeTab === 'tags'
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 border-blue-500 text-blue-700 dark:text-blue-300'
|
||||
: 'border-dashed bg-gray-50 dark:bg-gray-800 theme-text border-gray-300 dark:border-gray-600 hover:border-blue-500'
|
||||
}`}
|
||||
>
|
||||
+{remainingTagsCount} more tags
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="ml-auto text-sm theme-text">
|
||||
Showing {stories.length} of {totalElements} stories
|
||||
@@ -207,32 +249,36 @@ export default function ToolbarLayout({
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex gap-1 mb-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('tags')}
|
||||
onClick={() => setActiveTab('advanced')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeTab === 'tags'
|
||||
? 'bg-white dark:bg-gray-700 theme-text shadow-sm'
|
||||
: 'theme-text hover:bg-white/50 dark:hover:bg-gray-700/50'
|
||||
activeTab === 'advanced'
|
||||
? 'bg-blue-500 text-white shadow-sm'
|
||||
: 'theme-text hover:bg-blue-50 dark:hover:bg-blue-900/20 border border-blue-200 dark:border-blue-800'
|
||||
}`}
|
||||
>
|
||||
📋 Tags
|
||||
{remainingTagsCount > 0 && (
|
||||
<span className="ml-1 text-xs bg-gray-200 dark:bg-gray-600 px-1 rounded">
|
||||
{remainingTagsCount}
|
||||
⚙️ Advanced Filters
|
||||
{activeAdvancedFiltersCount > 0 && (
|
||||
<span className="ml-1 text-xs bg-white text-blue-500 px-1.5 py-0.5 rounded font-bold">
|
||||
{activeAdvancedFiltersCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('advanced')}
|
||||
onClick={() => setActiveTab('tags')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeTab === 'advanced'
|
||||
? 'bg-white dark:bg-gray-700 theme-text shadow-sm'
|
||||
: 'theme-text hover:bg-white/50 dark:hover:bg-gray-700/50'
|
||||
activeTab === 'tags'
|
||||
? 'bg-blue-500 text-white shadow-sm'
|
||||
: 'theme-text hover:bg-blue-50 dark:hover:bg-blue-900/20 border border-blue-200 dark:border-blue-800'
|
||||
}`}
|
||||
>
|
||||
⚙️ Advanced
|
||||
{activeAdvancedFiltersCount > 0 && (
|
||||
<span className="ml-1 text-xs bg-blue-500 text-white px-1 rounded">
|
||||
{activeAdvancedFiltersCount}
|
||||
📋 More Tags
|
||||
{remainingTagsCount > 0 && (
|
||||
<span className={`ml-1 text-xs px-1.5 py-0.5 rounded font-bold ${
|
||||
activeTab === 'tags'
|
||||
? 'bg-white text-blue-500'
|
||||
: 'bg-gray-200 dark:bg-gray-600'
|
||||
}`}>
|
||||
{remainingTagsCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -255,9 +301,9 @@ export default function ToolbarLayout({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 max-h-40 overflow-y-auto max-md:grid-cols-2">
|
||||
<div className="grid grid-cols-4 gap-2 max-h-40 overflow-y-auto max-md:grid-cols-2 max-sm:grid-cols-1">
|
||||
{filteredRemainingTags.length === 0 && tagSearch ? (
|
||||
<div className="col-span-4 text-center text-sm text-gray-500 py-4">
|
||||
<div className="col-span-4 max-md:col-span-2 max-sm:col-span-1 text-center text-sm text-gray-500 py-4">
|
||||
No tags match "{tagSearch}"
|
||||
</div>
|
||||
) : (
|
||||
@@ -269,9 +315,9 @@ export default function ToolbarLayout({
|
||||
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : ''
|
||||
}`}
|
||||
>
|
||||
<TagDisplay
|
||||
tag={{...tag, name: `${tag.name} (${tag.storyCount})`}}
|
||||
size="sm"
|
||||
<TagDisplay
|
||||
tag={{...tag, name: `${tag.name} (${tag.storyCount})`}}
|
||||
size="sm"
|
||||
clickable={true}
|
||||
className={`w-full ${selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : ''}`}
|
||||
/>
|
||||
|
||||
265
frontend/src/components/settings/AppearanceSettings.tsx
Normal file
265
frontend/src/components/settings/AppearanceSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
frontend/src/components/settings/ContentSettings.tsx
Normal file
32
frontend/src/components/settings/ContentSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
702
frontend/src/components/settings/SystemSettings.tsx
Normal file
702
frontend/src/components/settings/SystemSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -4,19 +4,24 @@ import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Textarea } from '../ui/Input';
|
||||
import Button from '../ui/Button';
|
||||
import { sanitizeHtmlSync } from '../../lib/sanitization';
|
||||
import { storyApi } from '../../lib/api';
|
||||
|
||||
interface RichTextEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
storyId?: string; // Optional - for image processing (undefined for new stories)
|
||||
enableImageProcessing?: boolean; // Enable background image processing
|
||||
}
|
||||
|
||||
export default function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
export default function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Write your story here...',
|
||||
error
|
||||
error,
|
||||
storyId,
|
||||
enableImageProcessing = false
|
||||
}: RichTextEditorProps) {
|
||||
const [viewMode, setViewMode] = useState<'visual' | 'html'>('visual');
|
||||
const [htmlValue, setHtmlValue] = useState(value);
|
||||
@@ -28,6 +33,12 @@ export default function RichTextEditor({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isUserTyping, setIsUserTyping] = useState(false);
|
||||
|
||||
// Image processing state
|
||||
const [imageProcessingQueue, setImageProcessingQueue] = useState<string[]>([]);
|
||||
const [processedImages, setProcessedImages] = useState<Set<string>>(new Set());
|
||||
const [imageWarnings, setImageWarnings] = useState<string[]>([]);
|
||||
const imageProcessingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Utility functions for cursor position preservation
|
||||
const saveCursorPosition = () => {
|
||||
const selection = window.getSelection();
|
||||
@@ -63,6 +74,82 @@ export default function RichTextEditor({
|
||||
}
|
||||
};
|
||||
|
||||
// Image processing functionality
|
||||
const findImageUrlsInHtml = (html: string): string[] => {
|
||||
const imgRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
|
||||
const urls: string[] = [];
|
||||
let match;
|
||||
while ((match = imgRegex.exec(html)) !== null) {
|
||||
const url = match[1];
|
||||
// Skip local URLs and data URLs
|
||||
if (!url.startsWith('/') && !url.startsWith('data:')) {
|
||||
urls.push(url);
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
};
|
||||
|
||||
const processContentImagesDebounced = useCallback(async (content: string) => {
|
||||
if (!enableImageProcessing || !storyId) return;
|
||||
|
||||
const imageUrls = findImageUrlsInHtml(content);
|
||||
if (imageUrls.length === 0) return;
|
||||
|
||||
// Find new URLs that haven't been processed yet
|
||||
const newUrls = imageUrls.filter(url => !processedImages.has(url));
|
||||
if (newUrls.length === 0) return;
|
||||
|
||||
// Add to processing queue
|
||||
setImageProcessingQueue(prev => [...prev, ...newUrls]);
|
||||
|
||||
try {
|
||||
// Call the API to process images
|
||||
const result = await storyApi.processContentImages(storyId, content);
|
||||
|
||||
// Mark URLs as processed
|
||||
setProcessedImages(prev => new Set([...Array.from(prev), ...newUrls]));
|
||||
|
||||
// Remove from processing queue
|
||||
setImageProcessingQueue(prev => prev.filter(url => !newUrls.includes(url)));
|
||||
|
||||
// Update content with processed images
|
||||
if (result.processedContent !== content) {
|
||||
onChange(result.processedContent);
|
||||
setHtmlValue(result.processedContent);
|
||||
}
|
||||
|
||||
// Handle warnings
|
||||
if (result.hasWarnings && result.warnings) {
|
||||
setImageWarnings(prev => [...prev, ...result.warnings!]);
|
||||
// Show brief warning notification - could be enhanced with a toast system
|
||||
console.warn('Image processing warnings:', result.warnings);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to process content images:', error);
|
||||
// Remove failed URLs from queue
|
||||
setImageProcessingQueue(prev => prev.filter(url => !newUrls.includes(url)));
|
||||
|
||||
// Show error message - could be enhanced with user notification
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
setImageWarnings(prev => [...prev, `Failed to process some images: ${errorMessage}`]);
|
||||
}
|
||||
}, [enableImageProcessing, storyId, processedImages, onChange]);
|
||||
|
||||
const triggerImageProcessing = useCallback((content: string) => {
|
||||
if (!enableImageProcessing || !storyId) return;
|
||||
|
||||
// Clear existing timeout
|
||||
if (imageProcessingTimeoutRef.current) {
|
||||
clearTimeout(imageProcessingTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Set new timeout to process after user stops typing
|
||||
imageProcessingTimeoutRef.current = setTimeout(() => {
|
||||
processContentImagesDebounced(content);
|
||||
}, 2000); // Wait 2 seconds after user stops typing
|
||||
}, [enableImageProcessing, storyId, processContentImagesDebounced]);
|
||||
|
||||
// Maximize/minimize functionality
|
||||
const toggleMaximize = () => {
|
||||
if (!isMaximized) {
|
||||
@@ -120,9 +207,13 @@ export default function RichTextEditor({
|
||||
|
||||
// Update the state
|
||||
setIsUserTyping(true);
|
||||
onChange(visualDiv.innerHTML);
|
||||
setHtmlValue(visualDiv.innerHTML);
|
||||
const newContent = visualDiv.innerHTML;
|
||||
onChange(newContent);
|
||||
setHtmlValue(newContent);
|
||||
setTimeout(() => setIsUserTyping(false), 100);
|
||||
|
||||
// Trigger image processing if enabled
|
||||
triggerImageProcessing(newContent);
|
||||
}
|
||||
} else {
|
||||
// HTML mode - existing logic with improvements
|
||||
@@ -244,6 +335,15 @@ export default function RichTextEditor({
|
||||
};
|
||||
}, [isMaximized, formatText]);
|
||||
|
||||
// Cleanup image processing timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (imageProcessingTimeoutRef.current) {
|
||||
clearTimeout(imageProcessingTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Set initial content when component mounts
|
||||
useEffect(() => {
|
||||
const div = visualDivRef.current;
|
||||
@@ -284,6 +384,8 @@ export default function RichTextEditor({
|
||||
if (newHtml !== value) {
|
||||
onChange(newHtml);
|
||||
setHtmlValue(newHtml);
|
||||
// Trigger image processing if enabled
|
||||
triggerImageProcessing(newHtml);
|
||||
}
|
||||
|
||||
// Reset typing state after a short delay
|
||||
@@ -357,13 +459,38 @@ export default function RichTextEditor({
|
||||
if (htmlContent && htmlContent.trim().length > 0) {
|
||||
console.log('Processing HTML content...');
|
||||
console.log('Raw HTML:', htmlContent.substring(0, 500));
|
||||
|
||||
const sanitizedHtml = sanitizeHtmlSync(htmlContent);
|
||||
|
||||
// Check if we have embedded images and image processing is enabled
|
||||
const hasImages = /<img[^>]+src=['"'][^'"']*['"][^>]*>/i.test(htmlContent);
|
||||
let processedHtml = htmlContent;
|
||||
|
||||
if (hasImages && enableImageProcessing && storyId) {
|
||||
console.log('Found images in pasted content, processing before sanitization...');
|
||||
try {
|
||||
// Process images synchronously before sanitization
|
||||
const result = await storyApi.processContentImages(storyId, htmlContent);
|
||||
processedHtml = result.processedContent;
|
||||
console.log('Image processing completed, processed content length:', processedHtml.length);
|
||||
|
||||
// Update image processing state
|
||||
if (result.downloadedImages && result.downloadedImages.length > 0) {
|
||||
setProcessedImages(prev => new Set([...Array.from(prev), ...result.downloadedImages]));
|
||||
}
|
||||
if (result.warnings && result.warnings.length > 0) {
|
||||
setImageWarnings(prev => [...prev, ...result.warnings!]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Image processing failed during paste:', error);
|
||||
// Continue with original content if image processing fails
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizedHtml = sanitizeHtmlSync(processedHtml);
|
||||
console.log('Sanitized HTML length:', sanitizedHtml.length);
|
||||
console.log('Sanitized HTML preview:', sanitizedHtml.substring(0, 500));
|
||||
|
||||
|
||||
// Check if sanitization removed too much content
|
||||
const ratio = sanitizedHtml.length / htmlContent.length;
|
||||
const ratio = sanitizedHtml.length / processedHtml.length;
|
||||
console.log('Sanitization ratio (kept/original):', ratio.toFixed(3));
|
||||
if (ratio < 0.1) {
|
||||
console.warn('Sanitization removed >90% of content - this might be too aggressive');
|
||||
@@ -404,9 +531,12 @@ export default function RichTextEditor({
|
||||
|
||||
// Update the state
|
||||
setIsUserTyping(true);
|
||||
onChange(visualDiv.innerHTML);
|
||||
setHtmlValue(visualDiv.innerHTML);
|
||||
const newContent = visualDiv.innerHTML;
|
||||
onChange(newContent);
|
||||
setHtmlValue(newContent);
|
||||
setTimeout(() => setIsUserTyping(false), 100);
|
||||
|
||||
// Note: Image processing already completed during paste, no need to trigger again
|
||||
} else if (textarea) {
|
||||
// Fallback for textarea mode (shouldn't happen in visual mode but good to have)
|
||||
const start = textarea.selectionStart;
|
||||
@@ -493,6 +623,9 @@ export default function RichTextEditor({
|
||||
const html = e.target.value;
|
||||
setHtmlValue(html);
|
||||
onChange(html);
|
||||
|
||||
// Trigger image processing if enabled
|
||||
triggerImageProcessing(html);
|
||||
};
|
||||
|
||||
const getPlainText = (html: string): string => {
|
||||
@@ -532,6 +665,24 @@ export default function RichTextEditor({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Image processing status indicator */}
|
||||
{enableImageProcessing && (
|
||||
<>
|
||||
{imageProcessingQueue.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 mr-2">
|
||||
<div className="animate-spin h-3 w-3 border-2 border-blue-600 border-t-transparent rounded-full"></div>
|
||||
<span>Processing {imageProcessingQueue.length} image{imageProcessingQueue.length > 1 ? 's' : ''}...</span>
|
||||
</div>
|
||||
)}
|
||||
{imageWarnings.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-orange-600 dark:text-orange-400 mr-2" title={imageWarnings.join('\n')}>
|
||||
<span>⚠️</span>
|
||||
<span>{imageWarnings.length} warning{imageWarnings.length > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -673,6 +824,24 @@ export default function RichTextEditor({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Image processing status indicator */}
|
||||
{enableImageProcessing && (
|
||||
<>
|
||||
{imageProcessingQueue.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 mr-2">
|
||||
<div className="animate-spin h-3 w-3 border-2 border-blue-600 border-t-transparent rounded-full"></div>
|
||||
<span>Processing {imageProcessingQueue.length} image{imageProcessingQueue.length > 1 ? 's' : ''}...</span>
|
||||
</div>
|
||||
)}
|
||||
{imageWarnings.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-orange-600 dark:text-orange-400 mr-2" title={imageWarnings.join('\n')}>
|
||||
<span>⚠️</span>
|
||||
<span>{imageWarnings.length} warning{imageWarnings.length > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
@@ -55,6 +55,17 @@ export default function StoryCard({
|
||||
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') {
|
||||
return (
|
||||
<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">
|
||||
<span>{formatWordCount(story.wordCount)}</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 && (
|
||||
<span>
|
||||
{story.seriesName} #{story.volume}
|
||||
@@ -231,6 +247,11 @@ export default function StoryCard({
|
||||
<div className="text-xs theme-text space-y-1">
|
||||
<div>{formatWordCount(story.wordCount)}</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 && (
|
||||
<div>
|
||||
{story.seriesName} #{story.volume}
|
||||
|
||||
44
frontend/src/components/ui/TabNavigation.tsx
Normal file
44
frontend/src/components/ui/TabNavigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -152,6 +152,18 @@ export const storyApi = {
|
||||
await api.delete(`/stories/${id}/cover`);
|
||||
},
|
||||
|
||||
processContentImages: async (id: string, htmlContent: string): Promise<{
|
||||
processedContent: string;
|
||||
warnings?: string[];
|
||||
downloadedImages: string[];
|
||||
hasWarnings: boolean;
|
||||
}> => {
|
||||
const response = await api.post(`/stories/${id}/process-content-images`, {
|
||||
htmlContent
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
addTag: async (storyId: string, tagId: string): Promise<Story> => {
|
||||
const response = await api.post(`/stories/${storyId}/tags/${tagId}`);
|
||||
return response.data;
|
||||
@@ -565,6 +577,38 @@ export const configApi = {
|
||||
const response = await api.get('/config/html-sanitization');
|
||||
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
|
||||
|
||||
@@ -72,7 +72,7 @@ async function fetchSanitizationConfig(): Promise<SanitizationConfig> {
|
||||
'p', 'br', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'ins',
|
||||
'sup', 'sub', 'small', 'big', 'mark', 'pre', 'code',
|
||||
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'a',
|
||||
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'a', 'img',
|
||||
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption',
|
||||
'blockquote', 'cite', 'q', 'hr'
|
||||
],
|
||||
@@ -87,6 +87,7 @@ async function fetchSanitizationConfig(): Promise<SanitizationConfig> {
|
||||
'h5': ['class', 'style'],
|
||||
'h6': ['class', 'style'],
|
||||
'a': ['class'],
|
||||
'img': ['src', 'alt', 'width', 'height', 'class', 'style'],
|
||||
'table': ['class'],
|
||||
'td': ['class', 'colspan', 'rowspan'],
|
||||
'th': ['class', 'colspan', 'rowspan']
|
||||
@@ -99,6 +100,9 @@ async function fetchSanitizationConfig(): Promise<SanitizationConfig> {
|
||||
allowedProtocols: {
|
||||
'a': {
|
||||
'href': ['http', 'https', '#', '/']
|
||||
},
|
||||
'img': {
|
||||
'src': ['http', 'https', 'data', '/']
|
||||
}
|
||||
},
|
||||
description: 'Fallback sanitization configuration'
|
||||
@@ -237,12 +241,12 @@ export function sanitizeHtmlSync(html: string): string {
|
||||
'p', 'br', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'ins',
|
||||
'sup', 'sub', 'small', 'big', 'mark', 'pre', 'code', 'kbd', 'samp', 'var',
|
||||
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'a',
|
||||
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'a', 'img',
|
||||
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col',
|
||||
'blockquote', 'cite', 'q', 'hr', 'details', 'summary'
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
'class', 'style', 'colspan', 'rowspan'
|
||||
'class', 'style', 'colspan', 'rowspan', 'src', 'alt', 'width', 'height'
|
||||
],
|
||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
SANITIZE_DOM: true,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user