Compare commits
2 Commits
Advanced-F
...
feature/em
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7b516be31 | ||
|
|
c92308c24a |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
@@ -53,14 +59,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 +189,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 +238,224 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(); }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -24,6 +24,9 @@ export default function StoryReadingPage() {
|
||||
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);
|
||||
|
||||
@@ -117,18 +120,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) {
|
||||
@@ -194,13 +209,41 @@ 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);
|
||||
|
||||
// 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 (debounced)
|
||||
if (hasScrolledToPosition) { // Only save after initial auto-scroll
|
||||
const characterPosition = getCharacterPositionFromScroll();
|
||||
@@ -220,11 +263,11 @@ export default function StoryReadingPage() {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [story, hasScrolledToPosition, getCharacterPositionFromScroll, debouncedSavePosition]);
|
||||
}, [story, hasScrolledToPosition, getCharacterPositionFromScroll, 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 +276,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;
|
||||
@@ -350,6 +412,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>
|
||||
|
||||
@@ -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' : ''}`}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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