diff --git a/frontend/core/__tests__/useArticle.test.js b/frontend/core/__tests__/useArticle.test.js index ec0fe5c5..5df92251 100644 --- a/frontend/core/__tests__/useArticle.test.js +++ b/frontend/core/__tests__/useArticle.test.js @@ -10,6 +10,7 @@ function TestHarness({ articleId }) { 'data-content': state.contentStr, 'data-error': state.error, 'data-title': state.title, + 'data-description': state.description, }); } @@ -46,7 +47,7 @@ describe('useArticle', () => { it('fetches article on mount and returns content', async () => { globalThis.fetch.mockResolvedValue({ - json: () => Promise.resolve({ title: 'My Title', content: '{"type":"doc"}' }), + json: () => Promise.resolve({ title: 'My Title', description: 'A short description', content: '{"type":"doc"}' }), }); const container = render(React.createElement(TestHarness, { articleId: '42' })); @@ -58,9 +59,25 @@ describe('useArticle', () => { expect(container.querySelector('[data-loaded="true"]')).not.toBeNull(); expect(container.querySelector('[data-title="My Title"]')).not.toBeNull(); + expect(container.querySelector('[data-description="A short description"]')).not.toBeNull(); expect(container.querySelector('[data-content]')?.dataset.content).toBe('{"type":"doc"}'); }); + it('returns empty description when API returns no description', async () => { + globalThis.fetch.mockResolvedValue({ + json: () => Promise.resolve({ title: 'No Desc', content: '{}' }), + }); + + const container = render(React.createElement(TestHarness, { articleId: '42' })); + + await act(async () => { + await flushMicrotasks(); + }); + + expect(container.querySelector('[data-title="No Desc"]')).not.toBeNull(); + expect(container.querySelector('[data-description=""]')).not.toBeNull(); + }); + it('sets error when fetch fails', async () => { globalThis.fetch.mockRejectedValue(new Error('Network error')); diff --git a/frontend/core/components/ArticleForm.jsx b/frontend/core/components/ArticleForm.jsx index 66c2e790..6c4068ef 100644 --- a/frontend/core/components/ArticleForm.jsx +++ b/frontend/core/components/ArticleForm.jsx @@ -196,6 +196,8 @@ function BlockNoteEditor({ initialContent, onReady }) { } const mod = e.ctrlKey || e.metaKey; if (!mod || (e.key !== 'z' && e.key !== 'y')) return; + const tag = document.activeElement?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; const editorEl = editor.dom?.closest('.bn-editor') || editor.dom; if (editorEl?.contains(document.activeElement)) return; e.preventDefault(); @@ -337,18 +339,35 @@ export default function ArticleForm() { const page = root?.dataset.page; const articleId = root?.dataset.articleId; - const { loaded, contentStr, title: loadedTitle, error: loadError } = useArticle( + const { loaded, contentStr, title: loadedTitle, description: loadedDescription, error: loadError } = useArticle( page === 'edit' ? articleId : null, ); const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); const [error, setError] = useState(''); const [saving, setSaving] = useState(false); const editorRef = useRef(null); + const lastTapRef = useRef({ time: 0, target: null }); + + const handleDoubleTapSelect = useCallback((e) => { + const now = Date.now(); + const last = lastTapRef.current; + if (last.target === e.currentTarget && now - last.time < 400) { + e.currentTarget.select(); + lastTapRef.current = { time: 0, target: null }; + } else { + lastTapRef.current = { time: now, target: e.currentTarget }; + } + }, []); useEffect(() => { if (loadedTitle) setTitle(loadedTitle); }, [loadedTitle]); + useEffect(() => { + if (loadedDescription) setDescription(loadedDescription); + }, [loadedDescription]); + useCodeBlockGapClick(editorRef); const handleSubmit = async () => { @@ -367,7 +386,7 @@ export default function ArticleForm() { const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title, content }), + body: JSON.stringify({ title, description, content }), }); if (res.ok) { @@ -406,8 +425,31 @@ export default function ArticleForm() { placeholder="Article title" value={title} onChange={(e) => setTitle(e.target.value)} + onClick={handleDoubleTapSelect} /> - { editorRef.current = ed; }} /> +
+
+ Description +
+
+ Maximum 300 characters + {description.length}/300 +
+