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} draftHtml={draft}
onChangeDraft={setDraft} onChangeDraft={setDraft}
generatedDraft={generatedDraft} generatedDraft={generatedDraft}
selectedImageKeys={genImageKeys}
onAutoSave={triggerImmediateAutoSave} onAutoSave={triggerImmediateAutoSave}
/> />
</StepContainer> </StepContainer>

View File

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

View File

@ -1,7 +1,7 @@
import express from 'express'; import express from 'express';
import multer from 'multer'; import multer from 'multer';
import crypto from 'crypto'; 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 { db } from './db';
import { audioClips, posts } from './db/schema'; import { audioClips, posts } from './db/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
@ -86,10 +86,31 @@ router.post('/image', upload.single('image'), async (
contentType: mime, contentType: mime,
}); });
// Provide a proxied URL for immediate use in editor // Copy to public bucket if configured
const url = `/api/media/obj?bucket=${encodeURIComponent(out.bucket)}&key=${encodeURIComponent(out.key)}`; 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); 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) { } catch (err) {
console.error('Image upload failed:', err); console.error('Image upload failed:', err);
return res.status(500).json({ error: 'Image upload failed' }); return res.status(500).json({ error: 'Image upload failed' });

View File

@ -1,5 +1,5 @@
import express from 'express'; import express from 'express';
import { uploadBuffer } from '../storage/s3'; import { uploadBuffer, copyObject, getPublicUrlForKey } from '../storage/s3';
import crypto from 'crypto'; import crypto from 'crypto';
const router = express.Router(); const router = express.Router();
@ -219,7 +219,7 @@ router.post('/import', async (req, res) => {
const filename = `pexels-${photoId}-${randomId}.${ext}`; const filename = `pexels-${photoId}-${randomId}.${ext}`;
const key = `images/${timestamp}/${filename}`; const key = `images/${timestamp}/${filename}`;
// Upload to S3 // Upload to S3 (private bucket)
const bucket = process.env.S3_BUCKET || ''; const bucket = process.env.S3_BUCKET || '';
await uploadBuffer({ await uploadBuffer({
bucket, bucket,
@ -230,11 +230,34 @@ router.post('/import', async (req, res) => {
console.log('[Stock Photos] Imported successfully:', key); 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({ res.json({
success: true, success: true,
key, key,
bucket, bucket,
url: `/api/media/obj?bucket=${encodeURIComponent(bucket)}&key=${encodeURIComponent(key)}`, url: finalUrl,
}); });
} catch (err: any) { } catch (err: any) {
console.error('[Stock Photos] Import error:', err); console.error('[Stock Photos] Import error:', err);