From 8cbc9a034a2dde93a616adb9fcd5db260d351a12 Mon Sep 17 00:00:00 2001 From: Ender Date: Fri, 24 Oct 2025 03:23:21 +0200 Subject: [PATCH] feat(media): add /api/media/image and proxied object fetch; feat(editor): image upload button and TipTap Image insertion --- apps/admin/src/components/RichEditor.tsx | 32 +++++++++++++ apps/api/src/media.ts | 58 +++++++++++++++++++++++- 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/apps/admin/src/components/RichEditor.tsx b/apps/admin/src/components/RichEditor.tsx index 90e56d2..19e70fa 100644 --- a/apps/admin/src/components/RichEditor.tsx +++ b/apps/admin/src/components/RichEditor.tsx @@ -3,6 +3,8 @@ import { EditorContent, useEditor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Link from '@tiptap/extension-link'; import Placeholder from '@tiptap/extension-placeholder'; +import Image from '@tiptap/extension-image'; +import { Button, Stack } from '@mui/material'; export default function RichEditor({ value, @@ -18,6 +20,7 @@ export default function RichEditor({ StarterKit, Link.configure({ openOnClick: true }), Placeholder.configure({ placeholder: placeholder || 'Write something…' }), + Image.configure({ inline: false, allowBase64: false }), ], content: value || '', onUpdate: ({ editor }) => { @@ -40,6 +43,35 @@ export default function RichEditor({ return (
+ + +
); diff --git a/apps/api/src/media.ts b/apps/api/src/media.ts index 39be152..048c8f9 100644 --- a/apps/api/src/media.ts +++ b/apps/api/src/media.ts @@ -1,11 +1,67 @@ import express from 'express'; import multer from 'multer'; import crypto from 'crypto'; -import { uploadBuffer } from './storage/s3'; +import { uploadBuffer, downloadObject } from './storage/s3'; const router = express.Router(); const upload = multer({ storage: multer.memoryStorage() }); +router.post('/image', upload.single('image'), async ( + req: express.Request, + res: express.Response +) => { + try { + console.log('[API] POST /api/media/image'); + const { S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY } = process.env; + if (!S3_ENDPOINT || !S3_ACCESS_KEY || !S3_SECRET_KEY) { + console.error('Image upload failed: missing S3 config'); + return res.status(500).json({ error: 'Object storage not configured' }); + } + + if (!req.file) return res.status(400).json({ error: 'No image file' }); + + const bucket = process.env.S3_BUCKET || 'voxblog'; + const mime = req.file.mimetype || 'application/octet-stream'; + const ext = mime.split('/')[1] || 'bin'; + const key = `images/${new Date().toISOString().slice(0,10)}/${crypto.randomUUID()}.${ext}`; + console.log('[API] Uploading image', { mime, size: req.file.size, bucket, key }); + + const out = await uploadBuffer({ + bucket, + key, + body: req.file.buffer, + contentType: mime, + }); + + // Provide a proxied URL for immediate use in editor + const url = `/api/media/obj?bucket=${encodeURIComponent(out.bucket)}&key=${encodeURIComponent(out.key)}`; + console.log('[API] Image upload success', out); + return res.status(200).json({ success: true, ...out, url }); + } catch (err) { + console.error('Image upload failed:', err); + return res.status(500).json({ error: 'Image upload failed' }); + } +}); + +router.get('/obj', async ( + req: express.Request, + res: express.Response +) => { + try { + const bucket = (req.query.bucket as string) || process.env.S3_BUCKET || ''; + const key = req.query.key as string; + if (!bucket || !key) return res.status(400).json({ error: 'bucket and key are required' }); + const { buffer, contentType } = await downloadObject({ bucket, key }); + res.setHeader('Content-Type', contentType || 'application/octet-stream'); + // Basic cache headers for media + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + return res.send(buffer); + } catch (err) { + console.error('Object fetch failed:', err); + return res.status(404).json({ error: 'Object not found' }); + } +}); + router.post('/audio', upload.single('audio'), async ( req: express.Request, res: express.Response