phase 1 and 2 of embedded images

This commit is contained in:
Stefan Hardegger
2025-09-16 14:58:50 +02:00
parent c92308c24a
commit c7b516be31
14 changed files with 686 additions and 54 deletions

View File

@@ -29,6 +29,7 @@ export default function AddStoryPage() {
const [coverImage, setCoverImage] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [processingImages, setProcessingImages] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [duplicateWarning, setDuplicateWarning] = useState<{
show: boolean;
@@ -250,9 +251,28 @@ export default function AddStoryPage() {
return Object.keys(newErrors).length === 0;
};
// Helper function to detect external images in HTML content
const hasExternalImages = (htmlContent: string): boolean => {
if (!htmlContent) return false;
// Create a temporary DOM element to parse HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
const images = tempDiv.querySelectorAll('img');
for (let i = 0; i < images.length; i++) {
const img = images[i];
const src = img.getAttribute('src');
if (src && (src.startsWith('http://') || src.startsWith('https://'))) {
return true;
}
}
return false;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
@@ -275,7 +295,43 @@ export default function AddStoryPage() {
};
const story = await storyApi.createStory(storyData);
// Process images if there are external images in the content
if (hasExternalImages(formData.contentHtml)) {
try {
setProcessingImages(true);
const imageResult = await storyApi.processContentImages(story.id, formData.contentHtml);
// If images were processed and content was updated, save the updated content
if (imageResult.processedContent !== formData.contentHtml) {
await storyApi.updateStory(story.id, {
title: formData.title,
summary: formData.summary || undefined,
contentHtml: imageResult.processedContent,
sourceUrl: formData.sourceUrl || undefined,
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
...(formData.seriesId ? { seriesId: formData.seriesId } : { seriesName: formData.seriesName || undefined }),
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
tagNames: formData.tags.length > 0 ? formData.tags : undefined,
});
// Show success message with image processing info
if (imageResult.downloadedImages.length > 0) {
console.log(`Successfully processed ${imageResult.downloadedImages.length} images`);
}
if (imageResult.warnings && imageResult.warnings.length > 0) {
console.warn('Image processing warnings:', imageResult.warnings);
}
}
} catch (imageError) {
console.error('Failed to process images:', imageError);
// Don't fail the entire operation if image processing fails
// The story was created successfully, just without processed images
} finally {
setProcessingImages(false);
}
}
// If there's a cover image, upload it separately
if (coverImage) {
await storyApi.uploadCover(story.id, coverImage);
@@ -404,7 +460,11 @@ export default function AddStoryPage() {
onChange={handleContentChange}
placeholder="Write or paste your story content here..."
error={errors.contentHtml}
enableImageProcessing={false}
/>
<p className="text-sm theme-text mt-2">
💡 <strong>Tip:</strong> If you paste content with images, they'll be automatically downloaded and stored locally when you save the story.
</p>
</div>
{/* Tags */}
@@ -450,6 +510,18 @@ export default function AddStoryPage() {
placeholder="https://example.com/original-story-url"
/>
{/* Image Processing Indicator */}
{processingImages && (
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-center gap-3">
<div className="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
<p className="text-blue-800 dark:text-blue-200">
Processing and downloading images...
</p>
</div>
</div>
)}
{/* Submit Error */}
{errors.submit && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
@@ -473,7 +545,7 @@ export default function AddStoryPage() {
loading={loading}
disabled={!formData.title || !formData.authorName || !formData.contentHtml}
>
Add Story
{processingImages ? 'Processing Images...' : 'Add Story'}
</Button>
</div>
</form>

View File

@@ -134,6 +134,27 @@
@apply italic;
}
/* Image styling for story content */
.reading-content img {
@apply max-w-full h-auto mx-auto my-6 rounded-lg shadow-sm;
max-height: 80vh; /* Prevent images from being too tall */
display: block;
}
.reading-content img[align="left"] {
@apply float-left mr-4 mb-4 ml-0;
max-width: 50%;
}
.reading-content img[align="right"] {
@apply float-right ml-4 mb-4 mr-0;
max-width: 50%;
}
.reading-content img[align="center"] {
@apply block mx-auto;
}
/* Editor content styling - same as reading content but for the rich text editor */
.editor-content h1 {
@apply text-2xl font-bold mt-8 mb-4 theme-header;
@@ -183,4 +204,36 @@
.editor-content em {
@apply italic;
}
/* Image styling for editor content */
.editor-content img {
@apply max-w-full h-auto mx-auto my-4 rounded border;
max-height: 60vh; /* Slightly smaller for editor */
display: block;
}
.editor-content img[align="left"] {
@apply float-left mr-4 mb-4 ml-0;
max-width: 50%;
}
.editor-content img[align="right"] {
@apply float-right ml-4 mb-4 mr-0;
max-width: 50%;
}
.editor-content img[align="center"] {
@apply block mx-auto;
}
/* Loading placeholder for images being processed */
.image-processing-placeholder {
@apply bg-gray-100 dark:bg-gray-800 animate-pulse rounded border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center;
min-height: 200px;
}
.image-processing-placeholder::before {
content: "🖼️ Processing image...";
@apply text-gray-500 dark:text-gray-400 text-sm;
}
}

View File

@@ -342,6 +342,8 @@ export default function EditStoryPage() {
onChange={handleContentChange}
placeholder="Edit your story content here..."
error={errors.contentHtml}
storyId={storyId}
enableImageProcessing={true}
/>
</div>

View File

@@ -120,18 +120,30 @@ export default function StoryReadingPage() {
// Sanitize story content and add IDs to headings
const sanitized = await sanitizeHtml(storyData.contentHtml || '');
// Parse and add IDs to headings for TOC functionality
const parser = new DOMParser();
const doc = parser.parseFromString(sanitized, 'text/html');
const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
headings.forEach((heading, index) => {
heading.id = `heading-${index}`;
});
setSanitizedContent(doc.body.innerHTML);
setHasHeadings(headings.length > 0);
// Add IDs to headings for TOC functionality using regex instead of DOMParser
// This avoids potential browser-specific sanitization that might strip src attributes
let processedContent = sanitized;
const headingMatches = processedContent.match(/<h[1-6][^>]*>/gi);
let headingCount = 0;
if (headingMatches) {
processedContent = processedContent.replace(/<h([1-6])([^>]*)>/gi, (match, level, attrs) => {
const headingId = `heading-${headingCount++}`;
// Check if id attribute already exists
if (attrs.includes('id=')) {
// Replace existing id
return match.replace(/id=['"][^'"]*['"]/, `id="${headingId}"`);
} else {
// Add id attribute
return `<h${level}${attrs} id="${headingId}">`;
}
});
}
setSanitizedContent(processedContent);
setHasHeadings(headingCount > 0);
// Load series stories if part of a series
if (storyData.seriesId) {