feat: enhance media handling with public bucket support
All checks were successful
Deploy to Production / deploy (push) Successful in 2m2s

- Added public bucket integration for serving media files directly instead of through API proxy
- Updated image picker to use new MediaLibrary component with improved UI/UX
- Removed selectedImageKeys prop dependency from EditorShell and StepEdit components
- Modified image upload endpoints to automatically copy files to public bucket when configured
- Added fallback to API proxy URLs when public bucket copy fails
- Improved image picker dialog
This commit is contained in:
Ender 2025-10-28 14:07:11 +01:00
parent 21e02bb34f
commit a2a011dc8c
4 changed files with 70 additions and 40 deletions

View File

@ -208,7 +208,6 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack
draftHtml={draft}
onChangeDraft={setDraft}
generatedDraft={generatedDraft}
selectedImageKeys={genImageKeys}
onAutoSave={triggerImmediateAutoSave}
/>
</StepContainer>

View File

@ -1,7 +1,8 @@
import { useState } from 'react';
import { Box, Button, Alert, Stack, Dialog, DialogTitle, DialogContent, DialogActions, List, ListItem, ListItemButton, ListItemText, CircularProgress, Typography, FormControlLabel, Checkbox } from '@mui/material';
import { Box, Button, Alert, Stack, Dialog, DialogTitle, DialogContent, DialogActions, CircularProgress, Typography, FormControlLabel, Checkbox } from '@mui/material';
import RichEditor, { type RichEditorHandle } from '../RichEditor';
import StepHeader from './StepHeader';
import MediaLibrary from '../MediaLibrary';
import type { ForwardedRef } from 'react';
import { generateAltText } from '../../services/ai';
@ -10,14 +11,12 @@ export default function StepEdit({
draftHtml,
onChangeDraft,
generatedDraft,
selectedImageKeys,
onAutoSave,
}: {
editorRef: ForwardedRef<RichEditorHandle> | any;
draftHtml: string;
onChangeDraft: (html: string) => void;
generatedDraft?: string;
selectedImageKeys?: string[];
onAutoSave?: () => void;
}) {
const [showImagePicker, setShowImagePicker] = useState(false);
@ -34,7 +33,7 @@ export default function StepEdit({
onChangeDraft(generatedDraft);
};
const replacePlaceholder = async (placeholder: string, imageKey: string) => {
const replacePlaceholder = async (placeholder: string, imageUrl: string) => {
setGeneratingAltText(true);
try {
@ -54,7 +53,6 @@ export default function StepEdit({
setAltTextCache(prev => ({ ...prev, [placeholder]: cached }));
}
const imageUrl = `/api/media/obj?key=${encodeURIComponent(imageKey)}`;
const placeholderPattern = `{{IMAGE:${placeholder}}}`;
// Use figure/figcaption if caption exists, otherwise plain img
@ -76,7 +74,6 @@ export default function StepEdit({
} catch (err) {
console.error('Failed to generate image text:', err);
// Fallback to simple alt text
const imageUrl = `/api/media/obj?key=${encodeURIComponent(imageKey)}`;
const placeholderPattern = `{{IMAGE:${placeholder}}}`;
const fallbackAlt = placeholder.replace(/_/g, ' ');
const replacement = `<img src="${imageUrl}" alt="${fallbackAlt}" />`;
@ -160,7 +157,15 @@ export default function StepEdit({
</Box>
{/* Image picker dialog */}
<Dialog open={showImagePicker} onClose={() => !generatingAltText && setShowImagePicker(false)} maxWidth="sm" fullWidth>
<Dialog
open={showImagePicker}
onClose={() => !generatingAltText && setShowImagePicker(false)}
maxWidth="lg"
fullWidth
PaperProps={{
sx: { height: '90vh' }
}}
>
<DialogTitle>
<Box>
Replace: {`{{IMAGE:${currentPlaceholder}}}`}
@ -183,33 +188,15 @@ export default function StepEdit({
sx={{ mt: 1 }}
/>
</DialogTitle>
<DialogContent>
{selectedImageKeys && selectedImageKeys.length > 0 ? (
<List>
{selectedImageKeys.map((key) => (
<ListItem key={key} disablePadding>
<ListItemButton onClick={() => replacePlaceholder(currentPlaceholder, key)}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
<img
src={`/api/media/obj?key=${encodeURIComponent(key)}`}
alt=""
style={{ width: 60, height: 60, objectFit: 'cover', borderRadius: 4 }}
/>
<ListItemText
primary={key.split('/').pop()}
secondary={key}
<DialogContent sx={{ p: 0 }}>
<Box sx={{ p: 2, height: '100%', overflow: 'auto' }}>
<MediaLibrary
onInsert={(url) => replacePlaceholder(currentPlaceholder, url)}
/>
</Box>
</ListItemButton>
</ListItem>
))}
</List>
) : (
<Alert severity="warning">No images selected. Go to Assets step to select images.</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setShowImagePicker(false)}>Cancel</Button>
<Button onClick={() => setShowImagePicker(false)} disabled={generatingAltText}>Cancel</Button>
</DialogActions>
</Dialog>
</Box>

View File

@ -1,7 +1,7 @@
import express from 'express';
import multer from 'multer';
import crypto from 'crypto';
import { uploadBuffer, downloadObject, listObjects, deleteObject as s3DeleteObject } from './storage/s3';
import { uploadBuffer, downloadObject, listObjects, deleteObject as s3DeleteObject, copyObject, getPublicUrlForKey } from './storage/s3';
import { db } from './db';
import { audioClips, posts } from './db/schema';
import { eq } from 'drizzle-orm';
@ -86,10 +86,31 @@ router.post('/image', upload.single('image'), async (
contentType: mime,
});
// Provide a proxied URL for immediate use in editor
const url = `/api/media/obj?bucket=${encodeURIComponent(out.bucket)}&key=${encodeURIComponent(out.key)}`;
// Copy to public bucket if configured
const publicBucket = process.env.PUBLIC_MEDIA_BUCKET;
const publicBaseUrl = process.env.PUBLIC_MEDIA_BASE_URL;
let finalUrl = `/api/media/obj?bucket=${encodeURIComponent(out.bucket)}&key=${encodeURIComponent(out.key)}`;
if (publicBucket && publicBaseUrl) {
try {
await copyObject({
srcBucket: bucket,
srcKey: key,
destBucket: publicBucket,
destKey: key,
});
const publicUrl = getPublicUrlForKey(key);
if (publicUrl) {
finalUrl = publicUrl;
console.log('[API] Image copied to public bucket, using public URL:', publicUrl);
}
} catch (err) {
console.warn('[API] Failed to copy image to public bucket, using API proxy URL:', err);
}
}
console.log('[API] Image upload success', out);
return res.status(200).json({ success: true, ...out, url });
return res.status(200).json({ success: true, ...out, url: finalUrl });
} catch (err) {
console.error('Image upload failed:', err);
return res.status(500).json({ error: 'Image upload failed' });

View File

@ -1,5 +1,5 @@
import express from 'express';
import { uploadBuffer } from '../storage/s3';
import { uploadBuffer, copyObject, getPublicUrlForKey } from '../storage/s3';
import crypto from 'crypto';
const router = express.Router();
@ -219,7 +219,7 @@ router.post('/import', async (req, res) => {
const filename = `pexels-${photoId}-${randomId}.${ext}`;
const key = `images/${timestamp}/${filename}`;
// Upload to S3
// Upload to S3 (private bucket)
const bucket = process.env.S3_BUCKET || '';
await uploadBuffer({
bucket,
@ -230,11 +230,34 @@ router.post('/import', async (req, res) => {
console.log('[Stock Photos] Imported successfully:', key);
// Copy to public bucket if configured
const publicBucket = process.env.PUBLIC_MEDIA_BUCKET;
const publicBaseUrl = process.env.PUBLIC_MEDIA_BASE_URL;
let finalUrl = `/api/media/obj?bucket=${encodeURIComponent(bucket)}&key=${encodeURIComponent(key)}`;
if (publicBucket && publicBaseUrl) {
try {
await copyObject({
srcBucket: bucket,
srcKey: key,
destBucket: publicBucket,
destKey: key,
});
const publicUrl = getPublicUrlForKey(key);
if (publicUrl) {
finalUrl = publicUrl;
console.log('[Stock Photos] Copied to public bucket, using public URL:', publicUrl);
}
} catch (err) {
console.warn('[Stock Photos] Failed to copy to public bucket, using API proxy URL:', err);
}
}
res.json({
success: true,
key,
bucket,
url: `/api/media/obj?bucket=${encodeURIComponent(bucket)}&key=${encodeURIComponent(key)}`,
url: finalUrl,
});
} catch (err: any) {
console.error('[Stock Photos] Import error:', err);