Compare commits
5 Commits
feature/op
...
41ff3a9961
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41ff3a9961 | ||
|
|
0101c0ca2c | ||
| 58bb7f8229 | |||
| a5628019f8 | |||
|
|
b1dbd85346 |
@@ -19,7 +19,7 @@ JWT_SECRET=REPLACE_WITH_SECURE_JWT_SECRET_MINIMUM_32_CHARS
|
|||||||
APP_PASSWORD=REPLACE_WITH_SECURE_APP_PASSWORD
|
APP_PASSWORD=REPLACE_WITH_SECURE_APP_PASSWORD
|
||||||
|
|
||||||
# OpenSearch Configuration
|
# OpenSearch Configuration
|
||||||
OPENSEARCH_PASSWORD=REPLACE_WITH_SECURE_OPENSEARCH_PASSWORD
|
#OPENSEARCH_PASSWORD=REPLACE_WITH_SECURE_OPENSEARCH_PASSWORD
|
||||||
SEARCH_ENGINE=opensearch
|
SEARCH_ENGINE=opensearch
|
||||||
|
|
||||||
# Image Storage
|
# Image Storage
|
||||||
|
|||||||
118
PORTABLE_TEXT_SETUP.md
Normal file
118
PORTABLE_TEXT_SETUP.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Portable Text Editor Setup Instructions
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
⚠️ **Temporarily Reverted to Original Editor**
|
||||||
|
|
||||||
|
Due to npm cache permission issues preventing Docker builds, I've temporarily reverted the imports back to `RichTextEditor`. The Portable Text implementation is complete and ready to activate once the npm issue is resolved.
|
||||||
|
|
||||||
|
## Files Ready for Portable Text
|
||||||
|
|
||||||
|
- ✅ `PortableTextEditor.tsx` - Complete implementation
|
||||||
|
- ✅ `schema.ts` - Portable Text schema
|
||||||
|
- ✅ `conversion.ts` - HTML ↔ Portable Text conversion
|
||||||
|
- ✅ `package.json.with-portabletext` - Updated dependencies
|
||||||
|
|
||||||
|
## Docker Build Issue Resolution
|
||||||
|
|
||||||
|
The error `npm ci` requires `package-lock.json` but npm cache permissions prevent generating it.
|
||||||
|
|
||||||
|
### Solution Steps:
|
||||||
|
|
||||||
|
1. **Fix npm permissions:**
|
||||||
|
```bash
|
||||||
|
sudo chown -R $(whoami) ~/.npm
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Switch to Portable Text setup:**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
mv package.json package.json.original
|
||||||
|
mv package.json.with-portabletext package.json
|
||||||
|
npm install # This will generate package-lock.json
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Update component imports** (change RichTextEditor → PortableTextEditor):
|
||||||
|
```typescript
|
||||||
|
// In src/app/add-story/page.tsx and src/app/stories/[id]/edit/page.tsx
|
||||||
|
import PortableTextEditor from '../../components/stories/PortableTextEditor';
|
||||||
|
// And update the JSX to use <PortableTextEditor ... />
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Build and test:**
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
docker-compose build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Complete
|
||||||
|
|
||||||
|
✅ **Portable Text Schema** - Defines formatting options matching the original editor
|
||||||
|
✅ **HTML ↔ Portable Text Conversion** - Seamless conversion between formats
|
||||||
|
✅ **Sanitization Integration** - Uses existing sanitization strategy
|
||||||
|
✅ **Component Replacement** - PortableTextEditor replaces RichTextEditor
|
||||||
|
✅ **Image Processing** - Maintains existing image processing functionality
|
||||||
|
✅ **Toolbar** - All formatting buttons from original editor
|
||||||
|
✅ **Keyboard Shortcuts** - Ctrl+B, Ctrl+I, Ctrl+Shift+1-6
|
||||||
|
|
||||||
|
## Features Maintained
|
||||||
|
|
||||||
|
### 1. **Formatting Options**
|
||||||
|
- Bold, Italic, Underline, Strike, Code
|
||||||
|
- Headings H1-H6
|
||||||
|
- Paragraphs and Blockquotes
|
||||||
|
- All original toolbar buttons
|
||||||
|
|
||||||
|
### 2. **Visual & HTML Modes**
|
||||||
|
- Visual mode: Structured Portable Text editing
|
||||||
|
- HTML mode: Direct HTML editing (fallback)
|
||||||
|
- Live preview in HTML mode
|
||||||
|
|
||||||
|
### 3. **Image Processing**
|
||||||
|
- Existing image processing pipeline maintained
|
||||||
|
- Background image download and conversion
|
||||||
|
- Processing status indicators
|
||||||
|
- Warning system
|
||||||
|
|
||||||
|
### 4. **Paste Handling**
|
||||||
|
- Rich text paste from websites
|
||||||
|
- Image processing during paste
|
||||||
|
- HTML sanitization
|
||||||
|
- Structured content conversion
|
||||||
|
|
||||||
|
### 5. **Maximization & Resizing**
|
||||||
|
- Fullscreen editing mode
|
||||||
|
- Resizable editor height
|
||||||
|
- Keyboard shortcuts (Escape to exit)
|
||||||
|
|
||||||
|
## Benefits of Portable Text
|
||||||
|
|
||||||
|
1. **Structured Content** - Content is stored as JSON, not just HTML
|
||||||
|
2. **Future-Proof** - Easy to export/migrate content
|
||||||
|
3. **Better Search** - Structured content works better with Typesense
|
||||||
|
4. **Extensible** - Easy to add custom block types (images, etc.)
|
||||||
|
5. **Sanitization** - Inherently safer than HTML parsing
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Install the npm packages using one of the methods above
|
||||||
|
2. Test the editor functionality
|
||||||
|
3. Verify image processing works correctly
|
||||||
|
4. Optional: Add custom image block types for enhanced image handling
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── components/stories/
|
||||||
|
│ ├── PortableTextEditor.tsx # New editor component
|
||||||
|
│ └── RichTextEditor.tsx # Original (can be removed after testing)
|
||||||
|
├── lib/portabletext/
|
||||||
|
│ ├── schema.ts # Portable Text schema and types
|
||||||
|
│ └── conversion.ts # HTML ↔ Portable Text conversion
|
||||||
|
└── app/
|
||||||
|
├── add-story/page.tsx # Updated to use PortableTextEditor
|
||||||
|
└── stories/[id]/edit/page.tsx # Updated to use PortableTextEditor
|
||||||
|
```
|
||||||
|
|
||||||
|
The implementation is backward compatible and maintains all existing functionality while providing the benefits of structured content editing.
|
||||||
@@ -2,6 +2,8 @@ package com.storycove.controller;
|
|||||||
|
|
||||||
import com.storycove.service.ImageService;
|
import com.storycove.service.ImageService;
|
||||||
import com.storycove.service.LibraryService;
|
import com.storycove.service.LibraryService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.core.io.FileSystemResource;
|
import org.springframework.core.io.FileSystemResource;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
@@ -21,6 +23,7 @@ import java.util.Map;
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/files")
|
@RequestMapping("/api/files")
|
||||||
public class FileController {
|
public class FileController {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(FileController.class);
|
||||||
|
|
||||||
private final ImageService imageService;
|
private final ImageService imageService;
|
||||||
private final LibraryService libraryService;
|
private final LibraryService libraryService;
|
||||||
@@ -32,7 +35,7 @@ public class FileController {
|
|||||||
|
|
||||||
private String getCurrentLibraryId() {
|
private String getCurrentLibraryId() {
|
||||||
String libraryId = libraryService.getCurrentLibraryId();
|
String libraryId = libraryService.getCurrentLibraryId();
|
||||||
System.out.println("FileController - Current Library ID: " + libraryId);
|
log.debug("FileController - Current Library ID: {}", libraryId);
|
||||||
return libraryId != null ? libraryId : "default";
|
return libraryId != null ? libraryId : "default";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +51,7 @@ public class FileController {
|
|||||||
String imageUrl = "/api/files/images/" + currentLibraryId + "/" + imagePath;
|
String imageUrl = "/api/files/images/" + currentLibraryId + "/" + imagePath;
|
||||||
response.put("url", imageUrl);
|
response.put("url", imageUrl);
|
||||||
|
|
||||||
System.out.println("Upload response - path: " + imagePath + ", url: " + imageUrl);
|
log.debug("Upload response - path: {}, url: {}", imagePath, imageUrl);
|
||||||
|
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import nl.siegmann.epublib.epub.EpubReader;
|
|||||||
|
|
||||||
import org.jsoup.Jsoup;
|
import org.jsoup.Jsoup;
|
||||||
import org.jsoup.nodes.Document;
|
import org.jsoup.nodes.Document;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -30,6 +32,7 @@ import java.util.Optional;
|
|||||||
@Service
|
@Service
|
||||||
@Transactional
|
@Transactional
|
||||||
public class EPUBImportService {
|
public class EPUBImportService {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(EPUBImportService.class);
|
||||||
|
|
||||||
private final StoryService storyService;
|
private final StoryService storyService;
|
||||||
private final AuthorService authorService;
|
private final AuthorService authorService;
|
||||||
@@ -87,12 +90,12 @@ public class EPUBImportService {
|
|||||||
savedStory = storyService.update(savedStory.getId(), savedStory);
|
savedStory = storyService.update(savedStory.getId(), savedStory);
|
||||||
|
|
||||||
// Log the image processing results
|
// Log the image processing results
|
||||||
System.out.println("EPUB Import - Image processing completed for story " + savedStory.getId() +
|
log.debug("EPUB Import - Image processing completed for story {}. Downloaded {} images.",
|
||||||
". Downloaded " + imageResult.getDownloadedImages().size() + " images.");
|
savedStory.getId(), imageResult.getDownloadedImages().size());
|
||||||
|
|
||||||
if (imageResult.hasWarnings()) {
|
if (imageResult.hasWarnings()) {
|
||||||
System.out.println("EPUB Import - Image processing warnings: " +
|
log.debug("EPUB Import - Image processing warnings: {}",
|
||||||
String.join(", ", imageResult.getWarnings()));
|
String.join(", ", imageResult.getWarnings()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -282,7 +285,7 @@ public class EPUBImportService {
|
|||||||
if (language != null && !language.trim().isEmpty()) {
|
if (language != null && !language.trim().isEmpty()) {
|
||||||
// Store as metadata in story description if needed
|
// Store as metadata in story description if needed
|
||||||
// For now, we'll just log it for potential future use
|
// For now, we'll just log it for potential future use
|
||||||
System.out.println("EPUB Language: " + language);
|
log.debug("EPUB Language: {}", language);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract publisher information
|
// Extract publisher information
|
||||||
@@ -290,14 +293,14 @@ public class EPUBImportService {
|
|||||||
if (publishers != null && !publishers.isEmpty()) {
|
if (publishers != null && !publishers.isEmpty()) {
|
||||||
String publisher = publishers.get(0);
|
String publisher = publishers.get(0);
|
||||||
// Could append to description or store separately in future
|
// Could append to description or store separately in future
|
||||||
System.out.println("EPUB Publisher: " + publisher);
|
log.debug("EPUB Publisher: {}", publisher);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract publication date
|
// Extract publication date
|
||||||
List<nl.siegmann.epublib.domain.Date> dates = metadata.getDates();
|
List<nl.siegmann.epublib.domain.Date> dates = metadata.getDates();
|
||||||
if (dates != null && !dates.isEmpty()) {
|
if (dates != null && !dates.isEmpty()) {
|
||||||
for (nl.siegmann.epublib.domain.Date date : dates) {
|
for (nl.siegmann.epublib.domain.Date date : dates) {
|
||||||
System.out.println("EPUB Date (" + date.getEvent() + "): " + date.getValue());
|
log.debug("EPUB Date ({}): {}", date.getEvent(), date.getValue());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +308,7 @@ public class EPUBImportService {
|
|||||||
List<nl.siegmann.epublib.domain.Identifier> identifiers = metadata.getIdentifiers();
|
List<nl.siegmann.epublib.domain.Identifier> identifiers = metadata.getIdentifiers();
|
||||||
if (identifiers != null && !identifiers.isEmpty()) {
|
if (identifiers != null && !identifiers.isEmpty()) {
|
||||||
for (nl.siegmann.epublib.domain.Identifier identifier : identifiers) {
|
for (nl.siegmann.epublib.domain.Identifier identifier : identifiers) {
|
||||||
System.out.println("EPUB Identifier (" + identifier.getScheme() + "): " + identifier.getValue());
|
log.debug("EPUB Identifier ({}): {}", identifier.getScheme(), identifier.getValue());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,14 +248,14 @@ public class ImageService {
|
|||||||
* Process HTML content and download all referenced images, replacing URLs with local paths
|
* Process HTML content and download all referenced images, replacing URLs with local paths
|
||||||
*/
|
*/
|
||||||
public ContentImageProcessingResult processContentImages(String htmlContent, UUID storyId) {
|
public ContentImageProcessingResult processContentImages(String htmlContent, UUID storyId) {
|
||||||
logger.info("Processing content images for story: {}, content length: {}", storyId,
|
logger.debug("Processing content images for story: {}, content length: {}", storyId,
|
||||||
htmlContent != null ? htmlContent.length() : 0);
|
htmlContent != null ? htmlContent.length() : 0);
|
||||||
|
|
||||||
List<String> warnings = new ArrayList<>();
|
List<String> warnings = new ArrayList<>();
|
||||||
List<String> downloadedImages = new ArrayList<>();
|
List<String> downloadedImages = new ArrayList<>();
|
||||||
|
|
||||||
if (htmlContent == null || htmlContent.trim().isEmpty()) {
|
if (htmlContent == null || htmlContent.trim().isEmpty()) {
|
||||||
logger.info("No content to process for story: {}", storyId);
|
logger.debug("No content to process for story: {}", storyId);
|
||||||
return new ContentImageProcessingResult(htmlContent, warnings, downloadedImages);
|
return new ContentImageProcessingResult(htmlContent, warnings, downloadedImages);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,18 +273,18 @@ public class ImageService {
|
|||||||
String imageUrl = matcher.group(1);
|
String imageUrl = matcher.group(1);
|
||||||
imageCount++;
|
imageCount++;
|
||||||
|
|
||||||
logger.info("Found image #{}: {} in tag: {}", imageCount, imageUrl, fullImgTag);
|
logger.debug("Found image #{}: {} in tag: {}", imageCount, imageUrl, fullImgTag);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Skip if it's already a local path or data URL
|
// Skip if it's already a local path or data URL
|
||||||
if (imageUrl.startsWith("/") || imageUrl.startsWith("data:")) {
|
if (imageUrl.startsWith("/") || imageUrl.startsWith("data:")) {
|
||||||
logger.info("Skipping local/data URL: {}", imageUrl);
|
logger.debug("Skipping local/data URL: {}", imageUrl);
|
||||||
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag));
|
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
externalImageCount++;
|
externalImageCount++;
|
||||||
logger.info("Processing external image #{}: {}", externalImageCount, imageUrl);
|
logger.debug("Processing external image #{}: {}", externalImageCount, imageUrl);
|
||||||
|
|
||||||
// Download and store the image
|
// Download and store the image
|
||||||
String localPath = downloadImageFromUrl(imageUrl, storyId);
|
String localPath = downloadImageFromUrl(imageUrl, storyId);
|
||||||
@@ -292,7 +292,7 @@ public class ImageService {
|
|||||||
|
|
||||||
// Generate local URL
|
// Generate local URL
|
||||||
String localUrl = getLocalImageUrl(storyId, localPath);
|
String localUrl = getLocalImageUrl(storyId, localPath);
|
||||||
logger.info("Downloaded image: {} -> {}", imageUrl, localUrl);
|
logger.debug("Downloaded image: {} -> {}", imageUrl, localUrl);
|
||||||
|
|
||||||
// Replace the src attribute with the local path - handle both single and double quotes
|
// Replace the src attribute with the local path - handle both single and double quotes
|
||||||
String newImgTag = fullImgTag
|
String newImgTag = fullImgTag
|
||||||
@@ -305,7 +305,7 @@ public class ImageService {
|
|||||||
newImgTag = fullImgTag.replaceAll("src\\s*=\\s*[\"']?" + Pattern.quote(imageUrl) + "[\"']?", "src=\"" + localUrl + "\"");
|
newImgTag = fullImgTag.replaceAll("src\\s*=\\s*[\"']?" + Pattern.quote(imageUrl) + "[\"']?", "src=\"" + localUrl + "\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Replaced img tag: {} -> {}", fullImgTag, newImgTag);
|
logger.debug("Replaced img tag: {} -> {}", fullImgTag, newImgTag);
|
||||||
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(newImgTag));
|
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(newImgTag));
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -388,7 +388,7 @@ public class ImageService {
|
|||||||
return "/api/files/images/default/" + imagePath;
|
return "/api/files/images/default/" + imagePath;
|
||||||
}
|
}
|
||||||
String localUrl = "/api/files/images/" + currentLibraryId + "/" + imagePath;
|
String localUrl = "/api/files/images/" + currentLibraryId + "/" + imagePath;
|
||||||
logger.info("Generated local image URL: {} for story: {}", localUrl, storyId);
|
logger.debug("Generated local image URL: {} for story: {}", localUrl, storyId);
|
||||||
return localUrl;
|
return localUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,20 +437,20 @@ public class ImageService {
|
|||||||
int foldersToDelete = 0;
|
int foldersToDelete = 0;
|
||||||
|
|
||||||
// Step 1: Collect all image references from all story content
|
// Step 1: Collect all image references from all story content
|
||||||
logger.info("Scanning all story content for image references...");
|
logger.debug("Scanning all story content for image references...");
|
||||||
referencedImages = collectAllImageReferences();
|
referencedImages = collectAllImageReferences();
|
||||||
logger.info("Found {} unique image references in story content", referencedImages.size());
|
logger.debug("Found {} unique image references in story content", referencedImages.size());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 2: Scan the content images directory
|
// Step 2: Scan the content images directory
|
||||||
Path contentImagesDir = Paths.get(getUploadDir(), ImageType.CONTENT.getDirectory());
|
Path contentImagesDir = Paths.get(getUploadDir(), ImageType.CONTENT.getDirectory());
|
||||||
|
|
||||||
if (!Files.exists(contentImagesDir)) {
|
if (!Files.exists(contentImagesDir)) {
|
||||||
logger.info("Content images directory does not exist: {}", contentImagesDir);
|
logger.debug("Content images directory does not exist: {}", contentImagesDir);
|
||||||
return new ContentImageCleanupResult(orphanedImages, 0, 0, referencedImages.size(), errors, dryRun);
|
return new ContentImageCleanupResult(orphanedImages, 0, 0, referencedImages.size(), errors, dryRun);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Scanning content images directory: {}", contentImagesDir);
|
logger.debug("Scanning content images directory: {}", contentImagesDir);
|
||||||
|
|
||||||
// Walk through all story directories
|
// Walk through all story directories
|
||||||
Files.walk(contentImagesDir, 2)
|
Files.walk(contentImagesDir, 2)
|
||||||
@@ -465,7 +465,7 @@ public class ImageService {
|
|||||||
boolean storyExists = storyService.findByIdOptional(UUID.fromString(storyId)).isPresent();
|
boolean storyExists = storyService.findByIdOptional(UUID.fromString(storyId)).isPresent();
|
||||||
|
|
||||||
if (!storyExists) {
|
if (!storyExists) {
|
||||||
logger.info("Found orphaned story directory (story deleted): {}", storyId);
|
logger.debug("Found orphaned story directory (story deleted): {}", storyId);
|
||||||
// Mark entire directory for deletion
|
// Mark entire directory for deletion
|
||||||
try {
|
try {
|
||||||
Files.walk(storyDir)
|
Files.walk(storyDir)
|
||||||
@@ -535,7 +535,7 @@ public class ImageService {
|
|||||||
|
|
||||||
// Step 3: Delete orphaned files if not dry run
|
// Step 3: Delete orphaned files if not dry run
|
||||||
if (!dryRun && !orphanedImages.isEmpty()) {
|
if (!dryRun && !orphanedImages.isEmpty()) {
|
||||||
logger.info("Deleting {} orphaned images...", orphanedImages.size());
|
logger.debug("Deleting {} orphaned images...", orphanedImages.size());
|
||||||
|
|
||||||
Set<Path> directoriesToCheck = new HashSet<>();
|
Set<Path> directoriesToCheck = new HashSet<>();
|
||||||
|
|
||||||
@@ -557,7 +557,7 @@ public class ImageService {
|
|||||||
try {
|
try {
|
||||||
if (Files.exists(dir) && isDirEmpty(dir)) {
|
if (Files.exists(dir) && isDirEmpty(dir)) {
|
||||||
Files.delete(dir);
|
Files.delete(dir);
|
||||||
logger.info("Deleted empty story directory: {}", dir);
|
logger.debug("Deleted empty story directory: {}", dir);
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
errors.add("Failed to delete empty directory " + dir + ": " + e.getMessage());
|
errors.add("Failed to delete empty directory " + dir + ": " + e.getMessage());
|
||||||
|
|||||||
@@ -144,9 +144,9 @@ public class LibraryService implements ApplicationContextAware {
|
|||||||
String previousLibraryId = currentLibraryId;
|
String previousLibraryId = currentLibraryId;
|
||||||
|
|
||||||
if (libraryId.equals(currentLibraryId) && forceReindex) {
|
if (libraryId.equals(currentLibraryId) && forceReindex) {
|
||||||
logger.info("Forcing reindex for current library: {} ({})", library.getName(), libraryId);
|
logger.debug("Forcing reindex for current library: {} ({})", library.getName(), libraryId);
|
||||||
} else {
|
} else {
|
||||||
logger.info("Switching to library: {} ({})", library.getName(), libraryId);
|
logger.debug("Switching to library: {} ({})", library.getName(), libraryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close current resources
|
// Close current resources
|
||||||
@@ -155,14 +155,14 @@ public class LibraryService implements ApplicationContextAware {
|
|||||||
// Set new active library (datasource routing handled by SmartRoutingDataSource)
|
// Set new active library (datasource routing handled by SmartRoutingDataSource)
|
||||||
currentLibraryId = libraryId;
|
currentLibraryId = libraryId;
|
||||||
// OpenSearch indexes are global - no per-library initialization needed
|
// OpenSearch indexes are global - no per-library initialization needed
|
||||||
logger.info("Library switched to OpenSearch mode for library: {}", libraryId);
|
logger.debug("Library switched to OpenSearch mode for library: {}", libraryId);
|
||||||
|
|
||||||
logger.info("Successfully switched to library: {}", library.getName());
|
logger.info("Successfully switched to library: {}", library.getName());
|
||||||
|
|
||||||
// Perform complete reindex AFTER library switch is fully complete
|
// Perform complete reindex AFTER library switch is fully complete
|
||||||
// This ensures database routing is properly established
|
// This ensures database routing is properly established
|
||||||
if (forceReindex || !libraryId.equals(previousLibraryId)) {
|
if (forceReindex || !libraryId.equals(previousLibraryId)) {
|
||||||
logger.info("Starting post-switch OpenSearch reindex for library: {}", libraryId);
|
logger.debug("Starting post-switch OpenSearch reindex for library: {}", libraryId);
|
||||||
|
|
||||||
// Run reindex asynchronously to avoid blocking authentication response
|
// Run reindex asynchronously to avoid blocking authentication response
|
||||||
// and allow time for database routing to fully stabilize
|
// and allow time for database routing to fully stabilize
|
||||||
@@ -171,7 +171,7 @@ public class LibraryService implements ApplicationContextAware {
|
|||||||
try {
|
try {
|
||||||
// Give routing time to stabilize
|
// Give routing time to stabilize
|
||||||
Thread.sleep(500);
|
Thread.sleep(500);
|
||||||
logger.info("Starting async OpenSearch reindex for library: {}", finalLibraryId);
|
logger.debug("Starting async OpenSearch reindex for library: {}", finalLibraryId);
|
||||||
|
|
||||||
SearchServiceAdapter searchService = applicationContext.getBean(SearchServiceAdapter.class);
|
SearchServiceAdapter searchService = applicationContext.getBean(SearchServiceAdapter.class);
|
||||||
// Get all stories and authors for reindexing
|
// Get all stories and authors for reindexing
|
||||||
@@ -342,10 +342,10 @@ public class LibraryService implements ApplicationContextAware {
|
|||||||
library.setInitialized((Boolean) data.getOrDefault("initialized", false));
|
library.setInitialized((Boolean) data.getOrDefault("initialized", false));
|
||||||
|
|
||||||
libraries.put(id, library);
|
libraries.put(id, library);
|
||||||
logger.info("Loaded library: {} ({})", library.getName(), id);
|
logger.debug("Loaded library: {} ({})", library.getName(), id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.info("No libraries configuration file found, will create default");
|
logger.debug("No libraries configuration file found, will create default");
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.error("Failed to load libraries configuration", e);
|
logger.error("Failed to load libraries configuration", e);
|
||||||
@@ -411,7 +411,7 @@ public class LibraryService implements ApplicationContextAware {
|
|||||||
String json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(config);
|
String json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(config);
|
||||||
Files.writeString(Paths.get(LIBRARIES_CONFIG_PATH), json);
|
Files.writeString(Paths.get(LIBRARIES_CONFIG_PATH), json);
|
||||||
|
|
||||||
logger.info("Saved libraries configuration");
|
logger.debug("Saved libraries configuration");
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.error("Failed to save libraries configuration", e);
|
logger.error("Failed to save libraries configuration", e);
|
||||||
}
|
}
|
||||||
@@ -419,7 +419,7 @@ public class LibraryService implements ApplicationContextAware {
|
|||||||
|
|
||||||
private DataSource createDataSource(String dbName) {
|
private DataSource createDataSource(String dbName) {
|
||||||
String url = baseDbUrl.replaceAll("/[^/]*$", "/" + dbName);
|
String url = baseDbUrl.replaceAll("/[^/]*$", "/" + dbName);
|
||||||
logger.info("Creating DataSource for: {}", url);
|
logger.debug("Creating DataSource for: {}", url);
|
||||||
|
|
||||||
// First, ensure the database exists
|
// First, ensure the database exists
|
||||||
ensureDatabaseExists(dbName);
|
ensureDatabaseExists(dbName);
|
||||||
@@ -459,7 +459,7 @@ public class LibraryService implements ApplicationContextAware {
|
|||||||
preparedStatement.setString(1, dbName);
|
preparedStatement.setString(1, dbName);
|
||||||
try (var resultSet = preparedStatement.executeQuery()) {
|
try (var resultSet = preparedStatement.executeQuery()) {
|
||||||
if (resultSet.next()) {
|
if (resultSet.next()) {
|
||||||
logger.info("Database {} already exists", dbName);
|
logger.debug("Database {} already exists", dbName);
|
||||||
return; // Database exists, nothing to do
|
return; // Database exists, nothing to do
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -488,7 +488,7 @@ public class LibraryService implements ApplicationContextAware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void initializeNewDatabaseSchema(String dbName) {
|
private void initializeNewDatabaseSchema(String dbName) {
|
||||||
logger.info("Initializing schema for new database: {}", dbName);
|
logger.debug("Initializing schema for new database: {}", dbName);
|
||||||
|
|
||||||
// Create a temporary DataSource for the new database to initialize schema
|
// Create a temporary DataSource for the new database to initialize schema
|
||||||
String newDbUrl = baseDbUrl.replaceAll("/[^/]*$", "/" + dbName);
|
String newDbUrl = baseDbUrl.replaceAll("/[^/]*$", "/" + dbName);
|
||||||
@@ -505,7 +505,7 @@ public class LibraryService implements ApplicationContextAware {
|
|||||||
// Use Hibernate to create the schema
|
// Use Hibernate to create the schema
|
||||||
// This mimics what Spring Boot does during startup
|
// This mimics what Spring Boot does during startup
|
||||||
createSchemaUsingHibernate(tempDataSource);
|
createSchemaUsingHibernate(tempDataSource);
|
||||||
logger.info("Schema initialized for database: {}", dbName);
|
logger.debug("Schema initialized for database: {}", dbName);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("Failed to initialize schema for database {}: {}", dbName, e.getMessage());
|
logger.error("Failed to initialize schema for database {}: {}", dbName, e.getMessage());
|
||||||
@@ -520,7 +520,7 @@ public class LibraryService implements ApplicationContextAware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info("Initializing resources for new library: {}", library.getName());
|
logger.debug("Initializing resources for new library: {}", library.getName());
|
||||||
|
|
||||||
// 1. Create image directory structure
|
// 1. Create image directory structure
|
||||||
initializeImageDirectories(library);
|
initializeImageDirectories(library);
|
||||||
@@ -528,7 +528,7 @@ public class LibraryService implements ApplicationContextAware {
|
|||||||
// 2. OpenSearch indexes are global and managed automatically
|
// 2. OpenSearch indexes are global and managed automatically
|
||||||
// No per-library initialization needed for OpenSearch
|
// No per-library initialization needed for OpenSearch
|
||||||
|
|
||||||
logger.info("Successfully initialized resources for library: {}", library.getName());
|
logger.debug("Successfully initialized resources for library: {}", library.getName());
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("Failed to initialize resources for library {}: {}", libraryId, e.getMessage());
|
logger.error("Failed to initialize resources for library {}: {}", libraryId, e.getMessage());
|
||||||
@@ -544,16 +544,16 @@ public class LibraryService implements ApplicationContextAware {
|
|||||||
|
|
||||||
if (!java.nio.file.Files.exists(libraryImagePath)) {
|
if (!java.nio.file.Files.exists(libraryImagePath)) {
|
||||||
java.nio.file.Files.createDirectories(libraryImagePath);
|
java.nio.file.Files.createDirectories(libraryImagePath);
|
||||||
logger.info("Created image directory: {}", imagePath);
|
logger.debug("Created image directory: {}", imagePath);
|
||||||
|
|
||||||
// Create subdirectories for different image types
|
// Create subdirectories for different image types
|
||||||
java.nio.file.Files.createDirectories(libraryImagePath.resolve("stories"));
|
java.nio.file.Files.createDirectories(libraryImagePath.resolve("stories"));
|
||||||
java.nio.file.Files.createDirectories(libraryImagePath.resolve("authors"));
|
java.nio.file.Files.createDirectories(libraryImagePath.resolve("authors"));
|
||||||
java.nio.file.Files.createDirectories(libraryImagePath.resolve("collections"));
|
java.nio.file.Files.createDirectories(libraryImagePath.resolve("collections"));
|
||||||
|
|
||||||
logger.info("Created image subdirectories for library: {}", library.getId());
|
logger.debug("Created image subdirectories for library: {}", library.getId());
|
||||||
} else {
|
} else {
|
||||||
logger.info("Image directory already exists: {}", imagePath);
|
logger.debug("Image directory already exists: {}", imagePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -749,7 +749,7 @@ public class LibraryService implements ApplicationContextAware {
|
|||||||
statement.executeUpdate(sql);
|
statement.executeUpdate(sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Successfully created all database tables and constraints");
|
logger.debug("Successfully created all database tables and constraints");
|
||||||
|
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
logger.error("Failed to create database schema", e);
|
logger.error("Failed to create database schema", e);
|
||||||
|
|||||||
@@ -55,15 +55,15 @@ public class OpenSearchService {
|
|||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void initializeIndices() {
|
public void initializeIndices() {
|
||||||
if (!isAvailable()) {
|
if (!isAvailable()) {
|
||||||
logger.info("OpenSearch client not available - skipping index initialization");
|
logger.debug("OpenSearch client not available - skipping index initialization");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info("Initializing OpenSearch indices...");
|
logger.debug("Initializing OpenSearch indices...");
|
||||||
createStoriesIndex();
|
createStoriesIndex();
|
||||||
createAuthorsIndex();
|
createAuthorsIndex();
|
||||||
logger.info("OpenSearch indices initialized successfully");
|
logger.debug("OpenSearch indices initialized successfully");
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.error("Failed to initialize OpenSearch indices", e);
|
logger.error("Failed to initialize OpenSearch indices", e);
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ public class OpenSearchService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Creating stories index: {}", indexName);
|
logger.debug("Creating stories index: {}", indexName);
|
||||||
|
|
||||||
// Create index settings programmatically
|
// Create index settings programmatically
|
||||||
IndexSettings indexSettings = IndexSettings.of(is -> is
|
IndexSettings indexSettings = IndexSettings.of(is -> is
|
||||||
@@ -125,7 +125,7 @@ public class OpenSearchService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
openSearchClient.indices().create(request);
|
openSearchClient.indices().create(request);
|
||||||
logger.info("Created stories index successfully: {}", indexName);
|
logger.debug("Created stories index successfully: {}", indexName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void createAuthorsIndex() throws IOException {
|
private void createAuthorsIndex() throws IOException {
|
||||||
@@ -135,7 +135,7 @@ public class OpenSearchService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Creating authors index: {}", indexName);
|
logger.debug("Creating authors index: {}", indexName);
|
||||||
|
|
||||||
IndexSettings indexSettings = IndexSettings.of(is -> is
|
IndexSettings indexSettings = IndexSettings.of(is -> is
|
||||||
.numberOfShards(properties.getIndices().getDefaultShards())
|
.numberOfShards(properties.getIndices().getDefaultShards())
|
||||||
@@ -159,7 +159,7 @@ public class OpenSearchService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
openSearchClient.indices().create(request);
|
openSearchClient.indices().create(request);
|
||||||
logger.info("Created authors index successfully: {}", indexName);
|
logger.debug("Created authors index successfully: {}", indexName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean indexExists(String indexName) throws IOException {
|
private boolean indexExists(String indexName) throws IOException {
|
||||||
@@ -293,7 +293,7 @@ public class OpenSearchService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Bulk indexing {} stories", stories.size());
|
logger.debug("Bulk indexing {} stories", stories.size());
|
||||||
|
|
||||||
BulkRequest.Builder bulkBuilder = new BulkRequest.Builder();
|
BulkRequest.Builder bulkBuilder = new BulkRequest.Builder();
|
||||||
|
|
||||||
@@ -319,7 +319,7 @@ public class OpenSearchService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Successfully bulk indexed {} stories", stories.size());
|
logger.debug("Successfully bulk indexed {} stories", stories.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void bulkIndexAuthors(List<Author> authors) throws IOException {
|
public void bulkIndexAuthors(List<Author> authors) throws IOException {
|
||||||
@@ -333,7 +333,7 @@ public class OpenSearchService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Bulk indexing {} authors", authors.size());
|
logger.debug("Bulk indexing {} authors", authors.size());
|
||||||
|
|
||||||
BulkRequest.Builder bulkBuilder = new BulkRequest.Builder();
|
BulkRequest.Builder bulkBuilder = new BulkRequest.Builder();
|
||||||
|
|
||||||
@@ -359,7 +359,7 @@ public class OpenSearchService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Successfully bulk indexed {} authors", authors.size());
|
logger.debug("Successfully bulk indexed {} authors", authors.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===============================
|
// ===============================
|
||||||
@@ -380,27 +380,27 @@ public class OpenSearchService {
|
|||||||
Integer minTagCount, Boolean popularOnly,
|
Integer minTagCount, Boolean popularOnly,
|
||||||
Boolean hiddenGemsOnly) {
|
Boolean hiddenGemsOnly) {
|
||||||
try {
|
try {
|
||||||
logger.info("OPENSEARCH SEARCH DEBUG:");
|
logger.debug("OPENSEARCH SEARCH DEBUG:");
|
||||||
logger.info(" Query: '{}'", query);
|
logger.debug(" Query: '{}'", query);
|
||||||
logger.info(" Tags: {}", tags);
|
logger.debug(" Tags: {}", tags);
|
||||||
logger.info(" Author: '{}'", author);
|
logger.debug(" Author: '{}'", author);
|
||||||
logger.info(" Series: '{}'", series);
|
logger.debug(" Series: '{}'", series);
|
||||||
logger.info(" SortBy: '{}'", sortBy);
|
logger.debug(" SortBy: '{}'", sortBy);
|
||||||
logger.info(" SortOrder: '{}'", sortOrder);
|
logger.debug(" SortOrder: '{}'", sortOrder);
|
||||||
logger.info(" Page: {}, Size: {}", page, size);
|
logger.debug(" Page: {}, Size: {}", page, size);
|
||||||
logger.info(" FacetBy: {}", facetBy);
|
logger.debug(" FacetBy: {}", facetBy);
|
||||||
logger.info(" Advanced filters: createdAfter='{}', createdBefore='{}', lastReadAfter='{}', lastReadBefore='{}'",
|
logger.debug(" Advanced filters: createdAfter='{}', createdBefore='{}', lastReadAfter='{}', lastReadBefore='{}'",
|
||||||
createdAfter, createdBefore, lastReadAfter, lastReadBefore);
|
createdAfter, createdBefore, lastReadAfter, lastReadBefore);
|
||||||
logger.info(" Boolean filters: unratedOnly={}, readingStatus='{}', hasReadingProgress={}, hasCoverImage={}",
|
logger.debug(" Boolean filters: unratedOnly={}, readingStatus='{}', hasReadingProgress={}, hasCoverImage={}",
|
||||||
unratedOnly, readingStatus, hasReadingProgress, hasCoverImage);
|
unratedOnly, readingStatus, hasReadingProgress, hasCoverImage);
|
||||||
logger.info(" Other filters: sourceDomain='{}', seriesFilter='{}', minTagCount={}, popularOnly={}, hiddenGemsOnly={}",
|
logger.debug(" Other filters: sourceDomain='{}', seriesFilter='{}', minTagCount={}, popularOnly={}, hiddenGemsOnly={}",
|
||||||
sourceDomain, seriesFilter, minTagCount, popularOnly, hiddenGemsOnly);
|
sourceDomain, seriesFilter, minTagCount, popularOnly, hiddenGemsOnly);
|
||||||
|
|
||||||
// Check index document count
|
// Check index document count
|
||||||
try {
|
try {
|
||||||
var countRequest = CountRequest.of(cr -> cr.index(getStoriesIndex()));
|
var countRequest = CountRequest.of(cr -> cr.index(getStoriesIndex()));
|
||||||
var countResponse = openSearchClient.count(countRequest);
|
var countResponse = openSearchClient.count(countRequest);
|
||||||
logger.info(" Stories index document count: {}", countResponse.count());
|
logger.debug(" Stories index document count: {}", countResponse.count());
|
||||||
|
|
||||||
// Test a simple search without sorting to see if we get results
|
// Test a simple search without sorting to see if we get results
|
||||||
if (countResponse.count() > 0) {
|
if (countResponse.count() > 0) {
|
||||||
@@ -410,7 +410,7 @@ public class OpenSearchService {
|
|||||||
.query(q -> q.matchAll(ma -> ma))
|
.query(q -> q.matchAll(ma -> ma))
|
||||||
);
|
);
|
||||||
var testResponse = openSearchClient.search(testSearch, StorySearchDto.class);
|
var testResponse = openSearchClient.search(testSearch, StorySearchDto.class);
|
||||||
logger.info(" Test search without sorting: totalHits={}, hits.size()={}",
|
logger.debug(" Test search without sorting: totalHits={}, hits.size()={}",
|
||||||
testResponse.hits().total() != null ? testResponse.hits().total().value() : 0,
|
testResponse.hits().total() != null ? testResponse.hits().total().value() : 0,
|
||||||
testResponse.hits().hits().size());
|
testResponse.hits().hits().size());
|
||||||
}
|
}
|
||||||
@@ -431,10 +431,10 @@ public class OpenSearchService {
|
|||||||
String trimmedQuery = query.trim();
|
String trimmedQuery = query.trim();
|
||||||
// Handle wildcard queries
|
// Handle wildcard queries
|
||||||
if ("*".equals(trimmedQuery) || "**".equals(trimmedQuery)) {
|
if ("*".equals(trimmedQuery) || "**".equals(trimmedQuery)) {
|
||||||
logger.info(" Using matchAll query for wildcard: '{}'", trimmedQuery);
|
logger.debug(" Using matchAll query for wildcard: '{}'", trimmedQuery);
|
||||||
boolBuilder.must(m -> m.matchAll(ma -> ma));
|
boolBuilder.must(m -> m.matchAll(ma -> ma));
|
||||||
} else {
|
} else {
|
||||||
logger.info(" Using multiMatch query for: '{}'", trimmedQuery);
|
logger.debug(" Using multiMatch query for: '{}'", trimmedQuery);
|
||||||
boolBuilder.must(m -> m
|
boolBuilder.must(m -> m
|
||||||
.multiMatch(mm -> mm
|
.multiMatch(mm -> mm
|
||||||
.query(trimmedQuery)
|
.query(trimmedQuery)
|
||||||
@@ -444,13 +444,13 @@ public class OpenSearchService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.info(" Using matchAll query for empty query");
|
logger.debug(" Using matchAll query for empty query");
|
||||||
boolBuilder.must(m -> m.matchAll(ma -> ma));
|
boolBuilder.must(m -> m.matchAll(ma -> ma));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add filters
|
// Add filters
|
||||||
if (tags != null && !tags.isEmpty()) {
|
if (tags != null && !tags.isEmpty()) {
|
||||||
logger.info(" Adding tags filter: {}", tags);
|
logger.debug(" Adding tags filter: {}", tags);
|
||||||
boolBuilder.filter(f -> f
|
boolBuilder.filter(f -> f
|
||||||
.terms(t -> t
|
.terms(t -> t
|
||||||
.field("tagNames")
|
.field("tagNames")
|
||||||
@@ -460,7 +460,7 @@ public class OpenSearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (author != null && !author.trim().isEmpty() && !"null".equalsIgnoreCase(author.trim())) {
|
if (author != null && !author.trim().isEmpty() && !"null".equalsIgnoreCase(author.trim())) {
|
||||||
logger.info(" Adding author filter: '{}'", author.trim());
|
logger.debug(" Adding author filter: '{}'", author.trim());
|
||||||
boolBuilder.filter(f -> f
|
boolBuilder.filter(f -> f
|
||||||
.term(t -> t
|
.term(t -> t
|
||||||
.field("authorName")
|
.field("authorName")
|
||||||
@@ -472,7 +472,7 @@ public class OpenSearchService {
|
|||||||
// Series filtering is now handled by advanced seriesFilter parameter
|
// Series filtering is now handled by advanced seriesFilter parameter
|
||||||
|
|
||||||
if (minWordCount != null || maxWordCount != null) {
|
if (minWordCount != null || maxWordCount != null) {
|
||||||
logger.info(" Adding word count filter: min={}, max={}", minWordCount, maxWordCount);
|
logger.debug(" Adding word count filter: min={}, max={}", minWordCount, maxWordCount);
|
||||||
boolBuilder.filter(f -> f
|
boolBuilder.filter(f -> f
|
||||||
.range(r -> {
|
.range(r -> {
|
||||||
var rangeBuilder = r.field("wordCount");
|
var rangeBuilder = r.field("wordCount");
|
||||||
@@ -488,7 +488,7 @@ public class OpenSearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (minRating != null) {
|
if (minRating != null) {
|
||||||
logger.info(" Adding rating filter: min={}", minRating);
|
logger.debug(" Adding rating filter: min={}", minRating);
|
||||||
boolBuilder.filter(f -> f
|
boolBuilder.filter(f -> f
|
||||||
.range(r -> r
|
.range(r -> r
|
||||||
.field("rating")
|
.field("rating")
|
||||||
@@ -498,7 +498,7 @@ public class OpenSearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isRead != null) {
|
if (isRead != null) {
|
||||||
logger.info(" Adding isRead filter: {}", isRead);
|
logger.debug(" Adding isRead filter: {}", isRead);
|
||||||
boolBuilder.filter(f -> f
|
boolBuilder.filter(f -> f
|
||||||
.term(t -> t
|
.term(t -> t
|
||||||
.field("isRead")
|
.field("isRead")
|
||||||
@@ -508,13 +508,13 @@ public class OpenSearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isFavorite != null) {
|
if (isFavorite != null) {
|
||||||
logger.info(" isFavorite filter requested: {} (FIELD NOT IMPLEMENTED - IGNORING)", isFavorite);
|
logger.debug(" isFavorite filter requested: {} (FIELD NOT IMPLEMENTED - IGNORING)", isFavorite);
|
||||||
// isFavorite field is not implemented in Story entity or StorySearchDto, so ignore this filter
|
// isFavorite field is not implemented in Story entity or StorySearchDto, so ignore this filter
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advanced date filters
|
// Advanced date filters
|
||||||
if (createdAfter != null && !createdAfter.trim().isEmpty() && !"null".equalsIgnoreCase(createdAfter.trim())) {
|
if (createdAfter != null && !createdAfter.trim().isEmpty() && !"null".equalsIgnoreCase(createdAfter.trim())) {
|
||||||
logger.info(" Adding createdAfter filter: '{}'", createdAfter.trim());
|
logger.debug(" Adding createdAfter filter: '{}'", createdAfter.trim());
|
||||||
boolBuilder.filter(f -> f
|
boolBuilder.filter(f -> f
|
||||||
.range(r -> r
|
.range(r -> r
|
||||||
.field("createdAt")
|
.field("createdAt")
|
||||||
@@ -524,7 +524,7 @@ public class OpenSearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (createdBefore != null && !createdBefore.trim().isEmpty() && !"null".equalsIgnoreCase(createdBefore.trim())) {
|
if (createdBefore != null && !createdBefore.trim().isEmpty() && !"null".equalsIgnoreCase(createdBefore.trim())) {
|
||||||
logger.info(" Adding createdBefore filter: '{}'", createdBefore.trim());
|
logger.debug(" Adding createdBefore filter: '{}'", createdBefore.trim());
|
||||||
boolBuilder.filter(f -> f
|
boolBuilder.filter(f -> f
|
||||||
.range(r -> r
|
.range(r -> r
|
||||||
.field("createdAt")
|
.field("createdAt")
|
||||||
@@ -534,7 +534,7 @@ public class OpenSearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lastReadAfter != null && !lastReadAfter.trim().isEmpty() && !"null".equalsIgnoreCase(lastReadAfter.trim())) {
|
if (lastReadAfter != null && !lastReadAfter.trim().isEmpty() && !"null".equalsIgnoreCase(lastReadAfter.trim())) {
|
||||||
logger.info(" Adding lastReadAfter filter: '{}'", lastReadAfter.trim());
|
logger.debug(" Adding lastReadAfter filter: '{}'", lastReadAfter.trim());
|
||||||
boolBuilder.filter(f -> f
|
boolBuilder.filter(f -> f
|
||||||
.range(r -> r
|
.range(r -> r
|
||||||
.field("lastReadAt")
|
.field("lastReadAt")
|
||||||
@@ -544,7 +544,7 @@ public class OpenSearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lastReadBefore != null && !lastReadBefore.trim().isEmpty() && !"null".equalsIgnoreCase(lastReadBefore.trim())) {
|
if (lastReadBefore != null && !lastReadBefore.trim().isEmpty() && !"null".equalsIgnoreCase(lastReadBefore.trim())) {
|
||||||
logger.info(" Adding lastReadBefore filter: '{}'", lastReadBefore.trim());
|
logger.debug(" Adding lastReadBefore filter: '{}'", lastReadBefore.trim());
|
||||||
boolBuilder.filter(f -> f
|
boolBuilder.filter(f -> f
|
||||||
.range(r -> r
|
.range(r -> r
|
||||||
.field("lastReadAt")
|
.field("lastReadAt")
|
||||||
@@ -555,7 +555,7 @@ public class OpenSearchService {
|
|||||||
|
|
||||||
// Advanced boolean filters
|
// Advanced boolean filters
|
||||||
if (unratedOnly != null && unratedOnly) {
|
if (unratedOnly != null && unratedOnly) {
|
||||||
logger.info(" Adding unratedOnly filter");
|
logger.debug(" Adding unratedOnly filter");
|
||||||
boolBuilder.filter(f -> f
|
boolBuilder.filter(f -> f
|
||||||
.bool(b -> b
|
.bool(b -> b
|
||||||
.should(s -> s.term(t -> t.field("rating").value(FieldValue.of(0))))
|
.should(s -> s.term(t -> t.field("rating").value(FieldValue.of(0))))
|
||||||
@@ -565,7 +565,7 @@ public class OpenSearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasReadingProgress != null) {
|
if (hasReadingProgress != null) {
|
||||||
logger.info(" Adding hasReadingProgress filter: {}", hasReadingProgress);
|
logger.debug(" Adding hasReadingProgress filter: {}", hasReadingProgress);
|
||||||
if (hasReadingProgress) {
|
if (hasReadingProgress) {
|
||||||
boolBuilder.filter(f -> f
|
boolBuilder.filter(f -> f
|
||||||
.range(r -> r
|
.range(r -> r
|
||||||
@@ -584,7 +584,7 @@ public class OpenSearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasCoverImage != null) {
|
if (hasCoverImage != null) {
|
||||||
logger.info(" Adding hasCoverImage filter: {}", hasCoverImage);
|
logger.debug(" Adding hasCoverImage filter: {}", hasCoverImage);
|
||||||
if (hasCoverImage) {
|
if (hasCoverImage) {
|
||||||
boolBuilder.filter(f -> f
|
boolBuilder.filter(f -> f
|
||||||
.exists(e -> e.field("coverPath"))
|
.exists(e -> e.field("coverPath"))
|
||||||
@@ -599,7 +599,7 @@ public class OpenSearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sourceDomain != null && !sourceDomain.trim().isEmpty() && !"null".equalsIgnoreCase(sourceDomain.trim())) {
|
if (sourceDomain != null && !sourceDomain.trim().isEmpty() && !"null".equalsIgnoreCase(sourceDomain.trim())) {
|
||||||
logger.info(" Adding sourceDomain filter: '{}'", sourceDomain.trim());
|
logger.debug(" Adding sourceDomain filter: '{}'", sourceDomain.trim());
|
||||||
boolBuilder.filter(f -> f
|
boolBuilder.filter(f -> f
|
||||||
.term(t -> t
|
.term(t -> t
|
||||||
.field("sourceDomain")
|
.field("sourceDomain")
|
||||||
@@ -610,10 +610,10 @@ public class OpenSearchService {
|
|||||||
|
|
||||||
// Reading status filter logic
|
// Reading status filter logic
|
||||||
if (readingStatus != null && !readingStatus.trim().isEmpty() && !"null".equalsIgnoreCase(readingStatus.trim()) && !"all".equalsIgnoreCase(readingStatus.trim())) {
|
if (readingStatus != null && !readingStatus.trim().isEmpty() && !"null".equalsIgnoreCase(readingStatus.trim()) && !"all".equalsIgnoreCase(readingStatus.trim())) {
|
||||||
logger.info(" Adding readingStatus filter: '{}'", readingStatus.trim());
|
logger.debug(" Adding readingStatus filter: '{}'", readingStatus.trim());
|
||||||
if ("unread".equalsIgnoreCase(readingStatus.trim())) {
|
if ("unread".equalsIgnoreCase(readingStatus.trim())) {
|
||||||
// Simplified unread test: just check isRead = false
|
// Simplified unread test: just check isRead = false
|
||||||
logger.info(" Applying simplified unread filter: isRead = false");
|
logger.debug(" Applying simplified unread filter: isRead = false");
|
||||||
boolBuilder.filter(f -> f
|
boolBuilder.filter(f -> f
|
||||||
.term(t -> t.field("isRead").value(FieldValue.of(false)))
|
.term(t -> t.field("isRead").value(FieldValue.of(false)))
|
||||||
);
|
);
|
||||||
@@ -635,10 +635,10 @@ public class OpenSearchService {
|
|||||||
|
|
||||||
// Series filter (separate from seriesFilter parameter which is handled above)
|
// Series filter (separate from seriesFilter parameter which is handled above)
|
||||||
if (seriesFilter != null && !seriesFilter.trim().isEmpty() && !"null".equalsIgnoreCase(seriesFilter.trim())) {
|
if (seriesFilter != null && !seriesFilter.trim().isEmpty() && !"null".equalsIgnoreCase(seriesFilter.trim())) {
|
||||||
logger.info(" Adding advanced seriesFilter: '{}'", seriesFilter.trim());
|
logger.debug(" Adding advanced seriesFilter: '{}'", seriesFilter.trim());
|
||||||
if ("standalone".equalsIgnoreCase(seriesFilter.trim())) {
|
if ("standalone".equalsIgnoreCase(seriesFilter.trim())) {
|
||||||
// Stories without series: seriesName field doesn't exist or is null
|
// Stories without series: seriesName field doesn't exist or is null
|
||||||
logger.info(" Applying standalone filter: seriesName field must not exist");
|
logger.debug(" Applying standalone filter: seriesName field must not exist");
|
||||||
boolBuilder.filter(f -> f
|
boolBuilder.filter(f -> f
|
||||||
.bool(b -> b
|
.bool(b -> b
|
||||||
.mustNot(mn -> mn.exists(e -> e.field("seriesName")))
|
.mustNot(mn -> mn.exists(e -> e.field("seriesName")))
|
||||||
@@ -646,7 +646,7 @@ public class OpenSearchService {
|
|||||||
);
|
);
|
||||||
} else if ("series".equalsIgnoreCase(seriesFilter.trim())) {
|
} else if ("series".equalsIgnoreCase(seriesFilter.trim())) {
|
||||||
// Stories with series: seriesName field exists and has a value
|
// Stories with series: seriesName field exists and has a value
|
||||||
logger.info(" Applying series filter: seriesName field must exist");
|
logger.debug(" Applying series filter: seriesName field must exist");
|
||||||
boolBuilder.filter(f -> f
|
boolBuilder.filter(f -> f
|
||||||
.exists(e -> e.field("seriesName"))
|
.exists(e -> e.field("seriesName"))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ RUN apk add --no-cache dumb-init
|
|||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install dependencies with optimized settings
|
# Install dependencies with optimized settings
|
||||||
RUN npm ci --prefer-offline --no-audit --frozen-lockfile
|
RUN npm install --prefer-offline --no-audit --legacy-peer-deps
|
||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
FROM node:18-alpine AS builder
|
FROM node:18-alpine AS builder
|
||||||
|
|||||||
2
frontend/next-env.d.ts
vendored
2
frontend/next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
|||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
|
|||||||
6291
frontend/package-lock.json
generated
6291
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,18 +10,22 @@
|
|||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"@portabletext/editor": "2.12.1",
|
||||||
"axios": "^1.11.0",
|
"@portabletext/keyboard-shortcuts": "^1.1.1",
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"@portabletext/react": "4.0.3",
|
||||||
"dompurify": "^3.2.6",
|
"@portabletext/types": "2.0.14",
|
||||||
"next": "14.0.0",
|
"autoprefixer": "^10.4.16",
|
||||||
"postcss": "^8.4.31",
|
"axios": "^1.11.0",
|
||||||
"react": "^18",
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"react-dom": "^18",
|
"dompurify": "^3.2.6",
|
||||||
"react-dropzone": "^14.2.3",
|
"next": "^14.2.32",
|
||||||
"server-only": "^0.0.1",
|
"postcss": "^8.4.31",
|
||||||
"tailwindcss": "^3.3.0"
|
"react": "^18",
|
||||||
|
"react-dom": "^18",
|
||||||
|
"react-dropzone": "^14.2.3",
|
||||||
|
"server-only": "^0.0.1",
|
||||||
|
"tailwindcss": "^3.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
|
|||||||
37
frontend/package.json.with-portabletext
Normal file
37
frontend/package.json.with-portabletext
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "storycove-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@heroicons/react": "^2.2.0",
|
||||||
|
"@portabletext/react": "4.0.3",
|
||||||
|
"@portabletext/types": "2.0.14",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"axios": "^1.11.0",
|
||||||
|
"cheerio": "^1.0.0-rc.12",
|
||||||
|
"dompurify": "^3.2.6",
|
||||||
|
"next": "14.0.0",
|
||||||
|
"postcss": "^8.4.31",
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18",
|
||||||
|
"react-dropzone": "^14.2.3",
|
||||||
|
"server-only": "^0.0.1",
|
||||||
|
"tailwindcss": "^3.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "14.0.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
550
frontend/src/app/add-story/AddStoryContent.tsx
Normal file
550
frontend/src/app/add-story/AddStoryContent.tsx
Normal file
@@ -0,0 +1,550 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { Input, Textarea } from '../../components/ui/Input';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
import TagInput from '../../components/stories/TagInput';
|
||||||
|
import PortableTextEditor from '../../components/stories/PortableTextEditorNew';
|
||||||
|
import ImageUpload from '../../components/ui/ImageUpload';
|
||||||
|
import AuthorSelector from '../../components/stories/AuthorSelector';
|
||||||
|
import SeriesSelector from '../../components/stories/SeriesSelector';
|
||||||
|
import { storyApi, authorApi } from '../../lib/api';
|
||||||
|
|
||||||
|
export default function AddStoryContent() {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: '',
|
||||||
|
summary: '',
|
||||||
|
authorName: '',
|
||||||
|
authorId: undefined as string | undefined,
|
||||||
|
contentHtml: '',
|
||||||
|
sourceUrl: '',
|
||||||
|
tags: [] as string[],
|
||||||
|
seriesName: '',
|
||||||
|
seriesId: undefined as string | undefined,
|
||||||
|
volume: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
count: number;
|
||||||
|
duplicates: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
authorName: string;
|
||||||
|
createdAt: string;
|
||||||
|
}>;
|
||||||
|
}>({ show: false, count: 0, duplicates: [] });
|
||||||
|
const [checkingDuplicates, setCheckingDuplicates] = useState(false);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
// Handle URL parameters
|
||||||
|
useEffect(() => {
|
||||||
|
const authorId = searchParams.get('authorId');
|
||||||
|
const from = searchParams.get('from');
|
||||||
|
|
||||||
|
// Pre-fill author if authorId is provided in URL
|
||||||
|
if (authorId) {
|
||||||
|
const loadAuthor = async () => {
|
||||||
|
try {
|
||||||
|
const author = await authorApi.getAuthor(authorId);
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
authorName: author.name,
|
||||||
|
authorId: author.id
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load author:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadAuthor();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle URL import data
|
||||||
|
if (from === 'url-import') {
|
||||||
|
const title = searchParams.get('title') || '';
|
||||||
|
const summary = searchParams.get('summary') || '';
|
||||||
|
const author = searchParams.get('author') || '';
|
||||||
|
const sourceUrl = searchParams.get('sourceUrl') || '';
|
||||||
|
const tagsParam = searchParams.get('tags');
|
||||||
|
const content = searchParams.get('content') || '';
|
||||||
|
|
||||||
|
let tags: string[] = [];
|
||||||
|
try {
|
||||||
|
tags = tagsParam ? JSON.parse(tagsParam) : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse tags:', error);
|
||||||
|
tags = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
authorName: author,
|
||||||
|
authorId: undefined, // Reset author ID when importing from URL
|
||||||
|
contentHtml: content,
|
||||||
|
sourceUrl,
|
||||||
|
tags
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
setErrors({ success: 'Story data imported successfully! Review and edit as needed before saving.' });
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// Load pending story data from bulk combine operation
|
||||||
|
useEffect(() => {
|
||||||
|
const fromBulkCombine = searchParams.get('from') === 'bulk-combine';
|
||||||
|
if (fromBulkCombine) {
|
||||||
|
const pendingStoryData = localStorage.getItem('pendingStory');
|
||||||
|
if (pendingStoryData) {
|
||||||
|
try {
|
||||||
|
const storyData = JSON.parse(pendingStoryData);
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
title: storyData.title || '',
|
||||||
|
authorName: storyData.author || '',
|
||||||
|
authorId: undefined, // Reset author ID for bulk combined stories
|
||||||
|
contentHtml: storyData.content || '',
|
||||||
|
sourceUrl: storyData.sourceUrl || '',
|
||||||
|
summary: storyData.summary || '',
|
||||||
|
tags: storyData.tags || []
|
||||||
|
}));
|
||||||
|
// Clear the pending data
|
||||||
|
localStorage.removeItem('pendingStory');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load pending story data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// Check for duplicates when title and author are both present
|
||||||
|
useEffect(() => {
|
||||||
|
const checkDuplicates = async () => {
|
||||||
|
const title = formData.title.trim();
|
||||||
|
const authorName = formData.authorName.trim();
|
||||||
|
|
||||||
|
// Don't check if user isn't authenticated or if title/author are empty
|
||||||
|
if (!isAuthenticated || !title || !authorName) {
|
||||||
|
setDuplicateWarning({ show: false, count: 0, duplicates: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce the check to avoid too many API calls
|
||||||
|
const timeoutId = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
setCheckingDuplicates(true);
|
||||||
|
const result = await storyApi.checkDuplicate(title, authorName);
|
||||||
|
|
||||||
|
if (result.hasDuplicates) {
|
||||||
|
setDuplicateWarning({
|
||||||
|
show: true,
|
||||||
|
count: result.count,
|
||||||
|
duplicates: result.duplicates
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setDuplicateWarning({ show: false, count: 0, duplicates: [] });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check for duplicates:', error);
|
||||||
|
// Clear any existing duplicate warnings on error
|
||||||
|
setDuplicateWarning({ show: false, count: 0, duplicates: [] });
|
||||||
|
// Don't show error to user as this is just a helpful warning
|
||||||
|
// Authentication errors will be handled by the API interceptor
|
||||||
|
} finally {
|
||||||
|
setCheckingDuplicates(false);
|
||||||
|
}
|
||||||
|
}, 500); // 500ms debounce
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkDuplicates();
|
||||||
|
}, [formData.title, formData.authorName, isAuthenticated]);
|
||||||
|
|
||||||
|
const handleInputChange = (field: string) => (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||||
|
) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[field]: e.target.value
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Clear error when user starts typing
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContentChange = (html: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, contentHtml: html }));
|
||||||
|
if (errors.contentHtml) {
|
||||||
|
setErrors(prev => ({ ...prev, contentHtml: '' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagsChange = (tags: string[]) => {
|
||||||
|
setFormData(prev => ({ ...prev, tags }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthorChange = (authorName: string, authorId?: string) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
authorName,
|
||||||
|
authorId: authorId // This will be undefined if creating new author, which clears the existing ID
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Clear error when user changes author
|
||||||
|
if (errors.authorName) {
|
||||||
|
setErrors(prev => ({ ...prev, authorName: '' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeriesChange = (seriesName: string, seriesId?: string) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
seriesName,
|
||||||
|
seriesId: seriesId // This will be undefined if creating new series, which clears the existing ID
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Clear error when user changes series
|
||||||
|
if (errors.seriesName) {
|
||||||
|
setErrors(prev => ({ ...prev, seriesName: '' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!formData.title.trim()) {
|
||||||
|
newErrors.title = 'Title is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.authorName.trim()) {
|
||||||
|
newErrors.authorName = 'Author name is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.contentHtml.trim()) {
|
||||||
|
newErrors.contentHtml = 'Story content is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.seriesName && !formData.volume) {
|
||||||
|
newErrors.volume = 'Volume number is required when series is specified';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.volume && !formData.seriesName.trim()) {
|
||||||
|
newErrors.seriesName = 'Series name is required when volume is specified';
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First, create the story with JSON data
|
||||||
|
const storyData = {
|
||||||
|
title: formData.title,
|
||||||
|
summary: formData.summary || undefined,
|
||||||
|
contentHtml: formData.contentHtml,
|
||||||
|
sourceUrl: formData.sourceUrl || undefined,
|
||||||
|
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
|
||||||
|
// Send seriesId if we have it (existing series), otherwise send seriesName (new series)
|
||||||
|
...(formData.seriesId ? { seriesId: formData.seriesId } : { seriesName: formData.seriesName || undefined }),
|
||||||
|
// Send authorId if we have it (existing author), otherwise send authorName (new author)
|
||||||
|
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
|
||||||
|
tagNames: formData.tags.length > 0 ? formData.tags : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/stories/${story.id}/detail`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to create story:', error);
|
||||||
|
const errorMessage = error.response?.data?.message || 'Failed to create story';
|
||||||
|
setErrors({ submit: errorMessage });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Success Message */}
|
||||||
|
{errors.success && (
|
||||||
|
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg mb-6">
|
||||||
|
<p className="text-green-800 dark:text-green-200">{errors.success}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Title */}
|
||||||
|
<Input
|
||||||
|
label="Title *"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={handleInputChange('title')}
|
||||||
|
placeholder="Enter the story title"
|
||||||
|
error={errors.title}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Author Selector */}
|
||||||
|
<AuthorSelector
|
||||||
|
label="Author *"
|
||||||
|
value={formData.authorName}
|
||||||
|
onChange={handleAuthorChange}
|
||||||
|
placeholder="Select or enter author name"
|
||||||
|
error={errors.authorName}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Duplicate Warning */}
|
||||||
|
{duplicateWarning.show && (
|
||||||
|
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="text-yellow-600 dark:text-yellow-400 mt-0.5">
|
||||||
|
⚠️
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-yellow-800 dark:text-yellow-200">
|
||||||
|
Potential Duplicate Detected
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
|
||||||
|
Found {duplicateWarning.count} existing {duplicateWarning.count === 1 ? 'story' : 'stories'} with the same title and author:
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{duplicateWarning.duplicates.map((duplicate, index) => (
|
||||||
|
<li key={duplicate.id} className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||||
|
• <span className="font-medium">{duplicate.title}</span> by {duplicate.authorName}
|
||||||
|
<span className="text-xs ml-2">
|
||||||
|
(added {new Date(duplicate.createdAt).toLocaleDateString()})
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-2">
|
||||||
|
You can still create this story if it's different from the existing ones.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Checking indicator */}
|
||||||
|
{checkingDuplicates && (
|
||||||
|
<div className="flex items-center gap-2 text-sm theme-text">
|
||||||
|
<div className="animate-spin w-4 h-4 border-2 border-theme-accent border-t-transparent rounded-full"></div>
|
||||||
|
Checking for duplicates...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium theme-header mb-2">
|
||||||
|
Summary
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.summary}
|
||||||
|
onChange={handleInputChange('summary')}
|
||||||
|
placeholder="Brief summary or description of the story..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<p className="text-sm theme-text mt-1">
|
||||||
|
Optional summary that will be displayed on the story detail page
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cover Image Upload */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium theme-header mb-2">
|
||||||
|
Cover Image
|
||||||
|
</label>
|
||||||
|
<ImageUpload
|
||||||
|
onImageSelect={setCoverImage}
|
||||||
|
accept="image/jpeg,image/png"
|
||||||
|
maxSizeMB={5}
|
||||||
|
aspectRatio="3:4"
|
||||||
|
placeholder="Drop a cover image here or click to select"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium theme-header mb-2">
|
||||||
|
Story Content *
|
||||||
|
</label>
|
||||||
|
<PortableTextEditor
|
||||||
|
value={formData.contentHtml}
|
||||||
|
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 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium theme-header mb-2">
|
||||||
|
Tags
|
||||||
|
</label>
|
||||||
|
<TagInput
|
||||||
|
tags={formData.tags}
|
||||||
|
onChange={handleTagsChange}
|
||||||
|
placeholder="Add tags to categorize your story..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Series and Volume */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<SeriesSelector
|
||||||
|
label="Series (optional)"
|
||||||
|
value={formData.seriesName}
|
||||||
|
onChange={handleSeriesChange}
|
||||||
|
placeholder="Select or enter series name if part of a series"
|
||||||
|
error={errors.seriesName}
|
||||||
|
authorId={formData.authorId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Volume/Part (optional)"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={formData.volume}
|
||||||
|
onChange={handleInputChange('volume')}
|
||||||
|
placeholder="Enter volume/part number"
|
||||||
|
error={errors.volume}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source URL */}
|
||||||
|
<Input
|
||||||
|
label="Source URL (optional)"
|
||||||
|
type="url"
|
||||||
|
value={formData.sourceUrl}
|
||||||
|
onChange={handleInputChange('sourceUrl')}
|
||||||
|
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">
|
||||||
|
<p className="text-red-800 dark:text-red-200">{errors.submit}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-4 pt-6">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
disabled={!formData.title || !formData.authorName || !formData.contentHtml}
|
||||||
|
>
|
||||||
|
{processingImages ? 'Processing Images...' : 'Add Story'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,554 +1,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { Suspense } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
|
||||||
import ImportLayout from '../../components/layout/ImportLayout';
|
import ImportLayout from '../../components/layout/ImportLayout';
|
||||||
import { Input, Textarea } from '../../components/ui/Input';
|
import LoadingSpinner from '../../components/ui/LoadingSpinner';
|
||||||
import Button from '../../components/ui/Button';
|
import AddStoryContent from './AddStoryContent';
|
||||||
import TagInput from '../../components/stories/TagInput';
|
|
||||||
import RichTextEditor from '../../components/stories/RichTextEditor';
|
|
||||||
import ImageUpload from '../../components/ui/ImageUpload';
|
|
||||||
import AuthorSelector from '../../components/stories/AuthorSelector';
|
|
||||||
import SeriesSelector from '../../components/stories/SeriesSelector';
|
|
||||||
import { storyApi, authorApi } from '../../lib/api';
|
|
||||||
|
|
||||||
export default function AddStoryPage() {
|
export default function AddStoryPage() {
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
title: '',
|
|
||||||
summary: '',
|
|
||||||
authorName: '',
|
|
||||||
authorId: undefined as string | undefined,
|
|
||||||
contentHtml: '',
|
|
||||||
sourceUrl: '',
|
|
||||||
tags: [] as string[],
|
|
||||||
seriesName: '',
|
|
||||||
seriesId: undefined as string | undefined,
|
|
||||||
volume: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
count: number;
|
|
||||||
duplicates: Array<{
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
authorName: string;
|
|
||||||
createdAt: string;
|
|
||||||
}>;
|
|
||||||
}>({ show: false, count: 0, duplicates: [] });
|
|
||||||
const [checkingDuplicates, setCheckingDuplicates] = useState(false);
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const { isAuthenticated } = useAuth();
|
|
||||||
|
|
||||||
// Handle URL parameters
|
|
||||||
useEffect(() => {
|
|
||||||
const authorId = searchParams.get('authorId');
|
|
||||||
const from = searchParams.get('from');
|
|
||||||
|
|
||||||
// Pre-fill author if authorId is provided in URL
|
|
||||||
if (authorId) {
|
|
||||||
const loadAuthor = async () => {
|
|
||||||
try {
|
|
||||||
const author = await authorApi.getAuthor(authorId);
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
authorName: author.name,
|
|
||||||
authorId: author.id
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load author:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadAuthor();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle URL import data
|
|
||||||
if (from === 'url-import') {
|
|
||||||
const title = searchParams.get('title') || '';
|
|
||||||
const summary = searchParams.get('summary') || '';
|
|
||||||
const author = searchParams.get('author') || '';
|
|
||||||
const sourceUrl = searchParams.get('sourceUrl') || '';
|
|
||||||
const tagsParam = searchParams.get('tags');
|
|
||||||
const content = searchParams.get('content') || '';
|
|
||||||
|
|
||||||
let tags: string[] = [];
|
|
||||||
try {
|
|
||||||
tags = tagsParam ? JSON.parse(tagsParam) : [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to parse tags:', error);
|
|
||||||
tags = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
title,
|
|
||||||
summary,
|
|
||||||
authorName: author,
|
|
||||||
authorId: undefined, // Reset author ID when importing from URL
|
|
||||||
contentHtml: content,
|
|
||||||
sourceUrl,
|
|
||||||
tags
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Show success message
|
|
||||||
setErrors({ success: 'Story data imported successfully! Review and edit as needed before saving.' });
|
|
||||||
}
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
// Load pending story data from bulk combine operation
|
|
||||||
useEffect(() => {
|
|
||||||
const fromBulkCombine = searchParams.get('from') === 'bulk-combine';
|
|
||||||
if (fromBulkCombine) {
|
|
||||||
const pendingStoryData = localStorage.getItem('pendingStory');
|
|
||||||
if (pendingStoryData) {
|
|
||||||
try {
|
|
||||||
const storyData = JSON.parse(pendingStoryData);
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
title: storyData.title || '',
|
|
||||||
authorName: storyData.author || '',
|
|
||||||
authorId: undefined, // Reset author ID for bulk combined stories
|
|
||||||
contentHtml: storyData.content || '',
|
|
||||||
sourceUrl: storyData.sourceUrl || '',
|
|
||||||
summary: storyData.summary || '',
|
|
||||||
tags: storyData.tags || []
|
|
||||||
}));
|
|
||||||
// Clear the pending data
|
|
||||||
localStorage.removeItem('pendingStory');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load pending story data:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
// Check for duplicates when title and author are both present
|
|
||||||
useEffect(() => {
|
|
||||||
const checkDuplicates = async () => {
|
|
||||||
const title = formData.title.trim();
|
|
||||||
const authorName = formData.authorName.trim();
|
|
||||||
|
|
||||||
// Don't check if user isn't authenticated or if title/author are empty
|
|
||||||
if (!isAuthenticated || !title || !authorName) {
|
|
||||||
setDuplicateWarning({ show: false, count: 0, duplicates: [] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debounce the check to avoid too many API calls
|
|
||||||
const timeoutId = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
setCheckingDuplicates(true);
|
|
||||||
const result = await storyApi.checkDuplicate(title, authorName);
|
|
||||||
|
|
||||||
if (result.hasDuplicates) {
|
|
||||||
setDuplicateWarning({
|
|
||||||
show: true,
|
|
||||||
count: result.count,
|
|
||||||
duplicates: result.duplicates
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setDuplicateWarning({ show: false, count: 0, duplicates: [] });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to check for duplicates:', error);
|
|
||||||
// Clear any existing duplicate warnings on error
|
|
||||||
setDuplicateWarning({ show: false, count: 0, duplicates: [] });
|
|
||||||
// Don't show error to user as this is just a helpful warning
|
|
||||||
// Authentication errors will be handled by the API interceptor
|
|
||||||
} finally {
|
|
||||||
setCheckingDuplicates(false);
|
|
||||||
}
|
|
||||||
}, 500); // 500ms debounce
|
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
|
||||||
};
|
|
||||||
|
|
||||||
checkDuplicates();
|
|
||||||
}, [formData.title, formData.authorName, isAuthenticated]);
|
|
||||||
|
|
||||||
const handleInputChange = (field: string) => (
|
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
|
||||||
) => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
[field]: e.target.value
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Clear error when user starts typing
|
|
||||||
if (errors[field]) {
|
|
||||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleContentChange = (html: string) => {
|
|
||||||
setFormData(prev => ({ ...prev, contentHtml: html }));
|
|
||||||
if (errors.contentHtml) {
|
|
||||||
setErrors(prev => ({ ...prev, contentHtml: '' }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTagsChange = (tags: string[]) => {
|
|
||||||
setFormData(prev => ({ ...prev, tags }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAuthorChange = (authorName: string, authorId?: string) => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
authorName,
|
|
||||||
authorId: authorId // This will be undefined if creating new author, which clears the existing ID
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Clear error when user changes author
|
|
||||||
if (errors.authorName) {
|
|
||||||
setErrors(prev => ({ ...prev, authorName: '' }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSeriesChange = (seriesName: string, seriesId?: string) => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
seriesName,
|
|
||||||
seriesId: seriesId // This will be undefined if creating new series, which clears the existing ID
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Clear error when user changes series
|
|
||||||
if (errors.seriesName) {
|
|
||||||
setErrors(prev => ({ ...prev, seriesName: '' }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateForm = () => {
|
|
||||||
const newErrors: Record<string, string> = {};
|
|
||||||
|
|
||||||
if (!formData.title.trim()) {
|
|
||||||
newErrors.title = 'Title is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.authorName.trim()) {
|
|
||||||
newErrors.authorName = 'Author name is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.contentHtml.trim()) {
|
|
||||||
newErrors.contentHtml = 'Story content is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.seriesName && !formData.volume) {
|
|
||||||
newErrors.volume = 'Volume number is required when series is specified';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.volume && !formData.seriesName.trim()) {
|
|
||||||
newErrors.seriesName = 'Series name is required when volume is specified';
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// First, create the story with JSON data
|
|
||||||
const storyData = {
|
|
||||||
title: formData.title,
|
|
||||||
summary: formData.summary || undefined,
|
|
||||||
contentHtml: formData.contentHtml,
|
|
||||||
sourceUrl: formData.sourceUrl || undefined,
|
|
||||||
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
|
|
||||||
// Send seriesId if we have it (existing series), otherwise send seriesName (new series)
|
|
||||||
...(formData.seriesId ? { seriesId: formData.seriesId } : { seriesName: formData.seriesName || undefined }),
|
|
||||||
// Send authorId if we have it (existing author), otherwise send authorName (new author)
|
|
||||||
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
|
|
||||||
tagNames: formData.tags.length > 0 ? formData.tags : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push(`/stories/${story.id}/detail`);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to create story:', error);
|
|
||||||
const errorMessage = error.response?.data?.message || 'Failed to create story';
|
|
||||||
setErrors({ submit: errorMessage });
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ImportLayout
|
<ImportLayout
|
||||||
title="Add New Story"
|
title="Add New Story"
|
||||||
description="Add a story to your personal collection"
|
description="Add a story to your personal collection"
|
||||||
>
|
>
|
||||||
{/* Success Message */}
|
<Suspense fallback={
|
||||||
{errors.success && (
|
<div className="flex items-center justify-center py-20">
|
||||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg mb-6">
|
<LoadingSpinner size="lg" />
|
||||||
<p className="text-green-800 dark:text-green-200">{errors.success}</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
}>
|
||||||
|
<AddStoryContent />
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
</Suspense>
|
||||||
{/* Title */}
|
|
||||||
<Input
|
|
||||||
label="Title *"
|
|
||||||
value={formData.title}
|
|
||||||
onChange={handleInputChange('title')}
|
|
||||||
placeholder="Enter the story title"
|
|
||||||
error={errors.title}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Author Selector */}
|
|
||||||
<AuthorSelector
|
|
||||||
label="Author *"
|
|
||||||
value={formData.authorName}
|
|
||||||
onChange={handleAuthorChange}
|
|
||||||
placeholder="Select or enter author name"
|
|
||||||
error={errors.authorName}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Duplicate Warning */}
|
|
||||||
{duplicateWarning.show && (
|
|
||||||
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="text-yellow-600 dark:text-yellow-400 mt-0.5">
|
|
||||||
⚠️
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-yellow-800 dark:text-yellow-200">
|
|
||||||
Potential Duplicate Detected
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
|
|
||||||
Found {duplicateWarning.count} existing {duplicateWarning.count === 1 ? 'story' : 'stories'} with the same title and author:
|
|
||||||
</p>
|
|
||||||
<ul className="mt-2 space-y-1">
|
|
||||||
{duplicateWarning.duplicates.map((duplicate, index) => (
|
|
||||||
<li key={duplicate.id} className="text-sm text-yellow-700 dark:text-yellow-300">
|
|
||||||
• <span className="font-medium">{duplicate.title}</span> by {duplicate.authorName}
|
|
||||||
<span className="text-xs ml-2">
|
|
||||||
(added {new Date(duplicate.createdAt).toLocaleDateString()})
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-2">
|
|
||||||
You can still create this story if it's different from the existing ones.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Checking indicator */}
|
|
||||||
{checkingDuplicates && (
|
|
||||||
<div className="flex items-center gap-2 text-sm theme-text">
|
|
||||||
<div className="animate-spin w-4 h-4 border-2 border-theme-accent border-t-transparent rounded-full"></div>
|
|
||||||
Checking for duplicates...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Summary */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium theme-header mb-2">
|
|
||||||
Summary
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={formData.summary}
|
|
||||||
onChange={handleInputChange('summary')}
|
|
||||||
placeholder="Brief summary or description of the story..."
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
<p className="text-sm theme-text mt-1">
|
|
||||||
Optional summary that will be displayed on the story detail page
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cover Image Upload */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium theme-header mb-2">
|
|
||||||
Cover Image
|
|
||||||
</label>
|
|
||||||
<ImageUpload
|
|
||||||
onImageSelect={setCoverImage}
|
|
||||||
accept="image/jpeg,image/png"
|
|
||||||
maxSizeMB={5}
|
|
||||||
aspectRatio="3:4"
|
|
||||||
placeholder="Drop a cover image here or click to select"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium theme-header mb-2">
|
|
||||||
Story Content *
|
|
||||||
</label>
|
|
||||||
<RichTextEditor
|
|
||||||
value={formData.contentHtml}
|
|
||||||
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 */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium theme-header mb-2">
|
|
||||||
Tags
|
|
||||||
</label>
|
|
||||||
<TagInput
|
|
||||||
tags={formData.tags}
|
|
||||||
onChange={handleTagsChange}
|
|
||||||
placeholder="Add tags to categorize your story..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Series and Volume */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<SeriesSelector
|
|
||||||
label="Series (optional)"
|
|
||||||
value={formData.seriesName}
|
|
||||||
onChange={handleSeriesChange}
|
|
||||||
placeholder="Select or enter series name if part of a series"
|
|
||||||
error={errors.seriesName}
|
|
||||||
authorId={formData.authorId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label="Volume/Part (optional)"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
value={formData.volume}
|
|
||||||
onChange={handleInputChange('volume')}
|
|
||||||
placeholder="Enter volume/part number"
|
|
||||||
error={errors.volume}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Source URL */}
|
|
||||||
<Input
|
|
||||||
label="Source URL (optional)"
|
|
||||||
type="url"
|
|
||||||
value={formData.sourceUrl}
|
|
||||||
onChange={handleInputChange('sourceUrl')}
|
|
||||||
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">
|
|
||||||
<p className="text-red-800 dark:text-red-200">{errors.submit}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex justify-end gap-4 pt-6">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => router.back()}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
loading={loading}
|
|
||||||
disabled={!formData.title || !formData.authorName || !formData.contentHtml}
|
|
||||||
>
|
|
||||||
{processingImages ? 'Processing Images...' : 'Add Story'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</ImportLayout>
|
</ImportLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -139,6 +139,15 @@
|
|||||||
@apply max-w-full h-auto mx-auto my-6 rounded-lg shadow-sm;
|
@apply max-w-full h-auto mx-auto my-6 rounded-lg shadow-sm;
|
||||||
max-height: 80vh; /* Prevent images from being too tall */
|
max-height: 80vh; /* Prevent images from being too tall */
|
||||||
display: block;
|
display: block;
|
||||||
|
/* Optimize for performance and prevent reloading */
|
||||||
|
will-change: auto;
|
||||||
|
transform: translateZ(0); /* Force hardware acceleration */
|
||||||
|
backface-visibility: hidden;
|
||||||
|
image-rendering: optimizeQuality;
|
||||||
|
/* Prevent layout shifts that might trigger reloads */
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* Ensure stable dimensions */
|
||||||
|
min-height: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reading-content img[align="left"] {
|
.reading-content img[align="left"] {
|
||||||
|
|||||||
341
frontend/src/app/library/LibraryContent.tsx
Normal file
341
frontend/src/app/library/LibraryContent.tsx
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { searchApi, storyApi, tagApi } from '../../lib/api';
|
||||||
|
import { Story, Tag, FacetCount, AdvancedFilters } from '../../types/api';
|
||||||
|
import { Input } from '../../components/ui/Input';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
import StoryMultiSelect from '../../components/stories/StoryMultiSelect';
|
||||||
|
import TagFilter from '../../components/stories/TagFilter';
|
||||||
|
import LoadingSpinner from '../../components/ui/LoadingSpinner';
|
||||||
|
import SidebarLayout from '../../components/library/SidebarLayout';
|
||||||
|
import ToolbarLayout from '../../components/library/ToolbarLayout';
|
||||||
|
import MinimalLayout from '../../components/library/MinimalLayout';
|
||||||
|
import { useLibraryLayout } from '../../hooks/useLibraryLayout';
|
||||||
|
|
||||||
|
type ViewMode = 'grid' | 'list';
|
||||||
|
type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating' | 'wordCount' | 'lastRead';
|
||||||
|
|
||||||
|
export default function LibraryContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { layout } = useLibraryLayout();
|
||||||
|
const [stories, setStories] = useState<Story[]>([]);
|
||||||
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
|
const [randomLoading, setRandomLoading] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
|
const [sortOption, setSortOption] = useState<SortOption>('lastRead');
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalElements, setTotalElements] = useState(0);
|
||||||
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
|
const [urlParamsProcessed, setUrlParamsProcessed] = useState(false);
|
||||||
|
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilters>({});
|
||||||
|
|
||||||
|
// Initialize filters from URL parameters
|
||||||
|
useEffect(() => {
|
||||||
|
const tagsParam = searchParams.get('tags');
|
||||||
|
if (tagsParam) {
|
||||||
|
console.log('URL tag filter detected:', tagsParam);
|
||||||
|
// Use functional updates to ensure all state changes happen together
|
||||||
|
setSelectedTags([tagsParam]);
|
||||||
|
setPage(0); // Reset to first page when applying URL filter
|
||||||
|
}
|
||||||
|
setUrlParamsProcessed(true);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// Convert facet counts to Tag objects for the UI, enriched with full tag data
|
||||||
|
const [fullTags, setFullTags] = useState<Tag[]>([]);
|
||||||
|
|
||||||
|
// Fetch full tag data for enrichment
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchFullTags = async () => {
|
||||||
|
try {
|
||||||
|
const result = await tagApi.getTags({ size: 1000 }); // Get all tags
|
||||||
|
setFullTags(result.content || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch full tag data:', error);
|
||||||
|
setFullTags([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchFullTags();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const convertFacetsToTags = (facets?: Record<string, FacetCount[]>): Tag[] => {
|
||||||
|
if (!facets || !facets.tagNames) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return facets.tagNames.map(facet => {
|
||||||
|
// Find the full tag data by name
|
||||||
|
const fullTag = fullTags.find(tag => tag.name.toLowerCase() === facet.value.toLowerCase());
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: fullTag?.id || facet.value, // Use actual ID if available, fallback to name
|
||||||
|
name: facet.value,
|
||||||
|
storyCount: facet.count,
|
||||||
|
// Include color and other metadata from the full tag data
|
||||||
|
color: fullTag?.color,
|
||||||
|
description: fullTag?.description,
|
||||||
|
aliasCount: fullTag?.aliasCount,
|
||||||
|
createdAt: fullTag?.createdAt,
|
||||||
|
aliases: fullTag?.aliases
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enrich existing tags when fullTags are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (fullTags.length > 0) {
|
||||||
|
// Use functional update to get the current tags state
|
||||||
|
setTags(currentTags => {
|
||||||
|
if (currentTags.length > 0) {
|
||||||
|
// Check if tags already have color data to avoid infinite loops
|
||||||
|
const hasColors = currentTags.some(tag => tag.color);
|
||||||
|
if (!hasColors) {
|
||||||
|
// Re-enrich existing tags with color data
|
||||||
|
return currentTags.map(tag => {
|
||||||
|
const fullTag = fullTags.find(ft => ft.name.toLowerCase() === tag.name.toLowerCase());
|
||||||
|
return {
|
||||||
|
...tag,
|
||||||
|
color: fullTag?.color,
|
||||||
|
description: fullTag?.description,
|
||||||
|
aliasCount: fullTag?.aliasCount,
|
||||||
|
createdAt: fullTag?.createdAt,
|
||||||
|
aliases: fullTag?.aliases,
|
||||||
|
id: fullTag?.id || tag.id
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return currentTags; // Return unchanged if no enrichment needed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [fullTags]); // Only run when fullTags change
|
||||||
|
|
||||||
|
// Debounce search to avoid too many API calls
|
||||||
|
useEffect(() => {
|
||||||
|
// Don't run search until URL parameters have been processed
|
||||||
|
if (!urlParamsProcessed) return;
|
||||||
|
|
||||||
|
const debounceTimer = setTimeout(() => {
|
||||||
|
const performSearch = async () => {
|
||||||
|
try {
|
||||||
|
// Use searchLoading for background search, loading only for initial load
|
||||||
|
const isInitialLoad = stories.length === 0 && !searchQuery;
|
||||||
|
if (isInitialLoad) {
|
||||||
|
setLoading(true);
|
||||||
|
} else {
|
||||||
|
setSearchLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always use search API for consistency - use '*' for match-all when no query
|
||||||
|
const apiParams = {
|
||||||
|
query: searchQuery.trim() || '*',
|
||||||
|
page: page, // Use 0-based pagination consistently
|
||||||
|
size: 20,
|
||||||
|
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||||
|
sortBy: sortOption,
|
||||||
|
sortDir: sortDirection,
|
||||||
|
facetBy: ['tagNames'], // Request tag facets for the filter UI
|
||||||
|
// Advanced filters
|
||||||
|
...advancedFilters
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Performing search with params:', apiParams);
|
||||||
|
const result = await searchApi.search(apiParams);
|
||||||
|
|
||||||
|
const currentStories = result?.results || [];
|
||||||
|
setStories(currentStories);
|
||||||
|
setTotalPages(Math.ceil((result?.totalHits || 0) / 20));
|
||||||
|
setTotalElements(result?.totalHits || 0);
|
||||||
|
|
||||||
|
// Update tags from facets - these represent all matching stories, not just current page
|
||||||
|
const resultTags = convertFacetsToTags(result?.facets);
|
||||||
|
setTags(resultTags);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load stories:', error);
|
||||||
|
setStories([]);
|
||||||
|
setTags([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setSearchLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
performSearch();
|
||||||
|
}, searchQuery ? 500 : 0); // Debounce search queries, but load immediately for filters/pagination
|
||||||
|
|
||||||
|
return () => clearTimeout(debounceTimer);
|
||||||
|
}, [searchQuery, selectedTags, sortOption, sortDirection, page, refreshTrigger, urlParamsProcessed, advancedFilters]);
|
||||||
|
|
||||||
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStoryUpdate = () => {
|
||||||
|
setRefreshTrigger(prev => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRandomStory = async () => {
|
||||||
|
if (totalElements === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setRandomLoading(true);
|
||||||
|
const randomStory = await storyApi.getRandomStory({
|
||||||
|
searchQuery: searchQuery || undefined,
|
||||||
|
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||||
|
...advancedFilters
|
||||||
|
});
|
||||||
|
if (randomStory) {
|
||||||
|
router.push(`/stories/${randomStory.id}`);
|
||||||
|
} else {
|
||||||
|
alert('No stories available. Please add some stories first.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get random story:', error);
|
||||||
|
alert('Failed to get a random story. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setRandomLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setSelectedTags([]);
|
||||||
|
setAdvancedFilters({});
|
||||||
|
setPage(0);
|
||||||
|
setRefreshTrigger(prev => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagToggle = (tagName: string) => {
|
||||||
|
setSelectedTags(prev =>
|
||||||
|
prev.includes(tagName)
|
||||||
|
? prev.filter(t => t !== tagName)
|
||||||
|
: [...prev, tagName]
|
||||||
|
);
|
||||||
|
setPage(0);
|
||||||
|
setRefreshTrigger(prev => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSortDirectionToggle = () => {
|
||||||
|
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdvancedFiltersChange = (filters: AdvancedFilters) => {
|
||||||
|
setAdvancedFilters(filters);
|
||||||
|
setPage(0);
|
||||||
|
setRefreshTrigger(prev => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSortChange = (option: string) => {
|
||||||
|
setSortOption(option as SortOption);
|
||||||
|
};
|
||||||
|
|
||||||
|
const layoutProps = {
|
||||||
|
stories,
|
||||||
|
tags,
|
||||||
|
totalElements,
|
||||||
|
searchQuery,
|
||||||
|
selectedTags,
|
||||||
|
viewMode,
|
||||||
|
sortOption,
|
||||||
|
sortDirection,
|
||||||
|
advancedFilters,
|
||||||
|
onSearchChange: handleSearchChange,
|
||||||
|
onTagToggle: handleTagToggle,
|
||||||
|
onViewModeChange: setViewMode,
|
||||||
|
onSortChange: handleSortChange,
|
||||||
|
onSortDirectionToggle: handleSortDirectionToggle,
|
||||||
|
onAdvancedFiltersChange: handleAdvancedFiltersChange,
|
||||||
|
onRandomStory: handleRandomStory,
|
||||||
|
onClearFilters: clearFilters,
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (stories.length === 0 && !loading) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 theme-card theme-shadow rounded-lg">
|
||||||
|
<p className="theme-text text-lg mb-4">
|
||||||
|
{searchQuery || selectedTags.length > 0 || Object.values(advancedFilters).some(v => v !== undefined && v !== '' && v !== 'all' && v !== false)
|
||||||
|
? 'No stories match your search criteria.'
|
||||||
|
: 'Your library is empty.'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
{searchQuery || selectedTags.length > 0 || Object.values(advancedFilters).some(v => v !== undefined && v !== '' && v !== 'all' && v !== false) ? (
|
||||||
|
<Button variant="ghost" onClick={clearFilters}>
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button href="/add-story">
|
||||||
|
Add Your First Story
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StoryMultiSelect
|
||||||
|
stories={stories}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onUpdate={handleStoryUpdate}
|
||||||
|
allowMultiSelect={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex justify-center gap-2 mt-8">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
disabled={page === 0}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<span className="flex items-center px-4 py-2 theme-text">
|
||||||
|
Page {page + 1} of {totalPages}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LayoutComponent = layout === 'sidebar' ? SidebarLayout :
|
||||||
|
layout === 'toolbar' ? ToolbarLayout :
|
||||||
|
MinimalLayout;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LayoutComponent {...layoutProps}>
|
||||||
|
{renderContent()}
|
||||||
|
</LayoutComponent>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,346 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { Suspense } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
import { searchApi, storyApi, tagApi } from '../../lib/api';
|
|
||||||
import { Story, Tag, FacetCount, AdvancedFilters } from '../../types/api';
|
|
||||||
import AppLayout from '../../components/layout/AppLayout';
|
import AppLayout from '../../components/layout/AppLayout';
|
||||||
import { Input } from '../../components/ui/Input';
|
|
||||||
import Button from '../../components/ui/Button';
|
|
||||||
import StoryMultiSelect from '../../components/stories/StoryMultiSelect';
|
|
||||||
import TagFilter from '../../components/stories/TagFilter';
|
|
||||||
import LoadingSpinner from '../../components/ui/LoadingSpinner';
|
import LoadingSpinner from '../../components/ui/LoadingSpinner';
|
||||||
import SidebarLayout from '../../components/library/SidebarLayout';
|
import LibraryContent from './LibraryContent';
|
||||||
import ToolbarLayout from '../../components/library/ToolbarLayout';
|
|
||||||
import MinimalLayout from '../../components/library/MinimalLayout';
|
|
||||||
import { useLibraryLayout } from '../../hooks/useLibraryLayout';
|
|
||||||
|
|
||||||
type ViewMode = 'grid' | 'list';
|
|
||||||
type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating' | 'wordCount' | 'lastRead';
|
|
||||||
|
|
||||||
export default function LibraryPage() {
|
export default function LibraryPage() {
|
||||||
const router = useRouter();
|
return (
|
||||||
const searchParams = useSearchParams();
|
<AppLayout>
|
||||||
const { layout } = useLibraryLayout();
|
<Suspense fallback={
|
||||||
const [stories, setStories] = useState<Story[]>([]);
|
|
||||||
const [tags, setTags] = useState<Tag[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [searchLoading, setSearchLoading] = useState(false);
|
|
||||||
const [randomLoading, setRandomLoading] = useState(false);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
|
||||||
const [sortOption, setSortOption] = useState<SortOption>('lastRead');
|
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
|
||||||
const [totalElements, setTotalElements] = useState(0);
|
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
|
||||||
const [urlParamsProcessed, setUrlParamsProcessed] = useState(false);
|
|
||||||
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilters>({});
|
|
||||||
|
|
||||||
// Initialize filters from URL parameters
|
|
||||||
useEffect(() => {
|
|
||||||
const tagsParam = searchParams.get('tags');
|
|
||||||
if (tagsParam) {
|
|
||||||
console.log('URL tag filter detected:', tagsParam);
|
|
||||||
// Use functional updates to ensure all state changes happen together
|
|
||||||
setSelectedTags([tagsParam]);
|
|
||||||
setPage(0); // Reset to first page when applying URL filter
|
|
||||||
}
|
|
||||||
setUrlParamsProcessed(true);
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
// Convert facet counts to Tag objects for the UI, enriched with full tag data
|
|
||||||
const [fullTags, setFullTags] = useState<Tag[]>([]);
|
|
||||||
|
|
||||||
// Fetch full tag data for enrichment
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchFullTags = async () => {
|
|
||||||
try {
|
|
||||||
const result = await tagApi.getTags({ size: 1000 }); // Get all tags
|
|
||||||
setFullTags(result.content || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch full tag data:', error);
|
|
||||||
setFullTags([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchFullTags();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const convertFacetsToTags = (facets?: Record<string, FacetCount[]>): Tag[] => {
|
|
||||||
if (!facets || !facets.tagNames) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return facets.tagNames.map(facet => {
|
|
||||||
// Find the full tag data by name
|
|
||||||
const fullTag = fullTags.find(tag => tag.name.toLowerCase() === facet.value.toLowerCase());
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: fullTag?.id || facet.value, // Use actual ID if available, fallback to name
|
|
||||||
name: facet.value,
|
|
||||||
storyCount: facet.count,
|
|
||||||
// Include color and other metadata from the full tag data
|
|
||||||
color: fullTag?.color,
|
|
||||||
description: fullTag?.description,
|
|
||||||
aliasCount: fullTag?.aliasCount,
|
|
||||||
createdAt: fullTag?.createdAt,
|
|
||||||
aliases: fullTag?.aliases
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Enrich existing tags when fullTags are loaded
|
|
||||||
useEffect(() => {
|
|
||||||
if (fullTags.length > 0) {
|
|
||||||
// Use functional update to get the current tags state
|
|
||||||
setTags(currentTags => {
|
|
||||||
if (currentTags.length > 0) {
|
|
||||||
// Check if tags already have color data to avoid infinite loops
|
|
||||||
const hasColors = currentTags.some(tag => tag.color);
|
|
||||||
if (!hasColors) {
|
|
||||||
// Re-enrich existing tags with color data
|
|
||||||
return currentTags.map(tag => {
|
|
||||||
const fullTag = fullTags.find(ft => ft.name.toLowerCase() === tag.name.toLowerCase());
|
|
||||||
return {
|
|
||||||
...tag,
|
|
||||||
color: fullTag?.color,
|
|
||||||
description: fullTag?.description,
|
|
||||||
aliasCount: fullTag?.aliasCount,
|
|
||||||
createdAt: fullTag?.createdAt,
|
|
||||||
aliases: fullTag?.aliases,
|
|
||||||
id: fullTag?.id || tag.id
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return currentTags; // Return unchanged if no enrichment needed
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [fullTags]); // Only run when fullTags change
|
|
||||||
|
|
||||||
// Debounce search to avoid too many API calls
|
|
||||||
useEffect(() => {
|
|
||||||
// Don't run search until URL parameters have been processed
|
|
||||||
if (!urlParamsProcessed) return;
|
|
||||||
|
|
||||||
const debounceTimer = setTimeout(() => {
|
|
||||||
const performSearch = async () => {
|
|
||||||
try {
|
|
||||||
// Use searchLoading for background search, loading only for initial load
|
|
||||||
const isInitialLoad = stories.length === 0 && !searchQuery;
|
|
||||||
if (isInitialLoad) {
|
|
||||||
setLoading(true);
|
|
||||||
} else {
|
|
||||||
setSearchLoading(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always use search API for consistency - use '*' for match-all when no query
|
|
||||||
const apiParams = {
|
|
||||||
query: searchQuery.trim() || '*',
|
|
||||||
page: page, // Use 0-based pagination consistently
|
|
||||||
size: 20,
|
|
||||||
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
|
||||||
sortBy: sortOption,
|
|
||||||
sortDir: sortDirection,
|
|
||||||
facetBy: ['tagNames'], // Request tag facets for the filter UI
|
|
||||||
// Advanced filters
|
|
||||||
...advancedFilters
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('Performing search with params:', apiParams);
|
|
||||||
const result = await searchApi.search(apiParams);
|
|
||||||
|
|
||||||
const currentStories = result?.results || [];
|
|
||||||
setStories(currentStories);
|
|
||||||
setTotalPages(Math.ceil((result?.totalHits || 0) / 20));
|
|
||||||
setTotalElements(result?.totalHits || 0);
|
|
||||||
|
|
||||||
// Update tags from facets - these represent all matching stories, not just current page
|
|
||||||
const resultTags = convertFacetsToTags(result?.facets);
|
|
||||||
setTags(resultTags);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load stories:', error);
|
|
||||||
setStories([]);
|
|
||||||
setTags([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setSearchLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
performSearch();
|
|
||||||
}, searchQuery ? 500 : 0); // Debounce search queries, but load immediately for filters/pagination
|
|
||||||
|
|
||||||
return () => clearTimeout(debounceTimer);
|
|
||||||
}, [searchQuery, selectedTags, sortOption, sortDirection, page, refreshTrigger, urlParamsProcessed, advancedFilters]);
|
|
||||||
|
|
||||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setSearchQuery(e.target.value);
|
|
||||||
setPage(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStoryUpdate = () => {
|
|
||||||
setRefreshTrigger(prev => prev + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRandomStory = async () => {
|
|
||||||
if (totalElements === 0) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setRandomLoading(true);
|
|
||||||
const randomStory = await storyApi.getRandomStory({
|
|
||||||
searchQuery: searchQuery || undefined,
|
|
||||||
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
|
||||||
...advancedFilters
|
|
||||||
});
|
|
||||||
if (randomStory) {
|
|
||||||
router.push(`/stories/${randomStory.id}`);
|
|
||||||
} else {
|
|
||||||
alert('No stories available. Please add some stories first.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to get random story:', error);
|
|
||||||
alert('Failed to get a random story. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setRandomLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearFilters = () => {
|
|
||||||
setSearchQuery('');
|
|
||||||
setSelectedTags([]);
|
|
||||||
setAdvancedFilters({});
|
|
||||||
setPage(0);
|
|
||||||
setRefreshTrigger(prev => prev + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTagToggle = (tagName: string) => {
|
|
||||||
setSelectedTags(prev =>
|
|
||||||
prev.includes(tagName)
|
|
||||||
? prev.filter(t => t !== tagName)
|
|
||||||
: [...prev, tagName]
|
|
||||||
);
|
|
||||||
setPage(0);
|
|
||||||
setRefreshTrigger(prev => prev + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSortDirectionToggle = () => {
|
|
||||||
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAdvancedFiltersChange = (filters: AdvancedFilters) => {
|
|
||||||
setAdvancedFilters(filters);
|
|
||||||
setPage(0);
|
|
||||||
setRefreshTrigger(prev => prev + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<AppLayout>
|
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<LoadingSpinner size="lg" />
|
<LoadingSpinner size="lg" />
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
}>
|
||||||
);
|
<LibraryContent />
|
||||||
}
|
</Suspense>
|
||||||
|
|
||||||
const handleSortChange = (option: string) => {
|
|
||||||
setSortOption(option as SortOption);
|
|
||||||
};
|
|
||||||
|
|
||||||
const layoutProps = {
|
|
||||||
stories,
|
|
||||||
tags,
|
|
||||||
totalElements,
|
|
||||||
searchQuery,
|
|
||||||
selectedTags,
|
|
||||||
viewMode,
|
|
||||||
sortOption,
|
|
||||||
sortDirection,
|
|
||||||
advancedFilters,
|
|
||||||
onSearchChange: handleSearchChange,
|
|
||||||
onTagToggle: handleTagToggle,
|
|
||||||
onViewModeChange: setViewMode,
|
|
||||||
onSortChange: handleSortChange,
|
|
||||||
onSortDirectionToggle: handleSortDirectionToggle,
|
|
||||||
onAdvancedFiltersChange: handleAdvancedFiltersChange,
|
|
||||||
onRandomStory: handleRandomStory,
|
|
||||||
onClearFilters: clearFilters,
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderContent = () => {
|
|
||||||
if (stories.length === 0 && !loading) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-12 theme-card theme-shadow rounded-lg">
|
|
||||||
<p className="theme-text text-lg mb-4">
|
|
||||||
{searchQuery || selectedTags.length > 0 || Object.values(advancedFilters).some(v => v !== undefined && v !== '' && v !== 'all' && v !== false)
|
|
||||||
? 'No stories match your search criteria.'
|
|
||||||
: 'Your library is empty.'
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
{searchQuery || selectedTags.length > 0 || Object.values(advancedFilters).some(v => v !== undefined && v !== '' && v !== 'all' && v !== false) ? (
|
|
||||||
<Button variant="ghost" onClick={clearFilters}>
|
|
||||||
Clear Filters
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button href="/add-story">
|
|
||||||
Add Your First Story
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<StoryMultiSelect
|
|
||||||
stories={stories}
|
|
||||||
viewMode={viewMode}
|
|
||||||
onUpdate={handleStoryUpdate}
|
|
||||||
allowMultiSelect={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="flex justify-center gap-2 mt-8">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setPage(page - 1)}
|
|
||||||
disabled={page === 0}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<span className="flex items-center px-4 py-2 theme-text">
|
|
||||||
Page {page + 1} of {totalPages}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setPage(page + 1)}
|
|
||||||
disabled={page >= totalPages - 1}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const LayoutComponent = layout === 'sidebar' ? SidebarLayout :
|
|
||||||
layout === 'toolbar' ? ToolbarLayout :
|
|
||||||
MinimalLayout;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppLayout>
|
|
||||||
<LayoutComponent {...layoutProps}>
|
|
||||||
{renderContent()}
|
|
||||||
</LayoutComponent>
|
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,27 +1,9 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
|
import { progressStore, type ProgressUpdate } from '../../../../lib/progress';
|
||||||
|
|
||||||
// Configure route timeout for long-running progress streams
|
// Configure route timeout for long-running progress streams
|
||||||
export const maxDuration = 900; // 15 minutes (900 seconds)
|
export const maxDuration = 900; // 15 minutes (900 seconds)
|
||||||
|
|
||||||
interface ProgressUpdate {
|
|
||||||
type: 'progress' | 'completed' | 'error';
|
|
||||||
current: number;
|
|
||||||
total: number;
|
|
||||||
message: string;
|
|
||||||
url?: string;
|
|
||||||
title?: string;
|
|
||||||
author?: string;
|
|
||||||
wordCount?: number;
|
|
||||||
totalWordCount?: number;
|
|
||||||
error?: string;
|
|
||||||
combinedStory?: any;
|
|
||||||
results?: any[];
|
|
||||||
summary?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global progress storage (in production, use Redis or database)
|
|
||||||
const progressStore = new Map<string, ProgressUpdate[]>();
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const sessionId = searchParams.get('sessionId');
|
const sessionId = searchParams.get('sessionId');
|
||||||
@@ -81,13 +63,3 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function for other routes to send progress updates
|
|
||||||
export function sendProgressUpdate(sessionId: string, update: ProgressUpdate) {
|
|
||||||
if (!progressStore.has(sessionId)) {
|
|
||||||
progressStore.set(sessionId, []);
|
|
||||||
}
|
|
||||||
progressStore.get(sessionId)!.push(update);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export the helper for other modules to use
|
|
||||||
export { progressStore };
|
|
||||||
@@ -4,15 +4,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
export const maxDuration = 900; // 15 minutes (900 seconds)
|
export const maxDuration = 900; // 15 minutes (900 seconds)
|
||||||
|
|
||||||
// Import progress tracking helper
|
// Import progress tracking helper
|
||||||
async function sendProgressUpdate(sessionId: string, update: any) {
|
import { sendProgressUpdate } from '../../../lib/progress';
|
||||||
try {
|
|
||||||
// Dynamic import to avoid circular dependency
|
|
||||||
const { sendProgressUpdate: sendUpdate } = await import('./progress/route');
|
|
||||||
sendUpdate(sessionId, update);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to send progress update:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BulkImportRequest {
|
interface BulkImportRequest {
|
||||||
urls: string[];
|
urls: string[];
|
||||||
|
|||||||
183
frontend/src/app/settings/SettingsContent.tsx
Normal file
183
frontend/src/app/settings/SettingsContent.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
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 { useTheme } from '../../lib/theme';
|
||||||
|
|
||||||
|
type FontFamily = 'serif' | 'sans' | 'mono';
|
||||||
|
type FontSize = 'small' | 'medium' | 'large' | 'extra-large';
|
||||||
|
type ReadingWidth = 'narrow' | 'medium' | 'wide';
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
theme: 'light' | 'dark';
|
||||||
|
fontFamily: FontFamily;
|
||||||
|
fontSize: FontSize;
|
||||||
|
readingWidth: ReadingWidth;
|
||||||
|
readingSpeed: number; // words per minute
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSettings: Settings = {
|
||||||
|
theme: 'light',
|
||||||
|
fontFamily: 'serif',
|
||||||
|
fontSize: 'medium',
|
||||||
|
readingWidth: 'medium',
|
||||||
|
readingSpeed: 200,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'appearance', label: 'Appearance', icon: '🎨' },
|
||||||
|
{ id: 'content', label: 'Content', icon: '🏷️' },
|
||||||
|
{ id: 'system', label: 'System', icon: '🔧' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const [settings, setSettings] = useState<Settings>(defaultSettings);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
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(() => {
|
||||||
|
const savedSettings = localStorage.getItem('storycove-settings');
|
||||||
|
if (savedSettings) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(savedSettings);
|
||||||
|
setSettings({ ...defaultSettings, ...parsed, theme });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse saved settings:', error);
|
||||||
|
setSettings({ ...defaultSettings, theme });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSettings({ ...defaultSettings, theme });
|
||||||
|
}
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSetting = <K extends keyof Settings>(key: K, value: Settings[K]) => {
|
||||||
|
setSettings(prev => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetToDefaults = () => {
|
||||||
|
setSettings({ ...defaultSettings, theme });
|
||||||
|
};
|
||||||
|
|
||||||
|
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} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 experience and manage system settings
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<TabNavigation
|
||||||
|
tabs={tabs}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={handleTabChange}
|
||||||
|
className="mb-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div className="min-h-[400px]">
|
||||||
|
{renderTabContent()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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={resetToDefaults}
|
||||||
|
>
|
||||||
|
Reset to Defaults
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={saveSettings}
|
||||||
|
className={saved ? 'bg-green-600 hover:bg-green-700' : ''}
|
||||||
|
>
|
||||||
|
{saved ? '✓ Saved!' : 'Save Settings'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,186 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { Suspense } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
import AppLayout from '../../components/layout/AppLayout';
|
import AppLayout from '../../components/layout/AppLayout';
|
||||||
import TabNavigation from '../../components/ui/TabNavigation';
|
import LoadingSpinner from '../../components/ui/LoadingSpinner';
|
||||||
import AppearanceSettings from '../../components/settings/AppearanceSettings';
|
import SettingsContent from './SettingsContent';
|
||||||
import ContentSettings from '../../components/settings/ContentSettings';
|
|
||||||
import SystemSettings from '../../components/settings/SystemSettings';
|
|
||||||
import Button from '../../components/ui/Button';
|
|
||||||
import { useTheme } from '../../lib/theme';
|
|
||||||
|
|
||||||
type FontFamily = 'serif' | 'sans' | 'mono';
|
|
||||||
type FontSize = 'small' | 'medium' | 'large' | 'extra-large';
|
|
||||||
type ReadingWidth = 'narrow' | 'medium' | 'wide';
|
|
||||||
|
|
||||||
interface Settings {
|
|
||||||
theme: 'light' | 'dark';
|
|
||||||
fontFamily: FontFamily;
|
|
||||||
fontSize: FontSize;
|
|
||||||
readingWidth: ReadingWidth;
|
|
||||||
readingSpeed: number; // words per minute
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultSettings: Settings = {
|
|
||||||
theme: 'light',
|
|
||||||
fontFamily: 'serif',
|
|
||||||
fontSize: 'medium',
|
|
||||||
readingWidth: 'medium',
|
|
||||||
readingSpeed: 200,
|
|
||||||
};
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'appearance', label: 'Appearance', icon: '🎨' },
|
|
||||||
{ id: 'content', label: 'Content', icon: '🏷️' },
|
|
||||||
{ id: 'system', label: 'System', icon: '🔧' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const { theme, setTheme } = useTheme();
|
|
||||||
const [settings, setSettings] = useState<Settings>(defaultSettings);
|
|
||||||
const [saved, setSaved] = useState(false);
|
|
||||||
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(() => {
|
|
||||||
const savedSettings = localStorage.getItem('storycove-settings');
|
|
||||||
if (savedSettings) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(savedSettings);
|
|
||||||
setSettings({ ...defaultSettings, ...parsed, theme });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to parse saved settings:', error);
|
|
||||||
setSettings({ ...defaultSettings, theme });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setSettings({ ...defaultSettings, theme });
|
|
||||||
}
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateSetting = <K extends keyof Settings>(key: K, value: Settings[K]) => {
|
|
||||||
setSettings(prev => ({ ...prev, [key]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetToDefaults = () => {
|
|
||||||
setSettings({ ...defaultSettings, theme });
|
|
||||||
};
|
|
||||||
|
|
||||||
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} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<Suspense fallback={
|
||||||
{/* Header */}
|
<div className="flex items-center justify-center py-20">
|
||||||
<div>
|
<LoadingSpinner size="lg" />
|
||||||
<h1 className="text-3xl font-bold theme-header">Settings</h1>
|
|
||||||
<p className="theme-text mt-2">
|
|
||||||
Customize your StoryCove experience and manage system settings
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
}>
|
||||||
{/* Tab Navigation */}
|
<SettingsContent />
|
||||||
<TabNavigation
|
</Suspense>
|
||||||
tabs={tabs}
|
|
||||||
activeTab={activeTab}
|
|
||||||
onTabChange={handleTabChange}
|
|
||||||
className="mb-6"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Tab Content */}
|
|
||||||
<div className="min-h-[400px]">
|
|
||||||
{renderTabContent()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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={resetToDefaults}
|
|
||||||
>
|
|
||||||
Reset to Defaults
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={saveSettings}
|
|
||||||
className={saved ? 'bg-green-600 hover:bg-green-700' : ''}
|
|
||||||
>
|
|
||||||
{saved ? '✓ Saved!' : 'Save Settings'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ import { Input, Textarea } from '../../../../components/ui/Input';
|
|||||||
import Button from '../../../../components/ui/Button';
|
import Button from '../../../../components/ui/Button';
|
||||||
import TagInput from '../../../../components/stories/TagInput';
|
import TagInput from '../../../../components/stories/TagInput';
|
||||||
import TagSuggestions from '../../../../components/tags/TagSuggestions';
|
import TagSuggestions from '../../../../components/tags/TagSuggestions';
|
||||||
import RichTextEditor from '../../../../components/stories/RichTextEditor';
|
import PortableTextEditor from '../../../../components/stories/PortableTextEditorNew';
|
||||||
import ImageUpload from '../../../../components/ui/ImageUpload';
|
import ImageUpload from '../../../../components/ui/ImageUpload';
|
||||||
import AuthorSelector from '../../../../components/stories/AuthorSelector';
|
import AuthorSelector from '../../../../components/stories/AuthorSelector';
|
||||||
import SeriesSelector from '../../../../components/stories/SeriesSelector';
|
import SeriesSelector from '../../../../components/stories/SeriesSelector';
|
||||||
@@ -337,7 +337,7 @@ export default function EditStoryPage() {
|
|||||||
<label className="block text-sm font-medium theme-header mb-2">
|
<label className="block text-sm font-medium theme-header mb-2">
|
||||||
Story Content *
|
Story Content *
|
||||||
</label>
|
</label>
|
||||||
<RichTextEditor
|
<PortableTextEditor
|
||||||
value={formData.contentHtml}
|
value={formData.contentHtml}
|
||||||
onChange={handleContentChange}
|
onChange={handleContentChange}
|
||||||
placeholder="Edit your story content here..."
|
placeholder="Edit your story content here..."
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback, useMemo, memo } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { storyApi, seriesApi } from '../../../lib/api';
|
import { storyApi, seriesApi } from '../../../lib/api';
|
||||||
@@ -11,6 +11,65 @@ import StoryRating from '../../../components/stories/StoryRating';
|
|||||||
import TagDisplay from '../../../components/tags/TagDisplay';
|
import TagDisplay from '../../../components/tags/TagDisplay';
|
||||||
import TableOfContents from '../../../components/stories/TableOfContents';
|
import TableOfContents from '../../../components/stories/TableOfContents';
|
||||||
import { sanitizeHtml, preloadSanitizationConfig } from '../../../lib/sanitization';
|
import { sanitizeHtml, preloadSanitizationConfig } from '../../../lib/sanitization';
|
||||||
|
import { debug } from '../../../lib/debug';
|
||||||
|
|
||||||
|
// Memoized content component that only re-renders when content changes
|
||||||
|
const StoryContent = memo(({
|
||||||
|
content,
|
||||||
|
contentRef
|
||||||
|
}: {
|
||||||
|
content: string;
|
||||||
|
contentRef: React.RefObject<HTMLDivElement>;
|
||||||
|
}) => {
|
||||||
|
const renderTime = Date.now();
|
||||||
|
debug.log('🔄 StoryContent component rendering at', renderTime, 'with content length:', content.length, 'hash:', content.slice(0, 50) + '...');
|
||||||
|
|
||||||
|
// Add observer to track image loading events
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contentRef.current) return;
|
||||||
|
|
||||||
|
const images = contentRef.current.querySelectorAll('img');
|
||||||
|
debug.log('📸 Found', images.length, 'images in content');
|
||||||
|
|
||||||
|
const handleImageLoad = (e: Event) => {
|
||||||
|
const img = e.target as HTMLImageElement;
|
||||||
|
debug.log('🖼️ Image loaded:', img.src);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageError = (e: Event) => {
|
||||||
|
const img = e.target as HTMLImageElement;
|
||||||
|
debug.log('❌ Image error:', img.src);
|
||||||
|
};
|
||||||
|
|
||||||
|
images.forEach(img => {
|
||||||
|
img.addEventListener('load', handleImageLoad);
|
||||||
|
img.addEventListener('error', handleImageError);
|
||||||
|
debug.log('👀 Monitoring image:', img.src);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
images.forEach(img => {
|
||||||
|
img.removeEventListener('load', handleImageLoad);
|
||||||
|
img.removeEventListener('error', handleImageError);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className="reading-content"
|
||||||
|
dangerouslySetInnerHTML={{ __html: content }}
|
||||||
|
style={{
|
||||||
|
// Prevent layout shifts that might cause image reloads
|
||||||
|
minHeight: '100vh',
|
||||||
|
contain: 'layout style'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
StoryContent.displayName = 'StoryContent';
|
||||||
|
|
||||||
export default function StoryReadingPage() {
|
export default function StoryReadingPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -91,14 +150,14 @@ export default function StoryReadingPage() {
|
|||||||
// Debounced function to save reading position
|
// Debounced function to save reading position
|
||||||
const saveReadingPosition = useCallback(async (position: number) => {
|
const saveReadingPosition = useCallback(async (position: number) => {
|
||||||
if (!story || position === story.readingPosition) {
|
if (!story || position === story.readingPosition) {
|
||||||
console.log('Skipping save - no story or position unchanged:', { story: !!story, position, current: story?.readingPosition });
|
debug.log('Skipping save - no story or position unchanged:', { story: !!story, position, current: story?.readingPosition });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Saving reading position:', position, 'for story:', story.id);
|
debug.log('Saving reading position:', position, 'for story:', story.id);
|
||||||
try {
|
try {
|
||||||
const updatedStory = await storyApi.updateReadingProgress(story.id, position);
|
const updatedStory = await storyApi.updateReadingProgress(story.id, position);
|
||||||
console.log('Reading position saved successfully, updated story:', updatedStory.readingPosition);
|
debug.log('Reading position saved successfully, updated story:', updatedStory.readingPosition);
|
||||||
setStory(prev => prev ? { ...prev, readingPosition: position, lastReadAt: updatedStory.lastReadAt } : null);
|
setStory(prev => prev ? { ...prev, readingPosition: position, lastReadAt: updatedStory.lastReadAt } : null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save reading position:', error);
|
console.error('Failed to save reading position:', error);
|
||||||
@@ -179,12 +238,12 @@ export default function StoryReadingPage() {
|
|||||||
if (story && sanitizedContent && !hasScrolledToPosition) {
|
if (story && sanitizedContent && !hasScrolledToPosition) {
|
||||||
// Use a small delay to ensure content is rendered
|
// Use a small delay to ensure content is rendered
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
console.log('Initializing reading position tracking, saved position:', story.readingPosition);
|
debug.log('Initializing reading position tracking, saved position:', story.readingPosition);
|
||||||
|
|
||||||
// Check if there's a hash in the URL (for TOC navigation)
|
// Check if there's a hash in the URL (for TOC navigation)
|
||||||
const hash = window.location.hash.substring(1);
|
const hash = window.location.hash.substring(1);
|
||||||
if (hash && hash.startsWith('heading-')) {
|
if (hash && hash.startsWith('heading-')) {
|
||||||
console.log('Auto-scrolling to heading from URL hash:', hash);
|
debug.log('Auto-scrolling to heading from URL hash:', hash);
|
||||||
const element = document.getElementById(hash);
|
const element = document.getElementById(hash);
|
||||||
if (element) {
|
if (element) {
|
||||||
element.scrollIntoView({
|
element.scrollIntoView({
|
||||||
@@ -198,13 +257,13 @@ export default function StoryReadingPage() {
|
|||||||
|
|
||||||
// Otherwise, use saved reading position
|
// Otherwise, use saved reading position
|
||||||
if (story.readingPosition && story.readingPosition > 0) {
|
if (story.readingPosition && story.readingPosition > 0) {
|
||||||
console.log('Auto-scrolling to saved position:', story.readingPosition);
|
debug.log('Auto-scrolling to saved position:', story.readingPosition);
|
||||||
const initialPercentage = calculateReadingPercentage(story.readingPosition);
|
const initialPercentage = calculateReadingPercentage(story.readingPosition);
|
||||||
setReadingPercentage(initialPercentage);
|
setReadingPercentage(initialPercentage);
|
||||||
scrollToCharacterPosition(story.readingPosition);
|
scrollToCharacterPosition(story.readingPosition);
|
||||||
} else {
|
} else {
|
||||||
// Even if there's no saved position, mark as ready for tracking
|
// Even if there's no saved position, mark as ready for tracking
|
||||||
console.log('No saved position, starting fresh tracking');
|
debug.log('No saved position, starting fresh tracking');
|
||||||
setReadingPercentage(0);
|
setReadingPercentage(0);
|
||||||
setHasScrolledToPosition(true);
|
setHasScrolledToPosition(true);
|
||||||
}
|
}
|
||||||
@@ -216,58 +275,72 @@ export default function StoryReadingPage() {
|
|||||||
|
|
||||||
// Track reading progress and save position
|
// Track reading progress and save position
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let ticking = false;
|
||||||
|
let scrollEventCount = 0;
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
const article = document.querySelector('[data-reading-content]') as HTMLElement;
|
scrollEventCount++;
|
||||||
if (article) {
|
if (scrollEventCount % 10 === 0) {
|
||||||
const scrolled = window.scrollY;
|
debug.log('📜 Scroll event #', scrollEventCount, 'at', Date.now());
|
||||||
const articleTop = article.offsetTop;
|
}
|
||||||
const articleHeight = article.scrollHeight;
|
|
||||||
const windowHeight = window.innerHeight;
|
|
||||||
|
|
||||||
const progress = Math.min(100, Math.max(0,
|
if (!ticking) {
|
||||||
((scrolled - articleTop + windowHeight) / articleHeight) * 100
|
requestAnimationFrame(() => {
|
||||||
));
|
const article = document.querySelector('[data-reading-content]') as HTMLElement;
|
||||||
|
if (article) {
|
||||||
|
const scrolled = window.scrollY;
|
||||||
|
const articleTop = article.offsetTop;
|
||||||
|
const articleHeight = article.scrollHeight;
|
||||||
|
const windowHeight = window.innerHeight;
|
||||||
|
|
||||||
setReadingProgress(progress);
|
const progress = Math.min(100, Math.max(0,
|
||||||
|
((scrolled - articleTop + windowHeight) / articleHeight) * 100
|
||||||
|
));
|
||||||
|
|
||||||
// Multi-method end-of-story detection
|
setReadingProgress(progress);
|
||||||
const documentHeight = document.documentElement.scrollHeight;
|
|
||||||
const windowBottom = scrolled + windowHeight;
|
|
||||||
const distanceFromBottom = documentHeight - windowBottom;
|
|
||||||
|
|
||||||
// Method 1: Distance from bottom (most reliable)
|
// Multi-method end-of-story detection
|
||||||
const nearBottom = distanceFromBottom <= 200;
|
const documentHeight = document.documentElement.scrollHeight;
|
||||||
|
const windowBottom = scrolled + windowHeight;
|
||||||
|
const distanceFromBottom = documentHeight - windowBottom;
|
||||||
|
|
||||||
// Method 2: High progress but only as secondary check
|
// Method 1: Distance from bottom (most reliable)
|
||||||
const highProgress = progress >= 98;
|
const nearBottom = distanceFromBottom <= 200;
|
||||||
|
|
||||||
// Method 3: Check if story content itself is fully visible
|
// Method 2: High progress but only as secondary check
|
||||||
const storyContentElement = contentRef.current;
|
const highProgress = progress >= 98;
|
||||||
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)
|
// Method 3: Check if story content itself is fully visible
|
||||||
if (nearBottom && (highProgress || storyContentFullyVisible) && !hasReachedEnd && hasScrolledToPosition) {
|
const storyContentElement = contentRef.current;
|
||||||
console.log('End of story detected:', { nearBottom, highProgress, storyContentFullyVisible, distanceFromBottom, progress });
|
let storyContentFullyVisible = false;
|
||||||
setHasReachedEnd(true);
|
if (storyContentElement) {
|
||||||
setShowEndOfStoryPopup(true);
|
const contentRect = storyContentElement.getBoundingClientRect();
|
||||||
}
|
const contentBottom = scrolled + contentRect.bottom;
|
||||||
|
const documentContentHeight = Math.max(documentHeight - 300, contentBottom); // Account for footer padding
|
||||||
|
storyContentFullyVisible = windowBottom >= documentContentHeight;
|
||||||
|
}
|
||||||
|
|
||||||
// Save reading position and update percentage (debounced)
|
// Trigger end detection if user is near bottom AND (has high progress OR story content is fully visible)
|
||||||
if (hasScrolledToPosition) { // Only save after initial auto-scroll
|
if (nearBottom && (highProgress || storyContentFullyVisible) && !hasReachedEnd && hasScrolledToPosition) {
|
||||||
const characterPosition = getCharacterPositionFromScroll();
|
debug.log('End of story detected:', { nearBottom, highProgress, storyContentFullyVisible, distanceFromBottom, progress });
|
||||||
const percentage = calculateReadingPercentage(characterPosition);
|
setHasReachedEnd(true);
|
||||||
console.log('Scroll detected, character position:', characterPosition, 'percentage:', percentage);
|
setShowEndOfStoryPopup(true);
|
||||||
setReadingPercentage(percentage);
|
}
|
||||||
debouncedSavePosition(characterPosition);
|
|
||||||
} else {
|
// Save reading position and update percentage (debounced)
|
||||||
console.log('Scroll detected but not ready for tracking yet');
|
if (hasScrolledToPosition) { // Only save after initial auto-scroll
|
||||||
}
|
const characterPosition = getCharacterPositionFromScroll();
|
||||||
|
const percentage = calculateReadingPercentage(characterPosition);
|
||||||
|
debug.log('Scroll detected, character position:', characterPosition, 'percentage:', percentage);
|
||||||
|
setReadingPercentage(percentage);
|
||||||
|
debouncedSavePosition(characterPosition);
|
||||||
|
} else {
|
||||||
|
debug.log('Scroll detected but not ready for tracking yet');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ticking = false;
|
||||||
|
});
|
||||||
|
ticking = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -329,6 +402,11 @@ export default function StoryReadingPage() {
|
|||||||
const nextStory = findNextStory();
|
const nextStory = findNextStory();
|
||||||
const previousStory = findPreviousStory();
|
const previousStory = findPreviousStory();
|
||||||
|
|
||||||
|
// Memoize the sanitized content to prevent re-processing on scroll
|
||||||
|
const memoizedContent = useMemo(() => {
|
||||||
|
return sanitizedContent;
|
||||||
|
}, [sanitizedContent]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen theme-bg flex items-center justify-center">
|
<div className="min-h-screen theme-bg flex items-center justify-center">
|
||||||
@@ -535,10 +613,10 @@ export default function StoryReadingPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Story Content */}
|
{/* Story Content */}
|
||||||
<div
|
<StoryContent
|
||||||
ref={contentRef}
|
key={`story-content-${story?.id || 'loading'}`}
|
||||||
className="reading-content"
|
content={memoizedContent}
|
||||||
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
|
contentRef={contentRef}
|
||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode, Suspense } from 'react';
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname, useSearchParams } from 'next/navigation';
|
|
||||||
import AppLayout from './AppLayout';
|
import AppLayout from './AppLayout';
|
||||||
|
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||||
interface ImportTab {
|
import ImportLayoutContent from './ImportLayoutContent';
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImportLayoutProps {
|
interface ImportLayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -18,112 +11,23 @@ interface ImportLayoutProps {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const importTabs: ImportTab[] = [
|
export default function ImportLayout({
|
||||||
{
|
children,
|
||||||
id: 'manual',
|
title,
|
||||||
label: 'Manual Entry',
|
description
|
||||||
href: '/add-story',
|
}: ImportLayoutProps) {
|
||||||
description: 'Add a story by manually entering details'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'url',
|
|
||||||
label: 'Import from URL',
|
|
||||||
href: '/import',
|
|
||||||
description: 'Import a single story from a website'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'epub',
|
|
||||||
label: 'Import EPUB',
|
|
||||||
href: '/import/epub',
|
|
||||||
description: 'Import a story from an EPUB file'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bulk',
|
|
||||||
label: 'Bulk Import',
|
|
||||||
href: '/import/bulk',
|
|
||||||
description: 'Import multiple stories from a list of URLs'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ImportLayout({ children, title, description }: ImportLayoutProps) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const mode = searchParams.get('mode');
|
|
||||||
|
|
||||||
// Determine which tab is active
|
|
||||||
const getActiveTab = () => {
|
|
||||||
if (pathname === '/add-story') {
|
|
||||||
return 'manual';
|
|
||||||
} else if (pathname === '/import') {
|
|
||||||
return 'url';
|
|
||||||
} else if (pathname === '/import/epub') {
|
|
||||||
return 'epub';
|
|
||||||
} else if (pathname === '/import/bulk') {
|
|
||||||
return 'bulk';
|
|
||||||
}
|
|
||||||
return 'manual';
|
|
||||||
};
|
|
||||||
|
|
||||||
const activeTab = getActiveTab();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto">
|
||||||
{/* Header */}
|
<Suspense fallback={
|
||||||
<div className="text-center">
|
<div className="flex items-center justify-center py-20">
|
||||||
<h1 className="text-3xl font-bold theme-header">{title}</h1>
|
<LoadingSpinner size="lg" />
|
||||||
{description && (
|
|
||||||
<p className="theme-text mt-2 text-lg">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
|
||||||
<div className="theme-card theme-shadow rounded-lg overflow-hidden">
|
|
||||||
{/* Tab Headers */}
|
|
||||||
<div className="flex border-b theme-border overflow-x-auto">
|
|
||||||
{importTabs.map((tab) => (
|
|
||||||
<Link
|
|
||||||
key={tab.id}
|
|
||||||
href={tab.href}
|
|
||||||
className={`flex-1 min-w-0 px-4 py-3 text-sm font-medium text-center transition-colors whitespace-nowrap ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'theme-accent-bg text-white border-b-2 border-transparent'
|
|
||||||
: 'theme-text hover:theme-accent-light hover:theme-accent-text'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="truncate">
|
|
||||||
{tab.label}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
}>
|
||||||
{/* Tab Descriptions */}
|
<ImportLayoutContent title={title} description={description}>
|
||||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-800/50">
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<p className="text-sm theme-text text-center">
|
|
||||||
{importTabs.find(tab => tab.id === activeTab)?.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Content */}
|
|
||||||
<div className="p-6">
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</ImportLayoutContent>
|
||||||
</div>
|
</Suspense>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<Link
|
|
||||||
href="/library"
|
|
||||||
className="theme-text hover:theme-accent transition-colors text-sm"
|
|
||||||
>
|
|
||||||
← Back to Library
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
116
frontend/src/components/layout/ImportLayoutContent.tsx
Normal file
116
frontend/src/components/layout/ImportLayoutContent.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
interface ImportTab {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportLayoutContentProps {
|
||||||
|
children: ReactNode;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const importTabs: ImportTab[] = [
|
||||||
|
{
|
||||||
|
id: 'manual',
|
||||||
|
label: 'Manual Entry',
|
||||||
|
href: '/add-story',
|
||||||
|
description: 'Add a story by manually entering details'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'url',
|
||||||
|
label: 'Import from URL',
|
||||||
|
href: '/import',
|
||||||
|
description: 'Import a single story from a website'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'epub',
|
||||||
|
label: 'Import EPUB',
|
||||||
|
href: '/import/epub',
|
||||||
|
description: 'Import a story from an EPUB file'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bulk',
|
||||||
|
label: 'Bulk Import',
|
||||||
|
href: '/import/bulk',
|
||||||
|
description: 'Import multiple stories from URLs'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ImportLayoutContent({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
description
|
||||||
|
}: ImportLayoutContentProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
// Determine active tab based on current path
|
||||||
|
const activeTab = importTabs.find(tab => {
|
||||||
|
if (tab.href === pathname) return true;
|
||||||
|
if (tab.href === '/import' && pathname === '/import') return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold theme-header">{title}</h1>
|
||||||
|
{description && (
|
||||||
|
<p className="theme-text mt-2">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/library"
|
||||||
|
className="inline-flex items-center px-4 py-2 text-sm font-medium theme-button theme-border border rounded-lg hover:theme-button-hover transition-colors"
|
||||||
|
>
|
||||||
|
← Back to Library
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Import Method Tabs */}
|
||||||
|
<div className="border-b theme-border">
|
||||||
|
<nav className="-mb-px flex space-x-8 overflow-x-auto">
|
||||||
|
{importTabs.map((tab) => {
|
||||||
|
const isActive = activeTab?.id === tab.id;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.id}
|
||||||
|
href={tab.href}
|
||||||
|
className={`
|
||||||
|
group inline-flex items-center px-1 py-4 border-b-2 font-medium text-sm whitespace-nowrap
|
||||||
|
${isActive
|
||||||
|
? 'border-theme-accent text-theme-accent'
|
||||||
|
: 'border-transparent theme-text hover:text-theme-header hover:border-gray-300'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span className="flex flex-col">
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
<span className="text-xs theme-text mt-1 group-hover:text-theme-header">
|
||||||
|
{tab.description}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div className="flex-1">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
610
frontend/src/components/stories/PortableTextEditor.tsx
Normal file
610
frontend/src/components/stories/PortableTextEditor.tsx
Normal file
@@ -0,0 +1,610 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { PortableText } from '@portabletext/react';
|
||||||
|
import type { PortableTextBlock } from '@portabletext/types';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import { Textarea } from '../ui/Input';
|
||||||
|
import { sanitizeHtmlSync } from '../../lib/sanitization';
|
||||||
|
import { storyApi } from '../../lib/api';
|
||||||
|
import {
|
||||||
|
htmlToPortableText,
|
||||||
|
portableTextToHtml,
|
||||||
|
parseHtmlToBlocks
|
||||||
|
} from '../../lib/portabletext/conversion';
|
||||||
|
import {
|
||||||
|
createTextBlock,
|
||||||
|
createImageBlock,
|
||||||
|
emptyPortableTextContent,
|
||||||
|
portableTextSchema
|
||||||
|
} from '../../lib/portabletext/schema';
|
||||||
|
import type { CustomPortableTextBlock } from '../../lib/portabletext/schema';
|
||||||
|
|
||||||
|
interface PortableTextEditorProps {
|
||||||
|
value: string; // HTML value for compatibility
|
||||||
|
onChange: (value: string) => void; // Returns HTML for compatibility
|
||||||
|
placeholder?: string;
|
||||||
|
error?: string;
|
||||||
|
storyId?: string;
|
||||||
|
enableImageProcessing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PortableTextEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Write your story here...',
|
||||||
|
error,
|
||||||
|
storyId,
|
||||||
|
enableImageProcessing = false
|
||||||
|
}: PortableTextEditorProps) {
|
||||||
|
console.log('🎯 PortableTextEditor loaded!', { value: value?.length, enableImageProcessing });
|
||||||
|
const [viewMode, setViewMode] = useState<'visual' | 'html'>('visual');
|
||||||
|
const [portableTextValue, setPortableTextValue] = useState<CustomPortableTextBlock[]>(emptyPortableTextContent);
|
||||||
|
const [htmlValue, setHtmlValue] = useState(value);
|
||||||
|
const [isMaximized, setIsMaximized] = useState(false);
|
||||||
|
const [containerHeight, setContainerHeight] = useState(300);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const editableRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Initialize Portable Text content from HTML value
|
||||||
|
useEffect(() => {
|
||||||
|
if (value && value !== htmlValue) {
|
||||||
|
const blocks = parseHtmlToBlocks(value);
|
||||||
|
setPortableTextValue(blocks);
|
||||||
|
setHtmlValue(value);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// Convert Portable Text to HTML when content changes
|
||||||
|
const updateHtmlFromPortableText = useCallback((blocks: CustomPortableTextBlock[]) => {
|
||||||
|
const html = portableTextToHtml(blocks);
|
||||||
|
setHtmlValue(html);
|
||||||
|
onChange(html);
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
// Image processing functionality (maintained from original)
|
||||||
|
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];
|
||||||
|
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;
|
||||||
|
|
||||||
|
const newUrls = imageUrls.filter(url => !processedImages.has(url));
|
||||||
|
if (newUrls.length === 0) return;
|
||||||
|
|
||||||
|
setImageProcessingQueue(prev => [...prev, ...newUrls]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await storyApi.processContentImages(storyId, content);
|
||||||
|
setProcessedImages(prev => new Set([...Array.from(prev), ...newUrls]));
|
||||||
|
setImageProcessingQueue(prev => prev.filter(url => !newUrls.includes(url)));
|
||||||
|
|
||||||
|
if (result.processedContent !== content) {
|
||||||
|
const newBlocks = parseHtmlToBlocks(result.processedContent);
|
||||||
|
setPortableTextValue(newBlocks);
|
||||||
|
onChange(result.processedContent);
|
||||||
|
setHtmlValue(result.processedContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.hasWarnings && result.warnings) {
|
||||||
|
setImageWarnings(prev => [...prev, ...result.warnings!]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to process content images:', error);
|
||||||
|
setImageProcessingQueue(prev => prev.filter(url => !newUrls.includes(url)));
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (imageProcessingTimeoutRef.current) {
|
||||||
|
clearTimeout(imageProcessingTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
imageProcessingTimeoutRef.current = setTimeout(() => {
|
||||||
|
processContentImagesDebounced(content);
|
||||||
|
}, 2000);
|
||||||
|
}, [enableImageProcessing, storyId, processContentImagesDebounced]);
|
||||||
|
|
||||||
|
// Toolbar functionality
|
||||||
|
const insertTextWithFormat = (format: string) => {
|
||||||
|
const newBlock = createTextBlock('New ' + format, format === 'normal' ? 'normal' : format);
|
||||||
|
const newBlocks = [...portableTextValue, newBlock];
|
||||||
|
setPortableTextValue(newBlocks);
|
||||||
|
updateHtmlFromPortableText(newBlocks);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatText = useCallback((format: string) => {
|
||||||
|
if (viewMode === 'visual') {
|
||||||
|
// In visual mode, add a new formatted block
|
||||||
|
insertTextWithFormat(format);
|
||||||
|
} else {
|
||||||
|
// HTML mode - maintain original functionality
|
||||||
|
const textarea = document.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const selectedText = htmlValue.substring(start, end);
|
||||||
|
|
||||||
|
if (selectedText) {
|
||||||
|
const beforeText = htmlValue.substring(0, start);
|
||||||
|
const afterText = htmlValue.substring(end);
|
||||||
|
const formattedText = `<${format}>${selectedText}</${format}>`;
|
||||||
|
const newValue = beforeText + formattedText + afterText;
|
||||||
|
|
||||||
|
setHtmlValue(newValue);
|
||||||
|
onChange(newValue);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(start, start + formattedText.length);
|
||||||
|
}, 0);
|
||||||
|
} else {
|
||||||
|
const template = format === 'h1' ? '<h1>Heading 1</h1>' :
|
||||||
|
format === 'h2' ? '<h2>Heading 2</h2>' :
|
||||||
|
format === 'h3' ? '<h3>Heading 3</h3>' :
|
||||||
|
format === 'h4' ? '<h4>Heading 4</h4>' :
|
||||||
|
format === 'h5' ? '<h5>Heading 5</h5>' :
|
||||||
|
format === 'h6' ? '<h6>Heading 6</h6>' :
|
||||||
|
`<${format}>Formatted text</${format}>`;
|
||||||
|
|
||||||
|
const newValue = htmlValue.substring(0, start) + template + htmlValue.substring(start);
|
||||||
|
setHtmlValue(newValue);
|
||||||
|
onChange(newValue);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const tagLength = `<${format}>`.length;
|
||||||
|
const newPosition = start + tagLength;
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(newPosition, newPosition + (template.includes('Heading') ? template.split('>')[1].split('<')[0].length : 'Formatted text'.length));
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [viewMode, htmlValue, onChange, portableTextValue, updateHtmlFromPortableText]);
|
||||||
|
|
||||||
|
// Handle HTML mode changes
|
||||||
|
const handleHtmlChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const html = e.target.value;
|
||||||
|
setHtmlValue(html);
|
||||||
|
onChange(html);
|
||||||
|
|
||||||
|
// Update Portable Text representation
|
||||||
|
const blocks = parseHtmlToBlocks(html);
|
||||||
|
setPortableTextValue(blocks);
|
||||||
|
|
||||||
|
triggerImageProcessing(html);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle visual mode content changes
|
||||||
|
const handleVisualContentChange = () => {
|
||||||
|
if (editableRef.current) {
|
||||||
|
const html = editableRef.current.innerHTML;
|
||||||
|
const blocks = parseHtmlToBlocks(html);
|
||||||
|
setPortableTextValue(blocks);
|
||||||
|
updateHtmlFromPortableText(blocks);
|
||||||
|
triggerImageProcessing(html);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Paste handling
|
||||||
|
const handlePaste = async (e: React.ClipboardEvent<HTMLDivElement>) => {
|
||||||
|
if (viewMode !== 'visual') return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const clipboardData = e.clipboardData;
|
||||||
|
let htmlContent = '';
|
||||||
|
let plainText = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
htmlContent = clipboardData.getData('text/html');
|
||||||
|
plainText = clipboardData.getData('text/plain');
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Direct getData failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (htmlContent && htmlContent.trim().length > 0) {
|
||||||
|
let processedHtml = htmlContent;
|
||||||
|
|
||||||
|
if (enableImageProcessing && storyId) {
|
||||||
|
const hasImages = /<img[^>]+src=['"'][^'"']*['"][^>]*>/i.test(htmlContent);
|
||||||
|
if (hasImages) {
|
||||||
|
try {
|
||||||
|
const result = await storyApi.processContentImages(storyId, htmlContent);
|
||||||
|
processedHtml = result.processedContent;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedHtml = sanitizeHtmlSync(processedHtml);
|
||||||
|
const blocks = parseHtmlToBlocks(sanitizedHtml);
|
||||||
|
|
||||||
|
// Insert at current position
|
||||||
|
const newBlocks = [...portableTextValue, ...blocks];
|
||||||
|
setPortableTextValue(newBlocks);
|
||||||
|
updateHtmlFromPortableText(newBlocks);
|
||||||
|
|
||||||
|
} else if (plainText && plainText.trim().length > 0) {
|
||||||
|
const textBlocks = plainText
|
||||||
|
.split('\n\n')
|
||||||
|
.filter(p => p.trim())
|
||||||
|
.map(p => createTextBlock(p.trim()));
|
||||||
|
|
||||||
|
const newBlocks = [...portableTextValue, ...textBlocks];
|
||||||
|
setPortableTextValue(newBlocks);
|
||||||
|
updateHtmlFromPortableText(newBlocks);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling paste:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Maximize/minimize functionality
|
||||||
|
const toggleMaximize = () => {
|
||||||
|
if (!isMaximized) {
|
||||||
|
if (containerRef.current) {
|
||||||
|
setContainerHeight(containerRef.current.scrollHeight || containerHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsMaximized(!isMaximized);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isMaximized) {
|
||||||
|
setIsMaximized(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {
|
||||||
|
const num = parseInt(e.key);
|
||||||
|
if (num >= 1 && num <= 6) {
|
||||||
|
e.preventDefault();
|
||||||
|
formatText(`h${num}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
|
||||||
|
switch (e.key.toLowerCase()) {
|
||||||
|
case 'b':
|
||||||
|
e.preventDefault();
|
||||||
|
formatText('strong');
|
||||||
|
return;
|
||||||
|
case 'i':
|
||||||
|
e.preventDefault();
|
||||||
|
formatText('em');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
if (isMaximized) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [isMaximized, formatText]);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (imageProcessingTimeoutRef.current) {
|
||||||
|
clearTimeout(imageProcessingTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Custom components for Portable Text rendering
|
||||||
|
const portableTextComponents = {
|
||||||
|
types: {
|
||||||
|
image: ({ value }: { value: any }) => (
|
||||||
|
<div className="image-block my-4">
|
||||||
|
<img
|
||||||
|
src={value.src}
|
||||||
|
alt={value.alt || ''}
|
||||||
|
className="max-w-full h-auto"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{value.caption && (
|
||||||
|
<p className="text-sm text-gray-600 mt-2 italic">{value.caption}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
block: {
|
||||||
|
normal: ({ children }: any) => <p className="mb-2">{children}</p>,
|
||||||
|
h1: ({ children }: any) => <h1 className="text-3xl font-bold mb-4">{children}</h1>,
|
||||||
|
h2: ({ children }: any) => <h2 className="text-2xl font-bold mb-3">{children}</h2>,
|
||||||
|
h3: ({ children }: any) => <h3 className="text-xl font-bold mb-3">{children}</h3>,
|
||||||
|
h4: ({ children }: any) => <h4 className="text-lg font-bold mb-2">{children}</h4>,
|
||||||
|
h5: ({ children }: any) => <h5 className="text-base font-bold mb-2">{children}</h5>,
|
||||||
|
h6: ({ children }: any) => <h6 className="text-sm font-bold mb-2">{children}</h6>,
|
||||||
|
blockquote: ({ children }: any) => (
|
||||||
|
<blockquote className="border-l-4 border-gray-300 pl-4 italic my-4">{children}</blockquote>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
marks: {
|
||||||
|
strong: ({ children }: any) => <strong>{children}</strong>,
|
||||||
|
em: ({ children }: any) => <em>{children}</em>,
|
||||||
|
underline: ({ children }: any) => <u>{children}</u>,
|
||||||
|
strike: ({ children }: any) => <s>{children}</s>,
|
||||||
|
code: ({ children }: any) => (
|
||||||
|
<code className="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">{children}</code>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center justify-between p-2 theme-card border theme-border rounded-t-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
|
||||||
|
✨ Portable Text Editor
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setViewMode('visual')}
|
||||||
|
className={viewMode === 'visual' ? 'theme-accent-bg text-white' : ''}
|
||||||
|
>
|
||||||
|
Visual
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setViewMode('html')}
|
||||||
|
className={viewMode === 'html' ? 'theme-accent-bg text-white' : ''}
|
||||||
|
>
|
||||||
|
HTML
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Image processing status */}
|
||||||
|
{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"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={toggleMaximize}
|
||||||
|
title={isMaximized ? "Minimize editor" : "Maximize editor"}
|
||||||
|
className="font-mono"
|
||||||
|
>
|
||||||
|
{isMaximized ? "⊡" : "⊞"}
|
||||||
|
</Button>
|
||||||
|
<div className="w-px h-4 bg-gray-300 mx-1" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => formatText('strong')}
|
||||||
|
title="Bold (Ctrl+B)"
|
||||||
|
className="font-bold"
|
||||||
|
>
|
||||||
|
B
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => formatText('em')}
|
||||||
|
title="Italic (Ctrl+I)"
|
||||||
|
className="italic"
|
||||||
|
>
|
||||||
|
I
|
||||||
|
</Button>
|
||||||
|
<div className="w-px h-4 bg-gray-300 mx-1" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => formatText('h1')}
|
||||||
|
title="Heading 1 (Ctrl+Shift+1)"
|
||||||
|
className="text-lg font-bold"
|
||||||
|
>
|
||||||
|
H1
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => formatText('h2')}
|
||||||
|
title="Heading 2 (Ctrl+Shift+2)"
|
||||||
|
className="text-base font-bold"
|
||||||
|
>
|
||||||
|
H2
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => formatText('h3')}
|
||||||
|
title="Heading 3 (Ctrl+Shift+3)"
|
||||||
|
className="text-sm font-bold"
|
||||||
|
>
|
||||||
|
H3
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => formatText('h4')}
|
||||||
|
title="Heading 4 (Ctrl+Shift+4)"
|
||||||
|
className="text-xs font-bold"
|
||||||
|
>
|
||||||
|
H4
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => formatText('h5')}
|
||||||
|
title="Heading 5 (Ctrl+Shift+5)"
|
||||||
|
className="text-xs font-bold"
|
||||||
|
>
|
||||||
|
H5
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => formatText('h6')}
|
||||||
|
title="Heading 6 (Ctrl+Shift+6)"
|
||||||
|
className="text-xs font-bold"
|
||||||
|
>
|
||||||
|
H6
|
||||||
|
</Button>
|
||||||
|
<div className="w-px h-4 bg-gray-300 mx-1" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => formatText('p')}
|
||||||
|
title="Paragraph"
|
||||||
|
>
|
||||||
|
P
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor */}
|
||||||
|
<div
|
||||||
|
className={`relative border theme-border rounded-b-lg ${
|
||||||
|
isMaximized ? 'fixed inset-4 z-50 bg-white dark:bg-gray-900 shadow-2xl' : ''
|
||||||
|
}`}
|
||||||
|
style={isMaximized ? {} : { height: containerHeight }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="h-full flex flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Editor content */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{viewMode === 'visual' ? (
|
||||||
|
<div className="relative h-full">
|
||||||
|
<div
|
||||||
|
ref={editableRef}
|
||||||
|
contentEditable
|
||||||
|
onInput={handleVisualContentChange}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
className="p-3 h-full overflow-y-auto focus:outline-none focus:ring-0 resize-none"
|
||||||
|
suppressContentEditableWarning={true}
|
||||||
|
>
|
||||||
|
<PortableText
|
||||||
|
value={portableTextValue}
|
||||||
|
components={portableTextComponents}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(!portableTextValue || portableTextValue.length === 0 ||
|
||||||
|
(portableTextValue.length === 1 && !portableTextValue[0])) && (
|
||||||
|
<div className="absolute top-3 left-3 text-gray-500 dark:text-gray-400 pointer-events-none select-none">
|
||||||
|
{placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Textarea
|
||||||
|
value={htmlValue}
|
||||||
|
onChange={handleHtmlChange}
|
||||||
|
placeholder="<p>Write your HTML content here...</p>"
|
||||||
|
className="border-0 rounded-none focus:ring-0 font-mono text-sm h-full resize-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview for HTML mode */}
|
||||||
|
{viewMode === 'html' && htmlValue && !isMaximized && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium theme-header">Preview:</h4>
|
||||||
|
<div className="p-4 border theme-border rounded-lg theme-card max-h-40 overflow-y-auto">
|
||||||
|
<PortableText
|
||||||
|
value={portableTextValue}
|
||||||
|
components={portableTextComponents}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-xs theme-text">
|
||||||
|
<p>
|
||||||
|
<strong>Visual mode:</strong> Structured content editor with rich formatting.
|
||||||
|
Paste content from websites and it will be converted to structured format.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>HTML mode:</strong> Edit HTML source directly for advanced formatting.
|
||||||
|
Content is automatically sanitized for security.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Keyboard shortcuts:</strong> Ctrl+B (Bold), Ctrl+I (Italic), Ctrl+Shift+1-6 (Headings 1-6).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
672
frontend/src/components/stories/PortableTextEditorNew.tsx
Normal file
672
frontend/src/components/stories/PortableTextEditorNew.tsx
Normal file
@@ -0,0 +1,672 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
EditorProvider,
|
||||||
|
PortableTextEditable,
|
||||||
|
useEditor,
|
||||||
|
type PortableTextBlock,
|
||||||
|
type RenderDecoratorFunction,
|
||||||
|
type RenderStyleFunction,
|
||||||
|
type RenderBlockFunction,
|
||||||
|
type RenderListItemFunction,
|
||||||
|
type RenderAnnotationFunction
|
||||||
|
} from '@portabletext/editor';
|
||||||
|
import { PortableText } from '@portabletext/react';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import { sanitizeHtmlSync } from '../../lib/sanitization';
|
||||||
|
import { editorSchema } from '../../lib/portabletext/editorSchema';
|
||||||
|
import { debug } from '../../lib/debug';
|
||||||
|
|
||||||
|
interface PortableTextEditorProps {
|
||||||
|
value: string; // HTML value for compatibility - will be converted
|
||||||
|
onChange: (value: string) => void; // Returns HTML for compatibility
|
||||||
|
placeholder?: string;
|
||||||
|
error?: string;
|
||||||
|
storyId?: string;
|
||||||
|
enableImageProcessing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversion utilities
|
||||||
|
function htmlToPortableTextBlocks(html: string): PortableTextBlock[] {
|
||||||
|
if (!html || html.trim() === '') {
|
||||||
|
return [{ _type: 'block', _key: generateKey(), style: 'normal', markDefs: [], children: [{ _type: 'span', _key: generateKey(), text: '', marks: [] }] }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic HTML to Portable Text conversion
|
||||||
|
// This is a simplified implementation - you could enhance this
|
||||||
|
const sanitizedHtml = sanitizeHtmlSync(html);
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(sanitizedHtml, 'text/html');
|
||||||
|
|
||||||
|
const blocks: PortableTextBlock[] = [];
|
||||||
|
const paragraphs = doc.querySelectorAll('p, h1, h2, h3, h4, h5, h6, blockquote, div');
|
||||||
|
|
||||||
|
if (paragraphs.length === 0) {
|
||||||
|
// Fallback: treat as single paragraph
|
||||||
|
return [{
|
||||||
|
_type: 'block',
|
||||||
|
_key: generateKey(),
|
||||||
|
style: 'normal',
|
||||||
|
markDefs: [],
|
||||||
|
children: [{
|
||||||
|
_type: 'span',
|
||||||
|
_key: generateKey(),
|
||||||
|
text: doc.body.textContent || '',
|
||||||
|
marks: []
|
||||||
|
}]
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process all elements in document order to maintain sequence
|
||||||
|
const allElements = Array.from(doc.body.querySelectorAll('*'));
|
||||||
|
const processedElements = new Set<Element>();
|
||||||
|
|
||||||
|
for (const element of allElements) {
|
||||||
|
// Skip if already processed
|
||||||
|
if (processedElements.has(element)) continue;
|
||||||
|
|
||||||
|
// Handle images
|
||||||
|
if (element.tagName === 'IMG') {
|
||||||
|
const img = element as HTMLImageElement;
|
||||||
|
blocks.push({
|
||||||
|
_type: 'image',
|
||||||
|
_key: generateKey(),
|
||||||
|
src: img.getAttribute('src') || '',
|
||||||
|
alt: img.getAttribute('alt') || '',
|
||||||
|
caption: img.getAttribute('title') || '',
|
||||||
|
width: img.getAttribute('width') ? parseInt(img.getAttribute('width')!) : undefined,
|
||||||
|
height: img.getAttribute('height') ? parseInt(img.getAttribute('height')!) : undefined,
|
||||||
|
});
|
||||||
|
processedElements.add(element);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle code blocks
|
||||||
|
if ((element.tagName === 'CODE' && element.parentElement?.tagName === 'PRE') ||
|
||||||
|
(element.tagName === 'PRE' && element.querySelector('code'))) {
|
||||||
|
const codeEl = element.tagName === 'CODE' ? element : element.querySelector('code');
|
||||||
|
if (codeEl) {
|
||||||
|
const code = codeEl.textContent || '';
|
||||||
|
const language = codeEl.getAttribute('class')?.replace('language-', '') || '';
|
||||||
|
|
||||||
|
if (code.trim()) {
|
||||||
|
blocks.push({
|
||||||
|
_type: 'codeBlock',
|
||||||
|
_key: generateKey(),
|
||||||
|
code,
|
||||||
|
language,
|
||||||
|
});
|
||||||
|
processedElements.add(element);
|
||||||
|
if (element.tagName === 'PRE') processedElements.add(codeEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle text blocks (paragraphs, headings, etc.)
|
||||||
|
if (['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'DIV'].includes(element.tagName)) {
|
||||||
|
// Skip if this contains already processed elements
|
||||||
|
if (element.querySelector('img') || (element.querySelector('code') && element.querySelector('pre'))) {
|
||||||
|
processedElements.add(element);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = getStyleFromElement(element);
|
||||||
|
const text = element.textContent || '';
|
||||||
|
|
||||||
|
if (text.trim()) {
|
||||||
|
blocks.push({
|
||||||
|
_type: 'block',
|
||||||
|
_key: generateKey(),
|
||||||
|
style,
|
||||||
|
markDefs: [],
|
||||||
|
children: [{
|
||||||
|
_type: 'span',
|
||||||
|
_key: generateKey(),
|
||||||
|
text,
|
||||||
|
marks: []
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
processedElements.add(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks.length > 0 ? blocks : [{
|
||||||
|
_type: 'block',
|
||||||
|
_key: generateKey(),
|
||||||
|
style: 'normal',
|
||||||
|
markDefs: [],
|
||||||
|
children: [{
|
||||||
|
_type: 'span',
|
||||||
|
_key: generateKey(),
|
||||||
|
text: '',
|
||||||
|
marks: []
|
||||||
|
}]
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
function portableTextToHtml(blocks: PortableTextBlock[]): string {
|
||||||
|
if (!blocks || blocks.length === 0) return '';
|
||||||
|
|
||||||
|
const htmlParts: string[] = [];
|
||||||
|
|
||||||
|
blocks.forEach(block => {
|
||||||
|
if (block._type === 'block' && Array.isArray(block.children)) {
|
||||||
|
const tag = getHtmlTagFromStyle((block.style as string) || 'normal');
|
||||||
|
const children = block.children as PortableTextChild[];
|
||||||
|
const text = children
|
||||||
|
.map(child => child._type === 'span' ? child.text || '' : '')
|
||||||
|
.join('') || '';
|
||||||
|
|
||||||
|
if (text.trim() || block.style !== 'normal') {
|
||||||
|
htmlParts.push(`<${tag}>${text}</${tag}>`);
|
||||||
|
}
|
||||||
|
} else if (block._type === 'image' && isImageBlock(block)) {
|
||||||
|
// Convert image blocks back to HTML
|
||||||
|
const attrs: string[] = [];
|
||||||
|
if (block.src) attrs.push(`src="${block.src}"`);
|
||||||
|
if (block.alt) attrs.push(`alt="${block.alt}"`);
|
||||||
|
if (block.caption) attrs.push(`title="${block.caption}"`);
|
||||||
|
if (block.width) attrs.push(`width="${block.width}"`);
|
||||||
|
if (block.height) attrs.push(`height="${block.height}"`);
|
||||||
|
|
||||||
|
htmlParts.push(`<img ${attrs.join(' ')} />`);
|
||||||
|
} else if (block._type === 'codeBlock' && isCodeBlock(block)) {
|
||||||
|
// Convert code blocks back to HTML
|
||||||
|
const langClass = block.language ? ` class="language-${block.language}"` : '';
|
||||||
|
htmlParts.push(`<pre><code${langClass}>${block.code || ''}</code></pre>`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = htmlParts.join('\n');
|
||||||
|
return sanitizeHtmlSync(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStyleFromElement(element: Element): string {
|
||||||
|
const tagName = element.tagName.toLowerCase();
|
||||||
|
const styleMap: Record<string, string> = {
|
||||||
|
'p': 'normal',
|
||||||
|
'div': 'normal',
|
||||||
|
'h1': 'h1',
|
||||||
|
'h2': 'h2',
|
||||||
|
'h3': 'h3',
|
||||||
|
'h4': 'h4',
|
||||||
|
'h5': 'h5',
|
||||||
|
'h6': 'h6',
|
||||||
|
'blockquote': 'blockquote',
|
||||||
|
};
|
||||||
|
return styleMap[tagName] || 'normal';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHtmlTagFromStyle(style: string): string {
|
||||||
|
const tagMap: Record<string, string> = {
|
||||||
|
'normal': 'p',
|
||||||
|
'h1': 'h1',
|
||||||
|
'h2': 'h2',
|
||||||
|
'h3': 'h3',
|
||||||
|
'h4': 'h4',
|
||||||
|
'h5': 'h5',
|
||||||
|
'h6': 'h6',
|
||||||
|
'blockquote': 'blockquote',
|
||||||
|
};
|
||||||
|
return tagMap[style] || 'p';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PortableTextChild {
|
||||||
|
_type: string;
|
||||||
|
_key: string;
|
||||||
|
text?: string;
|
||||||
|
marks?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type guards for custom block types
|
||||||
|
function isImageBlock(value: any): value is {
|
||||||
|
_type: 'image';
|
||||||
|
src?: string;
|
||||||
|
alt?: string;
|
||||||
|
caption?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
} {
|
||||||
|
return value && typeof value === 'object' && value._type === 'image';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCodeBlock(value: any): value is {
|
||||||
|
_type: 'codeBlock';
|
||||||
|
code?: string;
|
||||||
|
language?: string;
|
||||||
|
} {
|
||||||
|
return value && typeof value === 'object' && value._type === 'codeBlock';
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateKey(): string {
|
||||||
|
return Math.random().toString(36).substring(2, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toolbar component
|
||||||
|
function EditorToolbar({
|
||||||
|
isScrollable,
|
||||||
|
onToggleScrollable
|
||||||
|
}: {
|
||||||
|
isScrollable: boolean;
|
||||||
|
onToggleScrollable: () => void;
|
||||||
|
}) {
|
||||||
|
const editor = useEditor();
|
||||||
|
|
||||||
|
const toggleDecorator = (decorator: string) => {
|
||||||
|
editor.send({ type: 'decorator.toggle', decorator });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStyle = (style: string) => {
|
||||||
|
editor.send({ type: 'style.toggle', style });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between p-2 theme-card border theme-border rounded-t-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
|
||||||
|
✨ Portable Text Editor
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Style buttons */}
|
||||||
|
<div className="flex items-center gap-1 border-r pr-2 mr-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setStyle('normal')}
|
||||||
|
title="Normal paragraph"
|
||||||
|
>
|
||||||
|
P
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setStyle('h1')}
|
||||||
|
title="Heading 1"
|
||||||
|
className="text-lg font-bold"
|
||||||
|
>
|
||||||
|
H1
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setStyle('h2')}
|
||||||
|
title="Heading 2"
|
||||||
|
className="text-base font-bold"
|
||||||
|
>
|
||||||
|
H2
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setStyle('h3')}
|
||||||
|
title="Heading 3"
|
||||||
|
className="text-sm font-bold"
|
||||||
|
>
|
||||||
|
H3
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decorator buttons */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => toggleDecorator('strong')}
|
||||||
|
title="Bold (Ctrl+B)"
|
||||||
|
className="font-bold"
|
||||||
|
>
|
||||||
|
B
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => toggleDecorator('em')}
|
||||||
|
title="Italic (Ctrl+I)"
|
||||||
|
className="italic"
|
||||||
|
>
|
||||||
|
I
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => toggleDecorator('underline')}
|
||||||
|
title="Underline"
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
|
U
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => toggleDecorator('strike')}
|
||||||
|
title="Strike-through"
|
||||||
|
className="line-through"
|
||||||
|
>
|
||||||
|
S
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable toggle */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs theme-text">Scrollable:</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onToggleScrollable}
|
||||||
|
className={isScrollable ? 'theme-accent-bg text-white' : ''}
|
||||||
|
title={isScrollable ? 'Switch to auto-expand mode' : 'Switch to scrollable mode'}
|
||||||
|
>
|
||||||
|
{isScrollable ? '📜' : '📏'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple component that uses Portable Text editor directly
|
||||||
|
function EditorContent({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
error
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
error?: string;
|
||||||
|
}) {
|
||||||
|
const [portableTextValue, setPortableTextValue] = useState<PortableTextBlock[]>(() =>
|
||||||
|
htmlToPortableTextBlocks(value)
|
||||||
|
);
|
||||||
|
const [isScrollable, setIsScrollable] = useState(true); // Default to scrollable
|
||||||
|
|
||||||
|
// Sync HTML value with prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
debug.log('🔄 Editor value changed:', { valueLength: value?.length, valuePreview: value?.substring(0, 100) });
|
||||||
|
setPortableTextValue(htmlToPortableTextBlocks(value));
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// Debug: log when portableTextValue changes
|
||||||
|
useEffect(() => {
|
||||||
|
debug.log('📝 Portable text blocks updated:', { blockCount: portableTextValue.length, blocks: portableTextValue });
|
||||||
|
}, [portableTextValue]);
|
||||||
|
|
||||||
|
// Add a ref to the editor container for direct paste handling
|
||||||
|
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Global paste event listener to catch ALL paste events
|
||||||
|
useEffect(() => {
|
||||||
|
const handleGlobalPaste = (event: ClipboardEvent) => {
|
||||||
|
debug.log('🌍 Global paste event captured');
|
||||||
|
|
||||||
|
// Check if the paste is happening within our editor
|
||||||
|
const target = event.target as Element;
|
||||||
|
const isInEditor = editorContainerRef.current?.contains(target);
|
||||||
|
|
||||||
|
debug.log('📋 Paste details:', {
|
||||||
|
isInEditor,
|
||||||
|
targetTag: target?.tagName,
|
||||||
|
targetClasses: target?.className,
|
||||||
|
hasClipboardData: !!event.clipboardData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isInEditor && event.clipboardData) {
|
||||||
|
const htmlData = event.clipboardData.getData('text/html');
|
||||||
|
const textData = event.clipboardData.getData('text/plain');
|
||||||
|
|
||||||
|
debug.log('📋 Clipboard contents:', {
|
||||||
|
htmlLength: htmlData.length,
|
||||||
|
textLength: textData.length,
|
||||||
|
hasImages: htmlData.includes('<img'),
|
||||||
|
htmlPreview: htmlData.substring(0, 300)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (htmlData && htmlData.includes('<img')) {
|
||||||
|
debug.log('📋 Images detected in paste! Attempting to process...');
|
||||||
|
|
||||||
|
// Prevent default paste to handle it completely ourselves
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
// Convert the pasted HTML to our blocks maintaining order
|
||||||
|
const pastedBlocks = htmlToPortableTextBlocks(htmlData);
|
||||||
|
|
||||||
|
debug.log('📋 Converted blocks:', pastedBlocks.map(block => ({
|
||||||
|
type: block._type,
|
||||||
|
key: block._key,
|
||||||
|
...(block._type === 'image' ? { src: (block as any).src, alt: (block as any).alt } : {}),
|
||||||
|
...(block._type === 'block' ? { style: (block as any).style, text: (block as any).children?.[0]?.text?.substring(0, 50) } : {})
|
||||||
|
})));
|
||||||
|
|
||||||
|
if (pastedBlocks.length > 0) {
|
||||||
|
// Insert the blocks at the end of current content (maintaining order within the paste)
|
||||||
|
setTimeout(() => {
|
||||||
|
setPortableTextValue(prev => {
|
||||||
|
const updatedBlocks = [...prev, ...pastedBlocks];
|
||||||
|
const html = portableTextToHtml(updatedBlocks);
|
||||||
|
onChange(html);
|
||||||
|
debug.log('📋 Added structured blocks maintaining order:', { pastedCount: pastedBlocks.length, totalBlocks: updatedBlocks.length });
|
||||||
|
return updatedBlocks;
|
||||||
|
});
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add global event listener with capture phase to catch events early
|
||||||
|
document.addEventListener('paste', handleGlobalPaste, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('paste', handleGlobalPaste, true);
|
||||||
|
};
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
// Handle paste events directly on the editor container (backup approach)
|
||||||
|
const handleContainerPaste = useCallback((_event: React.ClipboardEvent) => {
|
||||||
|
debug.log('📦 Container paste handler triggered');
|
||||||
|
// This might not be reached if global handler prevents default
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Render functions for the editor
|
||||||
|
const renderStyle: RenderStyleFunction = useCallback((props) => {
|
||||||
|
const { schemaType, children } = props;
|
||||||
|
|
||||||
|
switch (schemaType.value) {
|
||||||
|
case 'h1':
|
||||||
|
return <h1 className="text-3xl font-bold mb-4">{children}</h1>;
|
||||||
|
case 'h2':
|
||||||
|
return <h2 className="text-2xl font-bold mb-3">{children}</h2>;
|
||||||
|
case 'h3':
|
||||||
|
return <h3 className="text-xl font-bold mb-3">{children}</h3>;
|
||||||
|
case 'h4':
|
||||||
|
return <h4 className="text-lg font-bold mb-2">{children}</h4>;
|
||||||
|
case 'h5':
|
||||||
|
return <h5 className="text-base font-bold mb-2">{children}</h5>;
|
||||||
|
case 'h6':
|
||||||
|
return <h6 className="text-sm font-bold mb-2">{children}</h6>;
|
||||||
|
case 'blockquote':
|
||||||
|
return <blockquote className="border-l-4 border-gray-300 pl-4 italic my-4">{children}</blockquote>;
|
||||||
|
default:
|
||||||
|
return <p className="mb-2">{children}</p>;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderDecorator: RenderDecoratorFunction = useCallback((props) => {
|
||||||
|
const { schemaType, children } = props;
|
||||||
|
|
||||||
|
switch (schemaType.value) {
|
||||||
|
case 'strong':
|
||||||
|
return <strong>{children}</strong>;
|
||||||
|
case 'em':
|
||||||
|
return <em>{children}</em>;
|
||||||
|
case 'underline':
|
||||||
|
return <u>{children}</u>;
|
||||||
|
case 'strike':
|
||||||
|
return <s>{children}</s>;
|
||||||
|
case 'code':
|
||||||
|
return <code className="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">{children}</code>;
|
||||||
|
default:
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderBlock: RenderBlockFunction = useCallback((props) => {
|
||||||
|
const { schemaType, value, children } = props;
|
||||||
|
|
||||||
|
debug.log('🎨 Rendering block:', { schemaType: schemaType.name, valueType: value?._type, value });
|
||||||
|
|
||||||
|
// Handle image blocks
|
||||||
|
if (schemaType.name === 'image' && isImageBlock(value)) {
|
||||||
|
debug.log('🖼️ Rendering image block:', value);
|
||||||
|
return (
|
||||||
|
<div className="my-4 p-3 border border-dashed border-gray-300 rounded-lg bg-gray-50">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-lg">🖼️</span>
|
||||||
|
<span className="font-medium text-gray-700">Image Block</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600 space-y-1">
|
||||||
|
<p><strong>Source:</strong> {value.src || 'No source'}</p>
|
||||||
|
{value.alt && <p><strong>Alt text:</strong> {value.alt}</p>}
|
||||||
|
{value.caption && <p><strong>Caption:</strong> {value.caption}</p>}
|
||||||
|
{(value.width || value.height) && (
|
||||||
|
<p><strong>Dimensions:</strong> {value.width || '?'} × {value.height || '?'}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle code blocks
|
||||||
|
if (schemaType.name === 'codeBlock' && isCodeBlock(value)) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 p-3 border border-dashed border-blue-300 rounded-lg bg-blue-50">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-lg">💻</span>
|
||||||
|
<span className="font-medium text-blue-700">Code Block</span>
|
||||||
|
{value.language && (
|
||||||
|
<span className="text-xs bg-blue-200 text-blue-800 px-2 py-1 rounded">
|
||||||
|
{value.language}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<pre className="text-sm text-gray-800 bg-white p-2 rounded border overflow-x-auto">
|
||||||
|
<code>{value.code || '// No code'}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default block rendering
|
||||||
|
return <div>{children}</div>;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderListItem: RenderListItemFunction = useCallback((props) => {
|
||||||
|
return <li>{props.children}</li>;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderAnnotation: RenderAnnotationFunction = useCallback((props) => {
|
||||||
|
const { schemaType, children, value } = props;
|
||||||
|
|
||||||
|
if (schemaType.name === 'link' && value && typeof value === 'object') {
|
||||||
|
const linkValue = value as { href?: string; target?: string; title?: string };
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={linkValue.href}
|
||||||
|
target={linkValue.target || '_self'}
|
||||||
|
title={linkValue.title}
|
||||||
|
className="text-blue-600 hover:text-blue-800 underline"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<EditorProvider
|
||||||
|
key={`editor-${portableTextValue.length}-${Date.now()}`}
|
||||||
|
initialConfig={{
|
||||||
|
schemaDefinition: editorSchema,
|
||||||
|
initialValue: portableTextValue,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditorToolbar
|
||||||
|
isScrollable={isScrollable}
|
||||||
|
onToggleScrollable={() => setIsScrollable(!isScrollable)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={editorContainerRef}
|
||||||
|
className="border theme-border rounded-b-lg overflow-hidden"
|
||||||
|
onPaste={handleContainerPaste}
|
||||||
|
>
|
||||||
|
<PortableTextEditable
|
||||||
|
className={`p-3 focus:outline-none focus:ring-0 resize-none ${
|
||||||
|
isScrollable
|
||||||
|
? 'h-[400px] overflow-y-auto'
|
||||||
|
: 'min-h-[300px]'
|
||||||
|
}`}
|
||||||
|
placeholder={placeholder}
|
||||||
|
renderStyle={renderStyle}
|
||||||
|
renderDecorator={renderDecorator}
|
||||||
|
renderBlock={renderBlock}
|
||||||
|
renderListItem={renderListItem}
|
||||||
|
renderAnnotation={renderAnnotation}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</EditorProvider>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-xs theme-text">
|
||||||
|
<p>
|
||||||
|
<strong>Portable Text Editor:</strong> Rich text editor with structured content.
|
||||||
|
{isScrollable ? ' Fixed height with scrolling.' : ' Auto-expanding height.'}
|
||||||
|
📋 Paste detection active.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PortableTextEditorNew({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Write your story here...',
|
||||||
|
error,
|
||||||
|
storyId,
|
||||||
|
enableImageProcessing = false
|
||||||
|
}: PortableTextEditorProps) {
|
||||||
|
debug.log('🎯 Portable Text Editor loaded!', {
|
||||||
|
valueLength: value?.length,
|
||||||
|
enableImageProcessing,
|
||||||
|
hasStoryId: !!storyId
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditorContent
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
frontend/src/lib/debug.ts
Normal file
90
frontend/src/lib/debug.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Debug logging utility
|
||||||
|
* Allows conditional logging based on environment or debug flags
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Check if we're in development mode or debug is explicitly enabled
|
||||||
|
const isDebugEnabled = (): boolean => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
// Server-side: check NODE_ENV
|
||||||
|
return process.env.NODE_ENV === 'development' || process.env.DEBUG === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client-side: check localStorage flag or development mode
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
process.env.NODE_ENV === 'development' ||
|
||||||
|
localStorage.getItem('debug') === 'true' ||
|
||||||
|
window.location.search.includes('debug=true')
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return process.env.NODE_ENV === 'development';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug logger that only outputs in development or when debug is enabled
|
||||||
|
*/
|
||||||
|
export const debug = {
|
||||||
|
log: (...args: any[]) => {
|
||||||
|
if (isDebugEnabled()) {
|
||||||
|
console.log('[DEBUG]', ...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
warn: (...args: any[]) => {
|
||||||
|
if (isDebugEnabled()) {
|
||||||
|
console.warn('[DEBUG]', ...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
error: (...args: any[]) => {
|
||||||
|
if (isDebugEnabled()) {
|
||||||
|
console.error('[DEBUG]', ...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
group: (label: string) => {
|
||||||
|
if (isDebugEnabled()) {
|
||||||
|
console.group(`[DEBUG] ${label}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
groupEnd: () => {
|
||||||
|
if (isDebugEnabled()) {
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
time: (label: string) => {
|
||||||
|
if (isDebugEnabled()) {
|
||||||
|
console.time(`[DEBUG] ${label}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
timeEnd: (label: string) => {
|
||||||
|
if (isDebugEnabled()) {
|
||||||
|
console.timeEnd(`[DEBUG] ${label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable debug mode (persists in localStorage)
|
||||||
|
*/
|
||||||
|
export const enableDebug = () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('debug', 'true');
|
||||||
|
console.log('Debug mode enabled. Reload page to see debug output.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable debug mode
|
||||||
|
*/
|
||||||
|
export const disableDebug = () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem('debug');
|
||||||
|
console.log('Debug mode disabled. Reload page to hide debug output.');
|
||||||
|
}
|
||||||
|
};
|
||||||
274
frontend/src/lib/portabletext/conversion.ts
Normal file
274
frontend/src/lib/portabletext/conversion.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* Conversion utilities between HTML and Portable Text
|
||||||
|
* Maintains compatibility with existing sanitization strategy
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PortableTextBlock } from '@portabletext/types';
|
||||||
|
import type { CustomPortableTextBlock } from './schema';
|
||||||
|
import { createTextBlock, createImageBlock } from './schema';
|
||||||
|
import { sanitizeHtmlSync } from '../sanitization';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert HTML to Portable Text
|
||||||
|
* This maintains backward compatibility with existing HTML content
|
||||||
|
*/
|
||||||
|
export function htmlToPortableText(html: string): CustomPortableTextBlock[] {
|
||||||
|
if (!html || html.trim() === '') {
|
||||||
|
return [createTextBlock()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// First sanitize the HTML using existing strategy
|
||||||
|
const sanitizedHtml = sanitizeHtmlSync(html);
|
||||||
|
|
||||||
|
// Parse the sanitized HTML into Portable Text blocks
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(sanitizedHtml, 'text/html');
|
||||||
|
|
||||||
|
const blocks: CustomPortableTextBlock[] = [];
|
||||||
|
|
||||||
|
// Process each child element in the body
|
||||||
|
const walker = doc.createTreeWalker(
|
||||||
|
doc.body,
|
||||||
|
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
let currentBlock: PortableTextBlock | null = null;
|
||||||
|
let node = walker.nextNode();
|
||||||
|
|
||||||
|
while (node) {
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const element = node as Element;
|
||||||
|
|
||||||
|
// Handle block-level elements
|
||||||
|
if (isBlockElement(element.tagName)) {
|
||||||
|
// Finish current block if any
|
||||||
|
if (currentBlock) {
|
||||||
|
blocks.push(currentBlock);
|
||||||
|
currentBlock = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle images separately
|
||||||
|
if (element.tagName === 'IMG') {
|
||||||
|
const img = element as HTMLImageElement;
|
||||||
|
blocks.push(createImageBlock(
|
||||||
|
img.src,
|
||||||
|
img.alt,
|
||||||
|
img.title || undefined
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
// Create new block for this element
|
||||||
|
const style = getBlockStyle(element.tagName);
|
||||||
|
const text = element.textContent || '';
|
||||||
|
currentBlock = createTextBlock(text, style);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle inline elements - add to current block
|
||||||
|
if (!currentBlock) {
|
||||||
|
currentBlock = createTextBlock();
|
||||||
|
}
|
||||||
|
// Inline elements are handled by processing their text content
|
||||||
|
// Mark handling would go here for future enhancement
|
||||||
|
}
|
||||||
|
} else if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
|
||||||
|
// Handle text nodes
|
||||||
|
if (!currentBlock) {
|
||||||
|
currentBlock = createTextBlock();
|
||||||
|
}
|
||||||
|
// Text content is already included in the parent element processing
|
||||||
|
}
|
||||||
|
|
||||||
|
node = walker.nextNode();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add final block if any
|
||||||
|
if (currentBlock) {
|
||||||
|
blocks.push(currentBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no blocks were created, return empty content
|
||||||
|
if (blocks.length === 0) {
|
||||||
|
return [createTextBlock()];
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Portable Text to HTML
|
||||||
|
* This ensures compatibility with existing backend processing
|
||||||
|
*/
|
||||||
|
export function portableTextToHtml(blocks: CustomPortableTextBlock[]): string {
|
||||||
|
if (!blocks || blocks.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlParts: string[] = [];
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (block._type === 'block') {
|
||||||
|
const portableBlock = block as PortableTextBlock;
|
||||||
|
const tag = getHtmlTag(portableBlock.style || 'normal');
|
||||||
|
const text = extractTextFromBlock(portableBlock);
|
||||||
|
|
||||||
|
if (text.trim() || portableBlock.style !== 'normal') {
|
||||||
|
htmlParts.push(`<${tag}>${text}</${tag}>`);
|
||||||
|
}
|
||||||
|
} else if (block._type === 'image') {
|
||||||
|
const imgBlock = block as any; // Type assertion for custom image block
|
||||||
|
const alt = imgBlock.alt ? ` alt="${escapeHtml(imgBlock.alt)}"` : '';
|
||||||
|
const title = imgBlock.caption ? ` title="${escapeHtml(imgBlock.caption)}"` : '';
|
||||||
|
htmlParts.push(`<img src="${escapeHtml(imgBlock.src)}"${alt}${title} />`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = htmlParts.join('\n');
|
||||||
|
|
||||||
|
// Apply final sanitization to ensure security
|
||||||
|
return sanitizeHtmlSync(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract plain text from a Portable Text block
|
||||||
|
*/
|
||||||
|
function extractTextFromBlock(block: PortableTextBlock): string {
|
||||||
|
if (!block.children) return '';
|
||||||
|
|
||||||
|
return block.children
|
||||||
|
.map(child => {
|
||||||
|
if (child._type === 'span') {
|
||||||
|
return child.text || '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if an HTML tag is a block-level element
|
||||||
|
*/
|
||||||
|
function isBlockElement(tagName: string): boolean {
|
||||||
|
const blockElements = [
|
||||||
|
'P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
|
||||||
|
'BLOCKQUOTE', 'UL', 'OL', 'LI', 'IMG', 'BR'
|
||||||
|
];
|
||||||
|
return blockElements.includes(tagName.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Portable Text block style from HTML tag
|
||||||
|
*/
|
||||||
|
function getBlockStyle(tagName: string): string {
|
||||||
|
const styleMap: Record<string, string> = {
|
||||||
|
'P': 'normal',
|
||||||
|
'DIV': 'normal',
|
||||||
|
'H1': 'h1',
|
||||||
|
'H2': 'h2',
|
||||||
|
'H3': 'h3',
|
||||||
|
'H4': 'h4',
|
||||||
|
'H5': 'h5',
|
||||||
|
'H6': 'h6',
|
||||||
|
'BLOCKQUOTE': 'blockquote',
|
||||||
|
};
|
||||||
|
|
||||||
|
return styleMap[tagName.toUpperCase()] || 'normal';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get HTML tag from Portable Text block style
|
||||||
|
*/
|
||||||
|
function getHtmlTag(style: string): string {
|
||||||
|
const tagMap: Record<string, string> = {
|
||||||
|
'normal': 'p',
|
||||||
|
'h1': 'h1',
|
||||||
|
'h2': 'h2',
|
||||||
|
'h3': 'h3',
|
||||||
|
'h4': 'h4',
|
||||||
|
'h5': 'h5',
|
||||||
|
'h6': 'h6',
|
||||||
|
'blockquote': 'blockquote',
|
||||||
|
};
|
||||||
|
|
||||||
|
return tagMap[style] || 'p';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML entities
|
||||||
|
*/
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple HTML parsing for converting existing content
|
||||||
|
* This is a basic implementation - could be enhanced with more sophisticated parsing
|
||||||
|
*/
|
||||||
|
export function parseHtmlToBlocks(html: string): CustomPortableTextBlock[] {
|
||||||
|
if (!html || html.trim() === '') {
|
||||||
|
return [createTextBlock()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize first
|
||||||
|
const sanitizedHtml = sanitizeHtmlSync(html);
|
||||||
|
|
||||||
|
// Split by block-level elements and convert
|
||||||
|
const blocks: CustomPortableTextBlock[] = [];
|
||||||
|
|
||||||
|
// Simple regex-based parsing for common elements
|
||||||
|
const blockElements = sanitizedHtml.split(/(<\/?(?:p|div|h[1-6]|blockquote|img)[^>]*>)/i)
|
||||||
|
.filter(part => part.trim().length > 0);
|
||||||
|
|
||||||
|
let currentText = '';
|
||||||
|
let currentStyle = 'normal';
|
||||||
|
|
||||||
|
for (const part of blockElements) {
|
||||||
|
if (part.match(/^<(h[1-6]|p|div|blockquote)/i)) {
|
||||||
|
// Start of block element
|
||||||
|
const match = part.match(/^<(h[1-6]|p|div|blockquote)/i);
|
||||||
|
if (match) {
|
||||||
|
currentStyle = getBlockStyle(match[1]);
|
||||||
|
}
|
||||||
|
} else if (part.match(/^<img/i)) {
|
||||||
|
// Image element
|
||||||
|
const srcMatch = part.match(/src=['"']([^'"']+)['"']/);
|
||||||
|
const altMatch = part.match(/alt=['"']([^'"']+)['"']/);
|
||||||
|
const titleMatch = part.match(/title=['"']([^'"']+)['"']/);
|
||||||
|
|
||||||
|
if (srcMatch) {
|
||||||
|
blocks.push(createImageBlock(
|
||||||
|
srcMatch[1],
|
||||||
|
altMatch?.[1],
|
||||||
|
titleMatch?.[1]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if (part.match(/^<\//)) {
|
||||||
|
// End tag - finalize current block
|
||||||
|
if (currentText.trim()) {
|
||||||
|
blocks.push(createTextBlock(currentText.trim(), currentStyle));
|
||||||
|
currentText = '';
|
||||||
|
currentStyle = 'normal';
|
||||||
|
}
|
||||||
|
} else if (!part.match(/^</)) {
|
||||||
|
// Text content
|
||||||
|
currentText += part;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle remaining text
|
||||||
|
if (currentText.trim()) {
|
||||||
|
blocks.push(createTextBlock(currentText.trim(), currentStyle));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no blocks created, return empty block
|
||||||
|
if (blocks.length === 0) {
|
||||||
|
return [createTextBlock()];
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to generate unique keys
|
||||||
|
function generateKey(): string {
|
||||||
|
return Math.random().toString(36).substr(2, 9);
|
||||||
|
}
|
||||||
97
frontend/src/lib/portabletext/editorSchema.ts
Normal file
97
frontend/src/lib/portabletext/editorSchema.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Portable Text Editor Schema Definition
|
||||||
|
* Defines the structure and capabilities of the editor
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineSchema } from '@portabletext/editor';
|
||||||
|
import type { SchemaDefinition } from '@portabletext/editor';
|
||||||
|
|
||||||
|
export const editorSchema: SchemaDefinition = defineSchema({
|
||||||
|
// Text decorators (inline formatting)
|
||||||
|
decorators: [
|
||||||
|
{ name: 'strong' },
|
||||||
|
{ name: 'em' },
|
||||||
|
{ name: 'underline' },
|
||||||
|
{ name: 'strike' },
|
||||||
|
{ name: 'code' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Block styles (paragraph types)
|
||||||
|
styles: [
|
||||||
|
{ name: 'normal' },
|
||||||
|
{ name: 'h1' },
|
||||||
|
{ name: 'h2' },
|
||||||
|
{ name: 'h3' },
|
||||||
|
{ name: 'h4' },
|
||||||
|
{ name: 'h5' },
|
||||||
|
{ name: 'h6' },
|
||||||
|
{ name: 'blockquote' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// List types
|
||||||
|
lists: [
|
||||||
|
{ name: 'bullet' },
|
||||||
|
{ name: 'number' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Annotations (links, etc.)
|
||||||
|
annotations: [
|
||||||
|
{
|
||||||
|
name: 'link',
|
||||||
|
type: 'object',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'href',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Block objects (custom content types)
|
||||||
|
blockObjects: [
|
||||||
|
{
|
||||||
|
name: 'image',
|
||||||
|
type: 'object',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'src',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'alt',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'caption',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'width',
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'height',
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'codeBlock',
|
||||||
|
type: 'object',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'code',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'language',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type exports for use in components
|
||||||
|
export type EditorSchema = typeof editorSchema;
|
||||||
169
frontend/src/lib/portabletext/schema.ts
Normal file
169
frontend/src/lib/portabletext/schema.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Portable Text schema definition matching current RichTextEditor functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PortableTextBlock,
|
||||||
|
ArbitraryTypedObject,
|
||||||
|
PortableTextMarkDefinition,
|
||||||
|
PortableTextSpan
|
||||||
|
} from '@portabletext/types';
|
||||||
|
|
||||||
|
// Define custom marks (inline formatting)
|
||||||
|
export interface StrongMark extends PortableTextMarkDefinition {
|
||||||
|
_type: 'strong';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmMark extends PortableTextMarkDefinition {
|
||||||
|
_type: 'em';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnderlineMark extends PortableTextMarkDefinition {
|
||||||
|
_type: 'underline';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrikeMark extends PortableTextMarkDefinition {
|
||||||
|
_type: 'strike';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeMark extends PortableTextMarkDefinition {
|
||||||
|
_type: 'code';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom block types for images (future enhancement)
|
||||||
|
export interface ImageBlock extends ArbitraryTypedObject {
|
||||||
|
_type: 'image';
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
caption?: string;
|
||||||
|
isProcessing?: boolean;
|
||||||
|
originalUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define the schema configuration
|
||||||
|
export const portableTextSchema = {
|
||||||
|
// Block styles (paragraph, headings)
|
||||||
|
styles: [
|
||||||
|
{ title: 'Normal', value: 'normal' },
|
||||||
|
{ title: 'Heading 1', value: 'h1' },
|
||||||
|
{ title: 'Heading 2', value: 'h2' },
|
||||||
|
{ title: 'Heading 3', value: 'h3' },
|
||||||
|
{ title: 'Heading 4', value: 'h4' },
|
||||||
|
{ title: 'Heading 5', value: 'h5' },
|
||||||
|
{ title: 'Heading 6', value: 'h6' },
|
||||||
|
{ title: 'Quote', value: 'blockquote' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// List types
|
||||||
|
lists: [
|
||||||
|
{ title: 'Bullet', value: 'bullet' },
|
||||||
|
{ title: 'Number', value: 'number' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Marks (inline formatting)
|
||||||
|
marks: {
|
||||||
|
// Decorators
|
||||||
|
decorators: [
|
||||||
|
{ title: 'Strong', value: 'strong' },
|
||||||
|
{ title: 'Emphasis', value: 'em' },
|
||||||
|
{ title: 'Underline', value: 'underline' },
|
||||||
|
{ title: 'Strike', value: 'strike' },
|
||||||
|
{ title: 'Code', value: 'code' },
|
||||||
|
],
|
||||||
|
// Annotations (links, etc.)
|
||||||
|
annotations: [
|
||||||
|
{
|
||||||
|
title: 'URL',
|
||||||
|
name: 'link',
|
||||||
|
type: 'object',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
title: 'URL',
|
||||||
|
name: 'href',
|
||||||
|
type: 'url',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Custom block types
|
||||||
|
blockTypes: [
|
||||||
|
{
|
||||||
|
title: 'Image',
|
||||||
|
name: 'image',
|
||||||
|
type: 'object',
|
||||||
|
fields: [
|
||||||
|
{ name: 'src', type: 'string', title: 'Image URL' },
|
||||||
|
{ name: 'alt', type: 'string', title: 'Alt Text' },
|
||||||
|
{ name: 'caption', type: 'string', title: 'Caption' },
|
||||||
|
{ name: 'isProcessing', type: 'boolean', title: 'Processing' },
|
||||||
|
{ name: 'originalUrl', type: 'string', title: 'Original URL' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Type definitions for our Portable Text content
|
||||||
|
export type CustomPortableTextBlock = PortableTextBlock | ImageBlock;
|
||||||
|
|
||||||
|
export type CustomMarkDefinition =
|
||||||
|
| StrongMark
|
||||||
|
| EmMark
|
||||||
|
| UnderlineMark
|
||||||
|
| StrikeMark
|
||||||
|
| CodeMark;
|
||||||
|
|
||||||
|
export type CustomPortableTextSpan = PortableTextSpan & {
|
||||||
|
marks?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to create a basic block
|
||||||
|
export function createTextBlock(
|
||||||
|
text: string = '',
|
||||||
|
style: string = 'normal'
|
||||||
|
): PortableTextBlock {
|
||||||
|
return {
|
||||||
|
_type: 'block',
|
||||||
|
_key: generateKey(),
|
||||||
|
style,
|
||||||
|
markDefs: [],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
_type: 'span',
|
||||||
|
_key: generateKey(),
|
||||||
|
text,
|
||||||
|
marks: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create an image block
|
||||||
|
export function createImageBlock(
|
||||||
|
src: string,
|
||||||
|
alt?: string,
|
||||||
|
caption?: string,
|
||||||
|
isProcessing?: boolean,
|
||||||
|
originalUrl?: string
|
||||||
|
): ImageBlock {
|
||||||
|
return {
|
||||||
|
_type: 'image',
|
||||||
|
_key: generateKey(),
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
caption,
|
||||||
|
isProcessing,
|
||||||
|
originalUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to generate unique keys
|
||||||
|
function generateKey(): string {
|
||||||
|
return Math.random().toString(36).substr(2, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default empty content
|
||||||
|
export const emptyPortableTextContent: CustomPortableTextBlock[] = [
|
||||||
|
createTextBlock('', 'normal')
|
||||||
|
];
|
||||||
32
frontend/src/lib/progress.ts
Normal file
32
frontend/src/lib/progress.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Progress tracking utilities for bulk operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ProgressUpdate {
|
||||||
|
type: 'progress' | 'completed' | 'error';
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
message: string;
|
||||||
|
url?: string;
|
||||||
|
title?: string;
|
||||||
|
author?: string;
|
||||||
|
wordCount?: number;
|
||||||
|
totalWordCount?: number;
|
||||||
|
error?: string;
|
||||||
|
combinedStory?: any;
|
||||||
|
results?: any[];
|
||||||
|
summary?: any;
|
||||||
|
hasImages?: boolean;
|
||||||
|
imageWarnings?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global progress storage (in production, use Redis or database)
|
||||||
|
export const progressStore = new Map<string, ProgressUpdate[]>();
|
||||||
|
|
||||||
|
// Helper function for other routes to send progress updates
|
||||||
|
export function sendProgressUpdate(sessionId: string, update: ProgressUpdate) {
|
||||||
|
if (!progressStore.has(sessionId)) {
|
||||||
|
progressStore.set(sessionId, []);
|
||||||
|
}
|
||||||
|
progressStore.get(sessionId)!.push(update);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { configApi } from './api';
|
import { configApi } from './api';
|
||||||
|
import { debug } from './debug';
|
||||||
|
|
||||||
interface SanitizationConfig {
|
interface SanitizationConfig {
|
||||||
allowedTags: string[];
|
allowedTags: string[];
|
||||||
@@ -28,7 +29,7 @@ function filterCssProperties(styleValue: string, allowedProperties: string[]): s
|
|||||||
const isAllowed = allowedProperties.includes(property);
|
const isAllowed = allowedProperties.includes(property);
|
||||||
|
|
||||||
if (!isAllowed) {
|
if (!isAllowed) {
|
||||||
console.log(`CSS property "${property}" was filtered out (not in allowed list)`);
|
debug.log(`CSS property "${property}" was filtered out (not in allowed list)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return isAllowed;
|
return isAllowed;
|
||||||
@@ -37,9 +38,9 @@ function filterCssProperties(styleValue: string, allowedProperties: string[]): s
|
|||||||
const result = filteredDeclarations.join('; ');
|
const result = filteredDeclarations.join('; ');
|
||||||
|
|
||||||
if (declarations.length !== filteredDeclarations.length) {
|
if (declarations.length !== filteredDeclarations.length) {
|
||||||
console.log(`CSS filtering: ${declarations.length} -> ${filteredDeclarations.length} properties`);
|
debug.log(`CSS filtering: ${declarations.length} -> ${filteredDeclarations.length} properties`);
|
||||||
console.log('Original:', styleValue);
|
debug.log('Original:', styleValue);
|
||||||
console.log('Filtered:', result);
|
debug.log('Filtered:', result);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -219,7 +220,7 @@ export function sanitizeHtmlSync(html: string): string {
|
|||||||
|
|
||||||
// If we don't have cached config but there's an ongoing request, wait for it
|
// If we don't have cached config but there's an ongoing request, wait for it
|
||||||
if (configPromise) {
|
if (configPromise) {
|
||||||
console.log('Sanitization config loading in progress, using fallback for now');
|
debug.log('Sanitization config loading in progress, using fallback for now');
|
||||||
} else {
|
} else {
|
||||||
// No config and no ongoing request - try to load it for next time
|
// No config and no ongoing request - try to load it for next time
|
||||||
console.warn('No cached sanitization config available, triggering load for future use');
|
console.warn('No cached sanitization config available, triggering load for future use');
|
||||||
@@ -229,7 +230,7 @@ export function sanitizeHtmlSync(html: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use comprehensive fallback configuration that preserves formatting
|
// Use comprehensive fallback configuration that preserves formatting
|
||||||
console.log('Using fallback sanitization configuration with formatting support');
|
debug.log('Using fallback sanitization configuration with formatting support');
|
||||||
const fallbackAllowedCssProperties = [
|
const fallbackAllowedCssProperties = [
|
||||||
'color', 'font-size', 'font-weight',
|
'color', 'font-size', 'font-weight',
|
||||||
'font-style', 'text-align', 'text-decoration', 'margin',
|
'font-style', 'text-align', 'text-decoration', 'margin',
|
||||||
|
|||||||
@@ -129,8 +129,7 @@ export async function cleanHtml(html: string): Promise<string> {
|
|||||||
const cheerio = await import('cheerio');
|
const cheerio = await import('cheerio');
|
||||||
const $ = cheerio.load(html, {
|
const $ = cheerio.load(html, {
|
||||||
// Preserve self-closing tags like <br>
|
// Preserve self-closing tags like <br>
|
||||||
xmlMode: false,
|
xmlMode: false
|
||||||
decodeEntities: false
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove dangerous elements
|
// Remove dangerous elements
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export function extractLinkText(
|
|||||||
$: cheerio.CheerioAPI,
|
$: cheerio.CheerioAPI,
|
||||||
config: LinkTextStrategy
|
config: LinkTextStrategy
|
||||||
): string {
|
): string {
|
||||||
let searchScope: cheerio.Cheerio<cheerio.AnyNode>;
|
let searchScope: any;
|
||||||
|
|
||||||
if (config.searchWithin) {
|
if (config.searchWithin) {
|
||||||
searchScope = $(config.searchWithin);
|
searchScope = $(config.searchWithin);
|
||||||
@@ -196,7 +196,7 @@ export function extractLinkText(
|
|||||||
config.nearText.forEach(text => {
|
config.nearText.forEach(text => {
|
||||||
if (foundText) return; // Already found
|
if (foundText) return; // Already found
|
||||||
|
|
||||||
searchScope.find('*').each((_, elem) => {
|
searchScope.find('*').each((_: any, elem: any) => {
|
||||||
const $elem = $(elem);
|
const $elem = $(elem);
|
||||||
const elemText = $elem.text().toLowerCase();
|
const elemText = $elem.text().toLowerCase();
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
3010
package-lock.json
generated
3010
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user