Compare commits
6 Commits
54fb0226bf
...
41f35ddca3
| Author | SHA1 | Date | |
|---|---|---|---|
| 41f35ddca3 | |||
| 15b1ac4ac0 | |||
| 8f4fbb098f | |||
| 8cbc9a034a | |||
| eff5dfd0bb | |||
| 45f7b3e8d5 |
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] Log conversion lifecycle for debug.
|
||||
- **M4 · Rich Editor Enhancements** (Scope: Goal 4)
|
||||
- [ ] Integrate block-based editor (e.g., TipTap/Rich text) with custom nodes.
|
||||
- [ ] Implement file/image upload widget wired to storage.
|
||||
- [x] Integrate block-based editor (e.g., TipTap/Rich text) with custom nodes.
|
||||
- [x] Implement file/image upload widget wired to storage.
|
||||
- [ ] Support color picker, code blocks, and metadata fields.
|
||||
- **M5 · AI Editing Tools** (Scope: Goal 5)
|
||||
- [ ] 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.
|
||||
- [ ] Handle tags, feature image, and canonical URL settings.
|
||||
- **M7 · Media Management** (Scope: Goal 7)
|
||||
- [ ] Centralize media library view with reuse.
|
||||
- [x] Centralize media library view with reuse.
|
||||
- [ ] Background cleanup/retention policies.
|
||||
- **M8 · UX Polish & Hardening** (Scope: Goal 8)
|
||||
- [ ] Loading/error states across workflows.
|
||||
- [ ] Responsive layout tuning & accessibility audit.
|
||||
- [ ] Smoke test scripts for manual verification.
|
||||
- [x] Recorder playback compatibility (MediaRecorder mime selection, webm/mp4).
|
||||
- [ ] Redesign admin layout (persistent sidebar navigation + header actions)
|
||||
- [x] RichEditor toolbar (bold/italic/underline, headings, links, lists, code)
|
||||
- [x] Insert images at cursor from MediaLibrary (use editor ref instead of appending)
|
||||
- [ ] 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
|
||||
- **Core tooling**
|
||||
|
||||
@ -14,6 +14,11 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-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-dom": "^19.1.1"
|
||||
},
|
||||
|
||||
@ -1,11 +1,16 @@
|
||||
import { Box, Button, Stack, TextField, Typography } from '@mui/material';
|
||||
import { Box, Button, Stack, Typography } from '@mui/material';
|
||||
import AdminLayout from '../layout/AdminLayout';
|
||||
import Recorder from '../features/recorder/Recorder';
|
||||
import { useEffect, useState } from 'react';
|
||||
import RichEditor from './RichEditor';
|
||||
import type { RichEditorHandle } from './RichEditor';
|
||||
import MediaLibrary from './MediaLibrary';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
|
||||
const [draft, setDraft] = useState<string>('');
|
||||
const [draftId, setDraftId] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<string[]>([]);
|
||||
const editorRef = useRef<RichEditorHandle | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const savedId = localStorage.getItem('voxblog_draft_id');
|
||||
@ -23,6 +28,15 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
|
||||
} catch {}
|
||||
})();
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/drafts');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data.items)) setDrafts(data.items);
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const saveDraft = async () => {
|
||||
@ -44,6 +58,18 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const loadDraft = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/drafts/${id}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setDraft(data.content || '');
|
||||
setDraftId(data.id || id);
|
||||
localStorage.setItem('voxblog_draft_id', data.id || id);
|
||||
if (data.content) localStorage.setItem('voxblog_draft', data.content);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title="VoxBlog Admin" onLogout={onLogout}>
|
||||
<Typography variant="h4" sx={{ mb: 2 }}>
|
||||
@ -51,15 +77,22 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
|
||||
</Typography>
|
||||
<Box sx={{ display: 'grid', gap: 3 }}>
|
||||
<Recorder onTranscript={(t) => setDraft(t)} />
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>Drafts</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', mb: 1 }}>
|
||||
{drafts.map(id => (
|
||||
<Button key={id} size="small" variant={draftId === id ? 'contained' : 'outlined'} onClick={() => loadDraft(id)}>
|
||||
{id.slice(0, 8)}
|
||||
</Button>
|
||||
))}
|
||||
{drafts.length === 0 && (
|
||||
<Typography variant="body2">No drafts yet.</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>Draft</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={8}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
/>
|
||||
<RichEditor ref={editorRef as any} value={draft} onChange={(html) => setDraft(html)} placeholder="Write your post..." />
|
||||
<Stack direction="row" spacing={2} sx={{ mt: 1 }}>
|
||||
<Button variant="contained" onClick={saveDraft}>Save Draft</Button>
|
||||
{draftId && (
|
||||
@ -67,6 +100,14 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
<MediaLibrary onInsert={(url) => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.insertImage(url);
|
||||
} else {
|
||||
// fallback
|
||||
setDraft((prev) => `${prev || ''}<p><img src="${url}" alt="" /></p>`);
|
||||
}
|
||||
}} />
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
|
||||
79
apps/admin/src/components/MediaLibrary.tsx
Normal file
79
apps/admin/src/components/MediaLibrary.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Box, Button, Stack, Typography } from '@mui/material';
|
||||
|
||||
type MediaItem = {
|
||||
key: string;
|
||||
size: number;
|
||||
lastModified: string | null;
|
||||
};
|
||||
|
||||
export default function MediaLibrary({ onInsert }: { onInsert: (url: string) => void }) {
|
||||
const [items, setItems] = useState<MediaItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const res = await fetch('/api/media/list?prefix=images/');
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
setItems(Array.isArray(data.items) ? data.items : []);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to load media');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const del = async (key: string) => {
|
||||
try {
|
||||
setError('');
|
||||
const res = await fetch('/api/media/obj', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle1">Media Library</Typography>
|
||||
<Button size="small" onClick={load} disabled={loading}>Refresh</Button>
|
||||
</Stack>
|
||||
{error && <Typography color="error" sx={{ mb: 1 }}>{error}</Typography>}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 1 }}>
|
||||
{items.map((it) => {
|
||||
const url = `/api/media/obj?key=${encodeURIComponent(it.key)}`;
|
||||
const name = it.key.split('/').slice(-1)[0];
|
||||
return (
|
||||
<Box key={it.key} sx={{ border: '1px solid #eee', borderRadius: 1, p: 1 }}>
|
||||
<Box sx={{ width: '100%', height: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', mb: 1, overflow: 'hidden', background: '#fafafa' }}>
|
||||
<img src={url} alt={name} style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }} title={name}>{name}</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button size="small" variant="outlined" onClick={() => onInsert(url)}>Insert</Button>
|
||||
<Button size="small" color="error" onClick={() => del(it.key)}>Delete</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 && !loading && (
|
||||
<Typography variant="body2">No images yet.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
89
apps/admin/src/components/RichEditor.tsx
Normal file
89
apps/admin/src/components/RichEditor.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle } from 'react';
|
||||
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 type RichEditorHandle = {
|
||||
insertImage: (src: string, alt?: string) => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (html: string) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
const RichEditor = forwardRef<RichEditorHandle, Props>(({ value, onChange, placeholder }, ref) => {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Link.configure({ openOnClick: true }),
|
||||
Placeholder.configure({ placeholder: placeholder ?? 'Write something…' }),
|
||||
Image.configure({ inline: false, allowBase64: false }),
|
||||
],
|
||||
content: value ?? '',
|
||||
onUpdate: ({ editor }) => onChange(editor.getHTML()),
|
||||
editorProps: { attributes: { class: 'tiptap-content' } },
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
insertImage: (src: string, alt?: string) => {
|
||||
if (!editor) return;
|
||||
editor.chain().focus().setImage({ src, alt }).run();
|
||||
},
|
||||
}), [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && value !== editor.getHTML()) {
|
||||
editor.commands.setContent(value ?? '', { emitUpdate: false });
|
||||
}
|
||||
}, [value, editor]);
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid #ddd', borderRadius: 6, padding: 8 }}>
|
||||
<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 () => {
|
||||
if (!editor) return;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
const fd = new FormData();
|
||||
fd.append('image', file, file.name);
|
||||
const res = await fetch('/api/media/image', { method: 'POST', body: fd });
|
||||
if (!res.ok) return console.error('Image upload failed', await res.text());
|
||||
const data = await res.json();
|
||||
const url = data.url as string | undefined;
|
||||
if (url) editor.chain().focus().setImage({ src: url, alt: file.name }).run();
|
||||
};
|
||||
input.click();
|
||||
}}>Insert Image</Button>
|
||||
</Stack>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default RichEditor;
|
||||
@ -1,11 +1,99 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import crypto from 'crypto';
|
||||
import { uploadBuffer } from './storage/s3';
|
||||
import { uploadBuffer, downloadObject, listObjects, deleteObject as s3DeleteObject } from './storage/s3';
|
||||
|
||||
const router = express.Router();
|
||||
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 (
|
||||
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
|
||||
|
||||
@ -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() {
|
||||
const endpoint = process.env.S3_ENDPOINT; // e.g. http://<VPS_IP>:9000
|
||||
@ -9,7 +9,6 @@ export function getS3Client() {
|
||||
if (!endpoint || !accessKeyId || !secretAccessKey) {
|
||||
throw new Error('Missing S3 config: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY');
|
||||
}
|
||||
|
||||
return new S3Client({
|
||||
region,
|
||||
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: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "31ba935b-4424-4226-9f8b-803d401022a2",
|
||||
"content": "asödknasdkjasdlkasdasdasdasdsd",
|
||||
"updatedAt": "2025-10-24T01:11:46.059Z"
|
||||
"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: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