updated editor and pageloading
This commit is contained in:
15
frontend/package-lock.json
generated
15
frontend/package-lock.json
generated
@@ -14,8 +14,10 @@
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.40.0",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"astro": "^6.0.8",
|
||||
"codemirror": "^6.0.2",
|
||||
@@ -2039,6 +2041,19 @@
|
||||
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@replit/codemirror-vim": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.3.0.tgz",
|
||||
"integrity": "sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@codemirror/commands": "6.x.x",
|
||||
"@codemirror/language": "6.x.x",
|
||||
"@codemirror/search": "6.x.x",
|
||||
"@codemirror/state": "6.x.x",
|
||||
"@codemirror/view": "6.x.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.40.0",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"astro": "^6.0.8",
|
||||
"codemirror": "^6.0.2",
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { marked } from 'marked';
|
||||
import hljs from 'highlight.js';
|
||||
import katex from 'katex';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
function renderMath(element: HTMLElement) {
|
||||
const delimiters = [
|
||||
{ left: '$$', right: '$$', display: true },
|
||||
{ left: '$', right: '$', display: false },
|
||||
{ left: '\\(', right: '\\)', display: false },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
];
|
||||
|
||||
const walk = (node: Node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement;
|
||||
if (el.tagName === 'CODE' || el.tagName === 'PRE') return;
|
||||
for (const child of Array.from(el.childNodes)) walk(child);
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent || '';
|
||||
for (const { left, right, display } of delimiters) {
|
||||
const idx = text.indexOf(left);
|
||||
if (idx === -1) continue;
|
||||
const end = text.indexOf(right, idx + left.length);
|
||||
if (end === -1) continue;
|
||||
const tex = text.slice(idx + left.length, end);
|
||||
try {
|
||||
const rendered = katex.renderToString(tex, { displayMode: display, throwOnError: false });
|
||||
const span = document.createElement('span');
|
||||
span.innerHTML = rendered;
|
||||
const range = document.createRange();
|
||||
range.setStart(node, idx);
|
||||
range.setEnd(node, end + right.length);
|
||||
range.deleteContents();
|
||||
range.insertNode(span);
|
||||
} catch { /* skip invalid tex */ }
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Multiple passes to catch nested/sequential math
|
||||
for (let i = 0; i < 3; i++) walk(element);
|
||||
}
|
||||
|
||||
export default function PostContent({ content, slug }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
const html = marked.parse(content);
|
||||
if (typeof html === 'string') {
|
||||
ref.current.innerHTML = html;
|
||||
} else {
|
||||
html.then(h => { if (ref.current) ref.current.innerHTML = h; });
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
// Wait a tick for innerHTML to settle
|
||||
const timer = setTimeout(() => {
|
||||
if (!ref.current) return;
|
||||
renderMath(ref.current);
|
||||
ref.current.querySelectorAll<HTMLElement>('pre code').forEach((block) => {
|
||||
hljs.highlightElement(block);
|
||||
});
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [content]);
|
||||
|
||||
// Show edit button if admin
|
||||
const isAdmin = typeof window !== 'undefined' && !!localStorage.getItem('admin_token');
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="mb-8 md:mb-12 border-b border-white/5 pb-8 md:pb-12">
|
||||
<a
|
||||
href="/"
|
||||
className="text-blue hover:text-sky transition-colors mb-6 md:mb-8 inline-flex items-center gap-2 group text-sm md:text-base"
|
||||
style={{ color: 'var(--blue)' }}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="md:w-5 md:h-5 transition-transform group-hover:-translate-x-1"><path d="m15 18-6-6 6-6"/></svg>
|
||||
Back to list
|
||||
</a>
|
||||
<div className="flex flex-col md:flex-row md:justify-between md:items-start mt-2 md:mt-4 gap-4">
|
||||
<h1 className="text-3xl md:text-5xl font-extrabold text-mauve">
|
||||
{formatSlug(slug)}
|
||||
</h1>
|
||||
{isAdmin && (
|
||||
<a
|
||||
href={`/admin/editor?edit=${slug}`}
|
||||
className="bg-surface0 hover:bg-surface1 text-blue px-3 py-1.5 md:px-4 md:py-2 rounded border border-surface1 transition-colors inline-flex items-center gap-2 text-sm md:text-base self-start"
|
||||
style={{ color: 'var(--blue)' }}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="md:w-4 md:h-4"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
|
||||
Edit
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div className="prose max-w-none" ref={ref} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSlug(slug: string) {
|
||||
if (!slug) return '';
|
||||
return slug
|
||||
.split('-')
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
61
frontend/src/components/react/PostEnhancer.tsx
Normal file
61
frontend/src/components/react/PostEnhancer.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useEffect } from 'react';
|
||||
import hljs from 'highlight.js';
|
||||
import katex from 'katex';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
function renderMath(element: HTMLElement) {
|
||||
const delimiters = [
|
||||
{ left: '$$', right: '$$', display: true },
|
||||
{ left: '$', right: '$', display: false },
|
||||
{ left: '\\(', right: '\\)', display: false },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
];
|
||||
|
||||
const walk = (node: Node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement;
|
||||
if (el.tagName === 'CODE' || el.tagName === 'PRE') return;
|
||||
for (const child of Array.from(el.childNodes)) walk(child);
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent || '';
|
||||
for (const { left, right, display } of delimiters) {
|
||||
const idx = text.indexOf(left);
|
||||
if (idx === -1) continue;
|
||||
const end = text.indexOf(right, idx + left.length);
|
||||
if (end === -1) continue;
|
||||
const tex = text.slice(idx + left.length, end);
|
||||
try {
|
||||
const rendered = katex.renderToString(tex, { displayMode: display, throwOnError: false });
|
||||
const span = document.createElement('span');
|
||||
span.innerHTML = rendered;
|
||||
const range = document.createRange();
|
||||
range.setStart(node, idx);
|
||||
range.setEnd(node, end + right.length);
|
||||
range.deleteContents();
|
||||
range.insertNode(span);
|
||||
} catch { /* skip invalid tex */ }
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < 3; i++) walk(element);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
containerId: string;
|
||||
}
|
||||
|
||||
export default function PostEnhancer({ containerId }: Props) {
|
||||
useEffect(() => {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
|
||||
renderMath(el);
|
||||
el.querySelectorAll<HTMLElement>('pre code').forEach((block) => {
|
||||
hljs.highlightElement(block);
|
||||
});
|
||||
}, [containerId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
||||
import { languages } from '@codemirror/language-data';
|
||||
import { defaultKeymap, indentWithTab } from '@codemirror/commands';
|
||||
import { vim } from '@replit/codemirror-vim';
|
||||
import { search, searchKeymap } from '@codemirror/search';
|
||||
import { closeBrackets } from '@codemirror/autocomplete';
|
||||
import { marked } from 'marked';
|
||||
import hljs from 'highlight.js';
|
||||
import katex from 'katex';
|
||||
import { getPost, savePost, deletePost, getAssets, ApiError } from '../../../lib/api';
|
||||
import type { Asset } from '../../../lib/types';
|
||||
import AssetManager from './AssetManager';
|
||||
@@ -36,16 +42,76 @@ const narlblogTheme = EditorView.theme({
|
||||
border: 'none',
|
||||
},
|
||||
'.cm-activeLineGutter': { backgroundColor: 'var(--surface0)' },
|
||||
// Search panel styling
|
||||
'.cm-panels': {
|
||||
backgroundColor: 'var(--mantle)',
|
||||
color: 'var(--text)',
|
||||
borderTop: '1px solid var(--surface1)',
|
||||
},
|
||||
'.cm-searchMatch': { backgroundColor: 'var(--yellow)', opacity: '0.3' },
|
||||
'.cm-searchMatch-selected': { backgroundColor: 'var(--peach)', opacity: '0.4' },
|
||||
// Vim cursor styling
|
||||
'.cm-fat-cursor': {
|
||||
backgroundColor: 'var(--mauve) !important',
|
||||
color: 'var(--crust) !important',
|
||||
},
|
||||
'&:not(.cm-focused) .cm-fat-cursor': {
|
||||
outline: '1px solid var(--mauve)',
|
||||
backgroundColor: 'transparent !important',
|
||||
},
|
||||
}, { dark: true });
|
||||
|
||||
function renderMathInElement(element: HTMLElement) {
|
||||
const delimiters = [
|
||||
{ left: '$$', right: '$$', display: true },
|
||||
{ left: '$', right: '$', display: false },
|
||||
{ left: '\\(', right: '\\)', display: false },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
];
|
||||
const walk = (node: Node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement;
|
||||
if (el.tagName === 'CODE' || el.tagName === 'PRE') return;
|
||||
for (const child of Array.from(el.childNodes)) walk(child);
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent || '';
|
||||
for (const { left, right, display } of delimiters) {
|
||||
const idx = text.indexOf(left);
|
||||
if (idx === -1) continue;
|
||||
const end = text.indexOf(right, idx + left.length);
|
||||
if (end === -1) continue;
|
||||
const tex = text.slice(idx + left.length, end);
|
||||
try {
|
||||
const rendered = katex.renderToString(tex, { displayMode: display, throwOnError: false });
|
||||
const span = document.createElement('span');
|
||||
span.innerHTML = rendered;
|
||||
const range = document.createRange();
|
||||
range.setStart(node, idx);
|
||||
range.setEnd(node, end + right.length);
|
||||
range.deleteContents();
|
||||
range.insertNode(span);
|
||||
} catch { /* skip */ }
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
for (let i = 0; i < 3; i++) walk(element);
|
||||
}
|
||||
|
||||
export default function Editor({ editSlug }: Props) {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [slug, setSlug] = useState(editSlug || '');
|
||||
const [summary, setSummary] = useState('');
|
||||
const [originalSlug, setOriginalSlug] = useState(editSlug || '');
|
||||
const [alert, setAlert] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [vimEnabled, setVimEnabled] = useState(() =>
|
||||
typeof window !== 'undefined' && window.innerWidth > 768
|
||||
);
|
||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||
const [autocompleteAssets, setAutocompleteAssets] = useState<Asset[]>([]);
|
||||
const [autocompletePos, setAutocompletePos] = useState({ top: 0, left: 0 });
|
||||
@@ -56,21 +122,41 @@ export default function Editor({ editSlug }: Props) {
|
||||
setTimeout(() => setAlert(null), 5000);
|
||||
}
|
||||
|
||||
// Initialize CodeMirror 6
|
||||
useEffect(() => {
|
||||
if (!editorRef.current || viewRef.current) return;
|
||||
const updatePreview = useCallback(() => {
|
||||
if (!showPreview || !viewRef.current || !previewRef.current) return;
|
||||
const content = viewRef.current.state.doc.toString();
|
||||
const result = marked.parse(content);
|
||||
if (typeof result === 'string') {
|
||||
previewRef.current.innerHTML = result;
|
||||
} else {
|
||||
result.then(h => { if (previewRef.current) previewRef.current.innerHTML = h; });
|
||||
}
|
||||
// Enhance preview after render
|
||||
requestAnimationFrame(() => {
|
||||
if (!previewRef.current) return;
|
||||
renderMathInElement(previewRef.current);
|
||||
previewRef.current.querySelectorAll<HTMLElement>('pre code').forEach(block => {
|
||||
hljs.highlightElement(block);
|
||||
});
|
||||
});
|
||||
}, [showPreview]);
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: '',
|
||||
extensions: [
|
||||
keymap.of([...defaultKeymap, indentWithTab]),
|
||||
function buildExtensions() {
|
||||
const exts = [
|
||||
...(vimEnabled ? [vim()] : []),
|
||||
keymap.of([...defaultKeymap, ...searchKeymap, indentWithTab]),
|
||||
search(),
|
||||
closeBrackets(),
|
||||
markdown({ base: markdownLanguage, codeLanguages: languages }),
|
||||
EditorView.lineWrapping,
|
||||
narlblogTheme,
|
||||
cmPlaceholder('# Hello World\nWrite your markdown here...'),
|
||||
EditorView.updateListener.of(update => {
|
||||
if (!update.docChanged) return;
|
||||
// Check for autocomplete trigger
|
||||
// Debounced preview update
|
||||
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
|
||||
previewTimerRef.current = setTimeout(updatePreview, 300);
|
||||
// Autocomplete trigger
|
||||
const pos = update.state.selection.main.head;
|
||||
const line = update.state.doc.lineAt(pos);
|
||||
const textBefore = line.text.slice(0, pos - line.from);
|
||||
@@ -81,14 +167,27 @@ export default function Editor({ editSlug }: Props) {
|
||||
setShowAutocomplete(false);
|
||||
}
|
||||
}),
|
||||
],
|
||||
});
|
||||
];
|
||||
return exts;
|
||||
}
|
||||
|
||||
// Initialize / rebuild CodeMirror when vim mode toggles
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
// Preserve content if rebuilding
|
||||
const oldContent = viewRef.current?.state.doc.toString() || '';
|
||||
if (viewRef.current) viewRef.current.destroy();
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: oldContent,
|
||||
extensions: buildExtensions(),
|
||||
});
|
||||
const view = new EditorView({ state, parent: editorRef.current });
|
||||
viewRef.current = view;
|
||||
|
||||
return () => { view.destroy(); viewRef.current = null; };
|
||||
}, []);
|
||||
}, [vimEnabled]);
|
||||
|
||||
// Load existing post for editing
|
||||
useEffect(() => {
|
||||
@@ -103,11 +202,15 @@ export default function Editor({ editSlug }: Props) {
|
||||
}).catch(() => showAlertMsg('Failed to load post.', 'error'));
|
||||
}, [editSlug]);
|
||||
|
||||
// Update preview when toggled on
|
||||
useEffect(() => {
|
||||
if (showPreview) updatePreview();
|
||||
}, [showPreview, updatePreview]);
|
||||
|
||||
async function triggerAutocomplete(view: EditorView) {
|
||||
try {
|
||||
const assets = await getAssets();
|
||||
setAutocompleteAssets(assets.slice(0, 8));
|
||||
// Position near cursor
|
||||
const pos = view.state.selection.main.head;
|
||||
const coords = view.coordsAtPos(pos);
|
||||
if (coords) {
|
||||
@@ -180,7 +283,6 @@ export default function Editor({ editSlug }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
// Close autocomplete on outside click
|
||||
useEffect(() => {
|
||||
if (!showAutocomplete) return;
|
||||
const handler = () => setShowAutocomplete(false);
|
||||
@@ -247,20 +349,49 @@ export default function Editor({ editSlug }: Props) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="relative">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-2 gap-2">
|
||||
<label className="block text-sm font-medium text-subtext1 italic">Tip: Type '/' to browse your assets</label>
|
||||
{/* Editor Toolbar */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="block text-sm font-medium text-subtext1 italic">Tip: Type '/' to browse assets</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVimEnabled(v => !v)}
|
||||
className={`text-xs px-3 py-1.5 rounded border transition-colors font-mono ${
|
||||
vimEnabled
|
||||
? 'bg-mauve/20 text-mauve border-mauve/30'
|
||||
: 'bg-surface0 text-subtext0 border-surface1 hover:text-text'
|
||||
}`}
|
||||
title={vimEnabled ? 'Vim mode ON' : 'Vim mode OFF'}
|
||||
>
|
||||
{vimEnabled ? 'VIM' : 'vim'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(p => !p)}
|
||||
className={`text-xs px-3 py-1.5 rounded border transition-colors ${
|
||||
showPreview
|
||||
? 'bg-blue/20 text-blue border-blue/30'
|
||||
: 'bg-surface0 text-subtext0 border-surface1 hover:text-text'
|
||||
}`}
|
||||
>
|
||||
{showPreview ? 'Hide Preview' : 'Show Preview'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(true)}
|
||||
className="text-sm bg-surface0 hover:bg-surface1 text-lavender px-4 py-2 rounded border border-surface1 transition-colors inline-flex items-center gap-2"
|
||||
className="text-sm bg-surface0 hover:bg-surface1 text-lavender px-4 py-1.5 rounded border border-surface1 transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
|
||||
Asset Library
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
|
||||
Assets
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor + Preview */}
|
||||
<div className={`relative ${showPreview ? 'grid grid-cols-1 md:grid-cols-2 gap-4' : ''}`}>
|
||||
<div className="relative">
|
||||
<div ref={editorRef} className="min-h-[500px]" />
|
||||
|
||||
{/* Autocomplete dropdown */}
|
||||
@@ -295,6 +426,17 @@ export default function Editor({ editSlug }: Props) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live Preview */}
|
||||
{showPreview && (
|
||||
<div className="border border-surface1 rounded-xl bg-crust/50 overflow-y-auto max-h-[600px] min-h-[500px]">
|
||||
<div className="sticky top-0 bg-mantle px-4 py-2 text-xs text-subtext0 uppercase border-b border-surface1 z-10">
|
||||
Preview
|
||||
</div>
|
||||
<div ref={previewRef} className="prose max-w-none p-4 md:p-6" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Asset Modal */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import PostContent from '../../components/react/PostContent';
|
||||
import PostEnhancer from '../../components/react/PostEnhancer';
|
||||
import { marked } from 'marked';
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const API_URL = (typeof process !== 'undefined' ? process.env.PUBLIC_API_URL : import.meta.env.PUBLIC_API_URL) || 'http://localhost:3000';
|
||||
@@ -11,12 +12,14 @@ interface PostDetail {
|
||||
}
|
||||
|
||||
let post: PostDetail | null = null;
|
||||
let html = '';
|
||||
let error = '';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/posts/${slug}`);
|
||||
if (response.ok) {
|
||||
post = await response.json();
|
||||
html = await marked.parse(post!.content);
|
||||
} else {
|
||||
error = 'Post not found';
|
||||
}
|
||||
@@ -26,13 +29,12 @@ try {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
function formatSlug(slug: string) {
|
||||
if (!slug) return '';
|
||||
return slug
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
function formatSlug(s: string) {
|
||||
if (!s) return '';
|
||||
return s.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
||||
}
|
||||
|
||||
const isAdmin = Astro.cookies.has('admin_token');
|
||||
---
|
||||
|
||||
<Layout title={post ? formatSlug(post.slug) : 'Post'}>
|
||||
@@ -45,7 +47,41 @@ function formatSlug(slug: string) {
|
||||
)}
|
||||
|
||||
{post && (
|
||||
<PostContent client:load content={post.content} slug={post.slug} />
|
||||
<>
|
||||
<header class="mb-8 md:mb-12 border-b border-white/5 pb-8 md:pb-12">
|
||||
<a
|
||||
href="/"
|
||||
class="text-blue hover:text-sky transition-colors mb-6 md:mb-8 inline-flex items-center gap-2 group text-sm md:text-base"
|
||||
style="color: var(--blue);"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="md:w-5 md:h-5 transition-transform group-hover:-translate-x-1"><path d="m15 18-6-6 6-6"/></svg>
|
||||
Back to list
|
||||
</a>
|
||||
<div class="flex flex-col md:flex-row md:justify-between md:items-start mt-2 md:mt-4 gap-4">
|
||||
<h1 class="text-3xl md:text-5xl font-extrabold text-mauve">
|
||||
{formatSlug(post.slug)}
|
||||
</h1>
|
||||
<a
|
||||
id="edit-link"
|
||||
href={`/admin/editor?edit=${post.slug}`}
|
||||
class="bg-surface0 hover:bg-surface1 text-blue px-3 py-1.5 md:px-4 md:py-2 rounded border border-surface1 transition-colors inline-flex items-center gap-2 text-sm md:text-base self-start hidden"
|
||||
style="color: var(--blue);"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="md:w-4 md:h-4"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
|
||||
Edit
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<div id="post-content" class="prose max-w-none" set:html={html} />
|
||||
<PostEnhancer client:load containerId="post-content" />
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
if (localStorage.getItem('admin_token')) {
|
||||
const el = document.getElementById('edit-link');
|
||||
if (el) el.classList.remove('hidden');
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user