feat: add rich text editor toolbar and media library management
This commit is contained in:
parent
15b1ac4ac0
commit
41f35ddca3
18
PLAN.md
18
PLAN.md
@ -27,8 +27,8 @@ Voice-first authoring tool for single-user Ghost blog. Capture audio, refine wit
|
|||||||
- [x] Surface transcript in rich editor state with status feedback.
|
- [x] Surface transcript in rich editor state with status feedback.
|
||||||
- [x] Log conversion lifecycle for debug.
|
- [x] Log conversion lifecycle for debug.
|
||||||
- **M4 · Rich Editor Enhancements** (Scope: Goal 4)
|
- **M4 · Rich Editor Enhancements** (Scope: Goal 4)
|
||||||
- [ ] Integrate block-based editor (e.g., TipTap/Rich text) with custom nodes.
|
- [x] Integrate block-based editor (e.g., TipTap/Rich text) with custom nodes.
|
||||||
- [ ] Implement file/image upload widget wired to storage.
|
- [x] Implement file/image upload widget wired to storage.
|
||||||
- [ ] Support color picker, code blocks, and metadata fields.
|
- [ ] Support color picker, code blocks, and metadata fields.
|
||||||
- **M5 · AI Editing Tools** (Scope: Goal 5)
|
- **M5 · AI Editing Tools** (Scope: Goal 5)
|
||||||
- [ ] Prompt templates for tone/style suggestions via OpenAI.
|
- [ ] Prompt templates for tone/style suggestions via OpenAI.
|
||||||
@ -38,13 +38,17 @@ Voice-first authoring tool for single-user Ghost blog. Capture audio, refine wit
|
|||||||
- [ ] Implement publish/draft triggers with status reports.
|
- [ ] Implement publish/draft triggers with status reports.
|
||||||
- [ ] Handle tags, feature image, and canonical URL settings.
|
- [ ] Handle tags, feature image, and canonical URL settings.
|
||||||
- **M7 · Media Management** (Scope: Goal 7)
|
- **M7 · Media Management** (Scope: Goal 7)
|
||||||
- [ ] Centralize media library view with reuse.
|
- [x] Centralize media library view with reuse.
|
||||||
- [ ] Background cleanup/retention policies.
|
- [ ] Background cleanup/retention policies.
|
||||||
- **M8 · UX Polish & Hardening** (Scope: Goal 8)
|
- **M8 · UX Polish & Hardening** (Scope: Goal 8)
|
||||||
- [ ] Loading/error states across workflows.
|
- [ ] Redesign admin layout (persistent sidebar navigation + header actions)
|
||||||
- [ ] Responsive layout tuning & accessibility audit.
|
- [x] RichEditor toolbar (bold/italic/underline, headings, links, lists, code)
|
||||||
- [ ] Smoke test scripts for manual verification.
|
- [x] Insert images at cursor from MediaLibrary (use editor ref instead of appending)
|
||||||
- [x] Recorder playback compatibility (MediaRecorder mime selection, webm/mp4).
|
- [ ] Snackbar toasts for upload/transcribe/save success & errors
|
||||||
|
- [ ] Loading skeletons/placeholders for MediaLibrary and Drafts
|
||||||
|
- [ ] Responsive layout tuning & accessibility audit
|
||||||
|
- [ ] Smoke test scripts for manual verification
|
||||||
|
- [x] Recorder playback compatibility (MediaRecorder mime selection, webm/mp4)
|
||||||
|
|
||||||
## Environment & Tooling TODOs
|
## Environment & Tooling TODOs
|
||||||
- **Core tooling**
|
- **Core tooling**
|
||||||
|
|||||||
@ -14,6 +14,11 @@
|
|||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^7.3.4",
|
"@mui/icons-material": "^7.3.4",
|
||||||
"@mui/material": "^7.3.4",
|
"@mui/material": "^7.3.4",
|
||||||
|
"@tiptap/extension-image": "^3.7.2",
|
||||||
|
"@tiptap/extension-link": "^3.7.2",
|
||||||
|
"@tiptap/extension-placeholder": "^3.7.2",
|
||||||
|
"@tiptap/react": "^3.7.2",
|
||||||
|
"@tiptap/starter-kit": "^3.7.2",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1"
|
"react-dom": "^19.1.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,13 +2,15 @@ import { Box, Button, Stack, Typography } from '@mui/material';
|
|||||||
import AdminLayout from '../layout/AdminLayout';
|
import AdminLayout from '../layout/AdminLayout';
|
||||||
import Recorder from '../features/recorder/Recorder';
|
import Recorder from '../features/recorder/Recorder';
|
||||||
import RichEditor from './RichEditor';
|
import RichEditor from './RichEditor';
|
||||||
|
import type { RichEditorHandle } from './RichEditor';
|
||||||
import MediaLibrary from './MediaLibrary';
|
import MediaLibrary from './MediaLibrary';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
|
export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
|
||||||
const [draft, setDraft] = useState<string>('');
|
const [draft, setDraft] = useState<string>('');
|
||||||
const [draftId, setDraftId] = useState<string | null>(null);
|
const [draftId, setDraftId] = useState<string | null>(null);
|
||||||
const [drafts, setDrafts] = useState<string[]>([]);
|
const [drafts, setDrafts] = useState<string[]>([]);
|
||||||
|
const editorRef = useRef<RichEditorHandle | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedId = localStorage.getItem('voxblog_draft_id');
|
const savedId = localStorage.getItem('voxblog_draft_id');
|
||||||
@ -90,7 +92,7 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
|
|||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>Draft</Typography>
|
<Typography variant="subtitle1" sx={{ mb: 1 }}>Draft</Typography>
|
||||||
<RichEditor value={draft} onChange={(html) => setDraft(html)} placeholder="Write your post..." />
|
<RichEditor ref={editorRef as any} value={draft} onChange={(html) => setDraft(html)} placeholder="Write your post..." />
|
||||||
<Stack direction="row" spacing={2} sx={{ mt: 1 }}>
|
<Stack direction="row" spacing={2} sx={{ mt: 1 }}>
|
||||||
<Button variant="contained" onClick={saveDraft}>Save Draft</Button>
|
<Button variant="contained" onClick={saveDraft}>Save Draft</Button>
|
||||||
{draftId && (
|
{draftId && (
|
||||||
@ -99,8 +101,12 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
<MediaLibrary onInsert={(url) => {
|
<MediaLibrary onInsert={(url) => {
|
||||||
// naive append an image block to current HTML
|
if (editorRef.current) {
|
||||||
setDraft((prev) => `${prev || ''}<p><img src="${url}" alt="" /></p>`);
|
editorRef.current.insertImage(url);
|
||||||
|
} else {
|
||||||
|
// fallback
|
||||||
|
setDraft((prev) => `${prev || ''}<p><img src="${url}" alt="" /></p>`);
|
||||||
|
}
|
||||||
}} />
|
}} />
|
||||||
</Box>
|
</Box>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
|
|||||||
@ -44,7 +44,24 @@ const RichEditor = forwardRef<RichEditorHandle, Props>(({ value, onChange, place
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ border: '1px solid #ddd', borderRadius: 6, padding: 8 }}>
|
<div style={{ border: '1px solid #ddd', borderRadius: 6, padding: 8 }}>
|
||||||
<Stack direction="row" spacing={1} sx={{ mb: 1 }}>
|
<Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: 'wrap' }}>
|
||||||
|
<Button size="small" onClick={() => editor?.chain().focus().toggleBold().run()} disabled={!editor}>Bold</Button>
|
||||||
|
<Button size="small" onClick={() => editor?.chain().focus().toggleItalic().run()} disabled={!editor}>Italic</Button>
|
||||||
|
<Button size="small" onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()} disabled={!editor}>H2</Button>
|
||||||
|
<Button size="small" onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()} disabled={!editor}>H3</Button>
|
||||||
|
<Button size="small" onClick={() => editor?.chain().focus().toggleBulletList().run()} disabled={!editor}>• List</Button>
|
||||||
|
<Button size="small" onClick={() => editor?.chain().focus().toggleOrderedList().run()} disabled={!editor}>1. List</Button>
|
||||||
|
<Button size="small" onClick={() => editor?.chain().focus().toggleCodeBlock().run()} disabled={!editor}>Code</Button>
|
||||||
|
<Button size="small" onClick={() => {
|
||||||
|
if (!editor) return;
|
||||||
|
const href = prompt('Enter link URL');
|
||||||
|
if (!href) {
|
||||||
|
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||||
|
} else {
|
||||||
|
editor.chain().focus().extendMarkRange('link').setLink({ href }).run();
|
||||||
|
}
|
||||||
|
}} disabled={!editor}>Link</Button>
|
||||||
|
<Button size="small" onClick={() => editor?.chain().focus().clearNodes().unsetAllMarks().run()} disabled={!editor}>Clear</Button>
|
||||||
<Button size="small" variant="outlined" onClick={async () => {
|
<Button size="small" variant="outlined" onClick={async () => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
|
|||||||
@ -1,11 +1,43 @@
|
|||||||
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 } from './storage/s3';
|
import { uploadBuffer, downloadObject, listObjects, deleteObject as s3DeleteObject } from './storage/s3';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const upload = multer({ storage: multer.memoryStorage() });
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
|
|
||||||
|
router.get('/list', async (
|
||||||
|
req: express.Request,
|
||||||
|
res: express.Response
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const bucket = (req.query.bucket as string) || process.env.S3_BUCKET || '';
|
||||||
|
const prefix = (req.query.prefix as string) || '';
|
||||||
|
if (!bucket) return res.status(400).json({ error: 'bucket is required' });
|
||||||
|
const out = await listObjects({ bucket, prefix, maxKeys: 200 });
|
||||||
|
return res.json(out);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('List objects failed:', err);
|
||||||
|
return res.status(500).json({ error: 'List failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/obj', async (
|
||||||
|
req: express.Request,
|
||||||
|
res: express.Response
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { bucket: bodyBucket, key } = req.body as { bucket?: string; key?: string };
|
||||||
|
const bucket = bodyBucket || process.env.S3_BUCKET || '';
|
||||||
|
if (!bucket || !key) return res.status(400).json({ error: 'bucket and key are required' });
|
||||||
|
await s3DeleteObject({ bucket, key });
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete object failed:', err);
|
||||||
|
return res.status(500).json({ error: 'Delete failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/image', upload.single('image'), async (
|
router.post('/image', upload.single('image'), async (
|
||||||
req: express.Request,
|
req: express.Request,
|
||||||
res: express.Response
|
res: express.Response
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
|
import { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
|
||||||
export function getS3Client() {
|
export function getS3Client() {
|
||||||
const endpoint = process.env.S3_ENDPOINT; // e.g. http://<VPS_IP>:9000
|
const endpoint = process.env.S3_ENDPOINT; // e.g. http://<VPS_IP>:9000
|
||||||
@ -9,7 +9,6 @@ export function getS3Client() {
|
|||||||
if (!endpoint || !accessKeyId || !secretAccessKey) {
|
if (!endpoint || !accessKeyId || !secretAccessKey) {
|
||||||
throw new Error('Missing S3 config: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY');
|
throw new Error('Missing S3 config: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY');
|
||||||
}
|
}
|
||||||
|
|
||||||
return new S3Client({
|
return new S3Client({
|
||||||
region,
|
region,
|
||||||
endpoint,
|
endpoint,
|
||||||
@ -18,6 +17,28 @@ export function getS3Client() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listObjects(params: { bucket: string; prefix?: string; maxKeys?: number }) {
|
||||||
|
const s3 = getS3Client();
|
||||||
|
const cmd = new ListObjectsV2Command({
|
||||||
|
Bucket: params.bucket,
|
||||||
|
Prefix: params.prefix,
|
||||||
|
MaxKeys: params.maxKeys || 100,
|
||||||
|
});
|
||||||
|
const res = await s3.send(cmd);
|
||||||
|
const items = (res.Contents || []).map(obj => ({
|
||||||
|
key: obj.Key || '',
|
||||||
|
size: obj.Size || 0,
|
||||||
|
lastModified: obj.LastModified?.toISOString() || null,
|
||||||
|
})).filter(i => i.key);
|
||||||
|
return { items, isTruncated: !!res.IsTruncated, nextContinuationToken: res.NextContinuationToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteObject(params: { bucket: string; key: string }) {
|
||||||
|
const s3 = getS3Client();
|
||||||
|
await s3.send(new DeleteObjectCommand({ Bucket: params.bucket, Key: params.key }));
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadBuffer(params: {
|
export async function uploadBuffer(params: {
|
||||||
bucket: string;
|
bucket: string;
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"id": "31ba935b-4424-4226-9f8b-803d401022a2",
|
"id": "31ba935b-4424-4226-9f8b-803d401022a2",
|
||||||
"content": "asödknasdkjasdlkasdasdasdasdsd",
|
"content": "<pre><code>enasdasdasd</code></pre><p>zdfsdfsadsdfsdfsdf</p><p></p><p></p><p>sdfsdfs</p><img src=\"/api/media/obj?bucket=voxblog&key=images%2F2025-10-24%2F15962af6-52ae-4c16-918d-86b9e6488bfa.png\" alt=\"Vector-2.png\"><p></p><p></p><ul><li><p>df</p></li></ul><p></p><p></p><p></p><p></p><p></p>",
|
||||||
"updatedAt": "2025-10-24T01:11:46.059Z"
|
"updatedAt": "2025-10-24T01:58:35.356Z"
|
||||||
}
|
}
|
||||||
1974
pnpm-lock.yaml
1974
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user