Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion frontend/core/__tests__/useArticle.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ function TestHarness({ articleId }) {
'data-content': state.contentStr,
'data-error': state.error,
'data-title': state.title,
'data-description': state.description,
});
}

Expand Down Expand Up @@ -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' }));
Expand All @@ -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'));

Expand Down
48 changes: 45 additions & 3 deletions frontend/core/components/ArticleForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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) {
Expand Down Expand Up @@ -406,8 +425,31 @@ export default function ArticleForm() {
placeholder="Article title"
value={title}
onChange={(e) => setTitle(e.target.value)}
onClick={handleDoubleTapSelect}
/>
<BlockNoteEditor initialContent={initialContent} onReady={(ed) => { editorRef.current = ed; }} />
<div className="article-editor-description-wrap">
<div className="article-editor-section-header">
<span className="article-editor-label">Description</span>
</div>
<div className="article-editor-section-header">
<span className="desc-limit-hint">Maximum 300 characters</span>
<span className="char-counter">{description.length}/300</span>
</div>
<textarea
className="article-editor-description"
placeholder="Short description (optional)"
maxLength={300}
value={description}
onChange={(e) => setDescription(e.target.value)}
onClick={handleDoubleTapSelect}
/>
</div>
<div className="article-editor-body-wrap">
<div className="article-editor-section-header">
<span className="article-editor-section-title">Content</span>
</div>
<BlockNoteEditor initialContent={initialContent} onReady={(ed) => { editorRef.current = ed; }} />
</div>
<div className="article-editor-actions">
<button className="btn" onClick={handleSubmit} disabled={saving}>
{saving ? 'Saving...' : page === 'create' ? 'Publish' : 'Save'}
Expand Down
15 changes: 0 additions & 15 deletions frontend/core/entry.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,4 @@ if (root && Component) {
</ErrorBoundary>
</React.StrictMode>,
);

if (window.innerWidth <= 768) {
const setPad = () => {
const ed = root.querySelector('.bn-editor');
if (!ed) return false;
ed.style.setProperty('padding-inline', '28px 2px');
return true;
};
if (!setPad()) {
const obs = new MutationObserver(() => {
if (setPad()) obs.disconnect();
});
obs.observe(root, { childList: true, subtree: true });
}
}
}
4 changes: 3 additions & 1 deletion frontend/core/hooks/useArticle.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export default function useArticle(articleId) {
const [loaded, setLoaded] = useState(!articleId || !!ssrContent);
const [contentStr, setContentStr] = useState(ssrContent || '');
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [error, setError] = useState('');

useEffect(() => {
Expand All @@ -23,6 +24,7 @@ export default function useArticle(articleId) {
const r = await fetch(`/api/articles/${articleId}`);
const data = await r.json();
setTitle(data.title || '');
setDescription(data.description || '');
setContentStr(data.content || '');
} catch {
setError('Failed to load article.');
Expand All @@ -32,5 +34,5 @@ export default function useArticle(articleId) {
})();
}, [articleId]);

return { loaded, contentStr, title, error };
return { loaded, contentStr, title, description, error };
}
3 changes: 2 additions & 1 deletion frontend/core/utils/video-override-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ function buildYouTubeIFrame(url, isEditable) {
if (isEditable) {
iframe.style.pointerEvents = 'none';

wrapper.addEventListener('dblclick', () => {
wrapper.addEventListener('click', (e) => {
e.stopPropagation();
iframe.style.pointerEvents = '';
if (iframe.contentWindow) iframe.contentWindow.focus();

Expand Down
Loading
Loading