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