diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3913e2f..c818a45 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 482ee94..c6a429d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/react/PostContent.tsx b/frontend/src/components/react/PostContent.tsx deleted file mode 100644 index bbec845..0000000 --- a/frontend/src/components/react/PostContent.tsx +++ /dev/null @@ -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(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('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 ( - <> -
- - - Back to list - -
-

- {formatSlug(slug)} -

- {isAdmin && ( - - - Edit - - )} -
-
-
- - ); -} - -function formatSlug(slug: string) { - if (!slug) return ''; - return slug - .split('-') - .map(w => w.charAt(0).toUpperCase() + w.slice(1)) - .join(' '); -} diff --git a/frontend/src/components/react/PostEnhancer.tsx b/frontend/src/components/react/PostEnhancer.tsx new file mode 100644 index 0000000..c0a1342 --- /dev/null +++ b/frontend/src/components/react/PostEnhancer.tsx @@ -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('pre code').forEach((block) => { + hljs.highlightElement(block); + }); + }, [containerId]); + + return null; +} diff --git a/frontend/src/components/react/admin/Editor.tsx b/frontend/src/components/react/admin/Editor.tsx index 6ff3c9a..9080efd 100644 --- a/frontend/src/components/react/admin/Editor.tsx +++ b/frontend/src/components/react/admin/Editor.tsx @@ -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(null); const viewRef = useRef(null); + const previewRef = useRef(null); + const previewTimerRef = useRef | 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([]); const [autocompletePos, setAutocompletePos] = useState({ top: 0, left: 0 }); @@ -56,39 +122,72 @@ export default function Editor({ editSlug }: Props) { setTimeout(() => setAlert(null), 5000); } - // Initialize CodeMirror 6 + 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('pre code').forEach(block => { + hljs.highlightElement(block); + }); + }); + }, [showPreview]); + + 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; + // 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); + const lastChar = textBefore.slice(-1); + if (lastChar === '/' || lastChar === '!') { + triggerAutocomplete(update.view); + } else if (lastChar === ' ' || textBefore.length === 0) { + setShowAutocomplete(false); + } + }), + ]; + return exts; + } + + // Initialize / rebuild CodeMirror when vim mode toggles useEffect(() => { - if (!editorRef.current || viewRef.current) return; + 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: '', - extensions: [ - keymap.of([...defaultKeymap, indentWithTab]), - 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 - const pos = update.state.selection.main.head; - const line = update.state.doc.lineAt(pos); - const textBefore = line.text.slice(0, pos - line.from); - const lastChar = textBefore.slice(-1); - if (lastChar === '/' || lastChar === '!') { - triggerAutocomplete(update.view); - } else if (lastChar === ' ' || textBefore.length === 0) { - setShowAutocomplete(false); - } - }), - ], + 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,51 +349,91 @@ export default function Editor({ editSlug }: Props) { />
- {/* Editor */} -
-
- + {/* Editor Toolbar */} +
+
+ +
+
+ +
+
-
+ {/* Editor + Preview */} +
+
+
- {/* Autocomplete dropdown */} - {showAutocomplete && autocompleteAssets.length > 0 && ( -
e.stopPropagation()} - > -
Assets Library
-
    - {autocompleteAssets.map(asset => { - const img = /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(asset.name); - return ( -
  • insertAssetMarkdown(asset)} - className="px-4 py-2 hover:bg-mauve/20 cursor-pointer text-sm truncate text-subtext1 hover:text-mauve flex items-center gap-3 transition-colors" - > -
    - {img ? ( - - ) : ( - - )} -
    - {asset.name} -
  • - ); - })} -
+ {/* Autocomplete dropdown */} + {showAutocomplete && autocompleteAssets.length > 0 && ( +
e.stopPropagation()} + > +
Assets Library
+
    + {autocompleteAssets.map(asset => { + const img = /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(asset.name); + return ( +
  • insertAssetMarkdown(asset)} + className="px-4 py-2 hover:bg-mauve/20 cursor-pointer text-sm truncate text-subtext1 hover:text-mauve flex items-center gap-3 transition-colors" + > +
    + {img ? ( + + ) : ( + + )} +
    + {asset.name} +
  • + ); + })} +
+
+ )} +
+ + {/* Live Preview */} + {showPreview && ( +
+
+ Preview +
+
)}
diff --git a/frontend/src/pages/posts/[slug].astro b/frontend/src/pages/posts/[slug].astro index fcfb071..0a00292 100644 --- a/frontend/src/pages/posts/[slug].astro +++ b/frontend/src/pages/posts/[slug].astro @@ -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'); --- @@ -45,7 +47,41 @@ function formatSlug(slug: string) { )} {post && ( - + <> +
+ + + Back to list + +
+

+ {formatSlug(post.slug)} +

+ +
+
+
+ + )} + +