feat: implement Ghost blog post publishing with metadata and media handling
This commit is contained in:
parent
41f35ddca3
commit
5a00636063
11
PLAN.md
11
PLAN.md
@ -34,9 +34,14 @@ Voice-first authoring tool for single-user Ghost blog. Capture audio, refine wit
|
|||||||
- [ ] Prompt templates for tone/style suggestions via OpenAI.
|
- [ ] Prompt templates for tone/style suggestions via OpenAI.
|
||||||
- [ ] Inline improvement workflow with diff/revert capabilities.
|
- [ ] Inline improvement workflow with diff/revert capabilities.
|
||||||
- **M6 · Ghost Publication Flow** (Scope: Goal 6)
|
- **M6 · Ghost Publication Flow** (Scope: Goal 6)
|
||||||
- [ ] Map editor content to Ghost post payload.
|
- [ ] Map editor content (HTML) to Ghost post payload (title, html, tags[], feature_image, canonical_url)
|
||||||
- [ ] Implement publish/draft triggers with status reports.
|
- [ ] Backend: `/api/ghost/post` create/update (draft or published) using Ghost Admin API
|
||||||
- [ ] Handle tags, feature image, and canonical URL settings.
|
- Body: `{ id?, title, html, tags: string[], feature_image?, canonical_url?, status: 'draft'|'published' }`
|
||||||
|
- Returns: `{ id, url, status }`
|
||||||
|
- [ ] Frontend: Metadata panel (title, tags input, feature image picker, canonical URL)
|
||||||
|
- [ ] Frontend: Buttons — "Save as Draft" and "Publish" (calls `/api/ghost/post`)
|
||||||
|
- [ ] Show status toast and link to view post
|
||||||
|
- [ ] ENV: `GHOST_ADMIN_API_URL`, `GHOST_ADMIN_API_KEY`, `GHOST_PUBLIC_URL`
|
||||||
- **M7 · Media Management** (Scope: Goal 7)
|
- **M7 · Media Management** (Scope: Goal 7)
|
||||||
- [x] Centralize media library view with reuse.
|
- [x] Centralize media library view with reuse.
|
||||||
- [ ] Background cleanup/retention policies.
|
- [ ] Background cleanup/retention policies.
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import Recorder from '../features/recorder/Recorder';
|
|||||||
import RichEditor from './RichEditor';
|
import RichEditor from './RichEditor';
|
||||||
import type { RichEditorHandle } from './RichEditor';
|
import type { RichEditorHandle } from './RichEditor';
|
||||||
import MediaLibrary from './MediaLibrary';
|
import MediaLibrary from './MediaLibrary';
|
||||||
|
import MetadataPanel, { type Metadata } from './MetadataPanel';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
|
export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
|
||||||
@ -11,6 +12,7 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
|
|||||||
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);
|
const editorRef = useRef<RichEditorHandle | null>(null);
|
||||||
|
const [meta, setMeta] = useState<Metadata>({ title: '', tagsText: '', canonicalUrl: '', featureImage: '' });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedId = localStorage.getItem('voxblog_draft_id');
|
const savedId = localStorage.getItem('voxblog_draft_id');
|
||||||
@ -100,6 +102,36 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
|
|||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
<MetadataPanel
|
||||||
|
value={meta}
|
||||||
|
onChange={setMeta}
|
||||||
|
onPublish={async (status) => {
|
||||||
|
try {
|
||||||
|
const tags = meta.tagsText.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
|
const res = await fetch('/api/ghost/post', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: meta.title || 'Untitled',
|
||||||
|
html: draft || '',
|
||||||
|
tags,
|
||||||
|
feature_image: meta.featureImage || undefined,
|
||||||
|
canonical_url: meta.canonicalUrl || undefined,
|
||||||
|
status,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('Ghost error', data);
|
||||||
|
alert('Ghost error: ' + (data?.detail || data?.error || res.status));
|
||||||
|
} else {
|
||||||
|
alert(`${status === 'published' ? 'Published' : 'Saved draft'} (id: ${data.id})`);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
alert('Ghost error: ' + (e?.message || String(e)));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<MediaLibrary onInsert={(url) => {
|
<MediaLibrary onInsert={(url) => {
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
editorRef.current.insertImage(url);
|
editorRef.current.insertImage(url);
|
||||||
@ -107,7 +139,7 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
|
|||||||
// fallback
|
// fallback
|
||||||
setDraft((prev) => `${prev || ''}<p><img src="${url}" alt="" /></p>`);
|
setDraft((prev) => `${prev || ''}<p><img src="${url}" alt="" /></p>`);
|
||||||
}
|
}
|
||||||
}} />
|
}} showSetFeature onSetFeature={(url) => setMeta((m) => ({ ...m, featureImage: url }))} />
|
||||||
</Box>
|
</Box>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -7,7 +7,7 @@ type MediaItem = {
|
|||||||
lastModified: string | null;
|
lastModified: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MediaLibrary({ onInsert }: { onInsert: (url: string) => void }) {
|
export default function MediaLibrary({ onInsert, onSetFeature, showSetFeature }: { onInsert: (url: string) => void; onSetFeature?: (url: string) => void; showSetFeature?: boolean }) {
|
||||||
const [items, setItems] = useState<MediaItem[]>([]);
|
const [items, setItems] = useState<MediaItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@ -65,6 +65,9 @@ export default function MediaLibrary({ onInsert }: { onInsert: (url: string) =>
|
|||||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }} title={name}>{name}</Typography>
|
<Typography variant="caption" sx={{ display: 'block', mb: 1 }} title={name}>{name}</Typography>
|
||||||
<Stack direction="row" spacing={1}>
|
<Stack direction="row" spacing={1}>
|
||||||
<Button size="small" variant="outlined" onClick={() => onInsert(url)}>Insert</Button>
|
<Button size="small" variant="outlined" onClick={() => onInsert(url)}>Insert</Button>
|
||||||
|
{showSetFeature && onSetFeature && (
|
||||||
|
<Button size="small" variant="outlined" onClick={() => onSetFeature(url)}>Set as Feature</Button>
|
||||||
|
)}
|
||||||
<Button size="small" color="error" onClick={() => del(it.key)}>Delete</Button>
|
<Button size="small" color="error" onClick={() => del(it.key)}>Delete</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
35
apps/admin/src/components/MetadataPanel.tsx
Normal file
35
apps/admin/src/components/MetadataPanel.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { Box, Button, Stack, TextField, Typography } from '@mui/material';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export type Metadata = {
|
||||||
|
title: string;
|
||||||
|
tagsText: string; // comma-separated
|
||||||
|
canonicalUrl: string;
|
||||||
|
featureImage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MetadataPanel({ value, onChange, onPublish }: {
|
||||||
|
value: Metadata;
|
||||||
|
onChange: (v: Metadata) => void;
|
||||||
|
onPublish: (status: 'draft' | 'published') => void;
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const set = (patch: Partial<Metadata>) => onChange({ ...value, ...patch });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ border: '1px solid #eee', borderRadius: 1, p: 2 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ mb: 1 }}>Post Metadata</Typography>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<TextField label="Title" value={value.title} onChange={(e) => set({ title: e.target.value })} fullWidth />
|
||||||
|
<TextField label="Tags (comma-separated)" value={value.tagsText} onChange={(e) => set({ tagsText: e.target.value })} fullWidth />
|
||||||
|
<TextField label="Canonical URL" value={value.canonicalUrl} onChange={(e) => set({ canonicalUrl: e.target.value })} fullWidth />
|
||||||
|
<TextField label="Feature Image URL" value={value.featureImage || ''} onChange={(e) => set({ featureImage: e.target.value })} fullWidth />
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button variant="outlined" disabled={busy} onClick={() => onPublish('draft')}>Save Draft to Ghost</Button>
|
||||||
|
<Button variant="contained" disabled={busy} onClick={() => onPublish('published')}>Publish to Ghost</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -33,6 +33,8 @@
|
|||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.916.0",
|
"@aws-sdk/client-s3": "^3.916.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.916.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.2.0",
|
||||||
"content-disposition": "^1.0.0",
|
"content-disposition": "^1.0.0",
|
||||||
@ -50,6 +52,7 @@
|
|||||||
"finalhandler": "^2.1.0",
|
"finalhandler": "^2.1.0",
|
||||||
"fresh": "^2.0.0",
|
"fresh": "^2.0.0",
|
||||||
"http-errors": "^2.0.0",
|
"http-errors": "^2.0.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"merge-descriptors": "^2.0.0",
|
"merge-descriptors": "^2.0.0",
|
||||||
"mime-types": "^3.0.0",
|
"mime-types": "^3.0.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
|
|||||||
156
apps/api/src/ghost.ts
Normal file
156
apps/api/src/ghost.ts
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { fetch } from 'undici';
|
||||||
|
import { getPresignedUrl, getPublicUrlForKey } from './storage/s3';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function getGhostToken(): string {
|
||||||
|
const apiUrl = process.env.GHOST_ADMIN_API_URL || '';
|
||||||
|
const apiKey = process.env.GHOST_ADMIN_API_KEY || '';
|
||||||
|
if (!apiUrl || !apiKey) {
|
||||||
|
throw new Error('Missing GHOST_ADMIN_API_URL or GHOST_ADMIN_API_KEY');
|
||||||
|
}
|
||||||
|
const [keyId, secret] = apiKey.split(':');
|
||||||
|
if (!keyId || !secret) throw new Error('Invalid GHOST_ADMIN_API_KEY format');
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const token = jwt.sign(
|
||||||
|
{
|
||||||
|
iat: now,
|
||||||
|
exp: now + 5 * 60,
|
||||||
|
aud: '/admin/',
|
||||||
|
},
|
||||||
|
Buffer.from(secret, 'hex'),
|
||||||
|
{
|
||||||
|
keyid: keyId,
|
||||||
|
algorithm: 'HS256',
|
||||||
|
issuer: keyId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/post', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const apiUrl = process.env.GHOST_ADMIN_API_URL || '';
|
||||||
|
const token = getGhostToken();
|
||||||
|
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
html,
|
||||||
|
tags,
|
||||||
|
feature_image,
|
||||||
|
canonical_url,
|
||||||
|
status,
|
||||||
|
} = req.body as {
|
||||||
|
id?: string;
|
||||||
|
title: string;
|
||||||
|
html: string;
|
||||||
|
tags?: string[];
|
||||||
|
feature_image?: string;
|
||||||
|
canonical_url?: string;
|
||||||
|
status?: 'draft' | 'published';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!title || !html) {
|
||||||
|
return res.status(400).json({ error: 'title and html are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rewriteInternalMediaUrl(src: string, bucketFallback: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
if (!src) return src;
|
||||||
|
// handle absolute or relative /api/media/obj URLs
|
||||||
|
const isInternal = src.includes('/api/media/obj');
|
||||||
|
if (!isInternal) return src;
|
||||||
|
// parse query
|
||||||
|
const base = 'http://local';
|
||||||
|
const u = new URL(src, base);
|
||||||
|
const key = u.searchParams.get('key') || '';
|
||||||
|
const bucket = u.searchParams.get('bucket') || bucketFallback;
|
||||||
|
if (!key || !bucket) return src;
|
||||||
|
// Prefer a public base if provided, else presigned URL
|
||||||
|
const publicUrl = getPublicUrlForKey(key);
|
||||||
|
if (publicUrl) return publicUrl;
|
||||||
|
return await getPresignedUrl({ bucket, key, expiresInSeconds: 60 * 60 * 24 * 7 });
|
||||||
|
} catch {
|
||||||
|
return src;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rewriteHtmlImagesToPublic(htmlIn: string, bucketFallback: string): Promise<string> {
|
||||||
|
if (!htmlIn) return htmlIn;
|
||||||
|
const re = /<img\b[^>]*src=["']([^"']+)["'][^>]*>/gi;
|
||||||
|
const matches = [...htmlIn.matchAll(re)];
|
||||||
|
if (matches.length === 0) return htmlIn;
|
||||||
|
let out = '';
|
||||||
|
let last = 0;
|
||||||
|
for (const m of matches) {
|
||||||
|
const idx = m.index || 0;
|
||||||
|
const full = m[0];
|
||||||
|
const src = m[1];
|
||||||
|
const newSrc = await rewriteInternalMediaUrl(src, bucketFallback);
|
||||||
|
out += htmlIn.slice(last, idx) + full.replace(src, newSrc);
|
||||||
|
last = idx + full.length;
|
||||||
|
}
|
||||||
|
out += htmlIn.slice(last);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucketDefault = process.env.S3_BUCKET || '';
|
||||||
|
const rewrittenFeatureImage = await rewriteInternalMediaUrl(feature_image || '', bucketDefault);
|
||||||
|
const rewrittenHtml = await rewriteHtmlImagesToPublic(html, bucketDefault);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
posts: [
|
||||||
|
{
|
||||||
|
...(id ? { id } : {}),
|
||||||
|
title,
|
||||||
|
html: rewrittenHtml,
|
||||||
|
status: status || 'draft',
|
||||||
|
tags: tags && Array.isArray(tags) ? tags : [],
|
||||||
|
...(rewrittenFeatureImage ? { feature_image: rewrittenFeatureImage } : {}),
|
||||||
|
...(canonical_url ? { canonical_url } : {}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const base = apiUrl.replace(/\/$/, '');
|
||||||
|
const url = id ? `${base}/posts/${id}/?source=html` : `${base}/posts/?source=html`;
|
||||||
|
|
||||||
|
const method = id ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
const ghRes = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Accept-Version': 'v5.0',
|
||||||
|
Authorization: `Ghost ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = ghRes.headers.get('content-type') || '';
|
||||||
|
if (!ghRes.ok) {
|
||||||
|
const text = await ghRes.text();
|
||||||
|
console.error('Ghost API error:', ghRes.status, text);
|
||||||
|
return res.status(ghRes.status).json({ error: 'Ghost API error', detail: text });
|
||||||
|
}
|
||||||
|
if (!contentType.includes('application/json')) {
|
||||||
|
const text = await ghRes.text();
|
||||||
|
console.error('Ghost API non-JSON response (possible wrong GHOST_ADMIN_API_URL):', text.slice(0, 300));
|
||||||
|
return res.status(500).json({ error: 'Ghost response not JSON', detail: text.slice(0, 500) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = await ghRes.json();
|
||||||
|
const post = data?.posts?.[0] || {};
|
||||||
|
return res.json({ id: post.id, url: post.url, status: post.status });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Ghost publish failed:', err);
|
||||||
|
return res.status(500).json({ error: 'Ghost publish failed', detail: err?.message || String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@ -8,6 +8,7 @@ import authRouter from './auth';
|
|||||||
import mediaRouter from './media';
|
import mediaRouter from './media';
|
||||||
import sttRouter from './stt';
|
import sttRouter from './stt';
|
||||||
import draftsRouter from './drafts';
|
import draftsRouter from './drafts';
|
||||||
|
import ghostRouter from './ghost';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
console.log('ENV ADMIN_PASSWORD loaded:', Boolean(process.env.ADMIN_PASSWORD));
|
console.log('ENV ADMIN_PASSWORD loaded:', Boolean(process.env.ADMIN_PASSWORD));
|
||||||
@ -25,6 +26,7 @@ app.use('/api/auth', authRouter);
|
|||||||
app.use('/api/media', mediaRouter);
|
app.use('/api/media', mediaRouter);
|
||||||
app.use('/api/stt', sttRouter);
|
app.use('/api/stt', sttRouter);
|
||||||
app.use('/api/drafts', draftsRouter);
|
app.use('/api/drafts', draftsRouter);
|
||||||
|
app.use('/api/ghost', ghostRouter);
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
import { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
|
|
||||||
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
|
||||||
@ -17,6 +18,19 @@ export function getS3Client() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPresignedUrl(params: { bucket: string; key: string; expiresInSeconds?: number }): Promise<string> {
|
||||||
|
const s3 = getS3Client();
|
||||||
|
const cmd = new GetObjectCommand({ Bucket: params.bucket, Key: params.key });
|
||||||
|
const expiresIn = typeof params.expiresInSeconds === 'number' ? params.expiresInSeconds : 60 * 60 * 24 * 7; // 7 days
|
||||||
|
return await getSignedUrl(s3, cmd, { expiresIn });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPublicUrlForKey(key: string): string | null {
|
||||||
|
const base = process.env.PUBLIC_MEDIA_BASE_URL;
|
||||||
|
if (!base) return null;
|
||||||
|
return `${base.replace(/\/$/, '')}/${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listObjects(params: { bucket: string; prefix?: string; maxKeys?: number }) {
|
export async function listObjects(params: { bucket: string; prefix?: string; maxKeys?: number }) {
|
||||||
const s3 = getS3Client();
|
const s3 = getS3Client();
|
||||||
const cmd = new ListObjectsV2Command({
|
const cmd = new ListObjectsV2Command({
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"id": "31ba935b-4424-4226-9f8b-803d401022a2",
|
"id": "31ba935b-4424-4226-9f8b-803d401022a2",
|
||||||
"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>",
|
"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"
|
"updatedAt": "2025-10-24T09:28:18.204Z"
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user