feat: enhance media handling with public bucket support
All checks were successful
Deploy to Production / deploy (push) Successful in 2m2s
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:
parent
21e02bb34f
commit
a2a011dc8c
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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' });
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user