import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useSwipeable } from 'react-swipeable'; import { articlesApi, type Article } from '../../api/articles'; import { patchArticle, useArticleActions } from '../../hooks/useArticleActions'; import { usePrefs } from '../../stores/prefsStore'; import { formatLong, readingTimeLabel } from '../../lib/format'; import { prepareArticleHtml } from '../../lib/html'; import { ShareButton } from '../ShareButton'; import { ReaderSettings } from './ReaderSettings'; interface ReaderProps { /** The list item being read (title, image… shown instantly while the body loads). */ article: Article; next?: Article; hasPrev?: boolean; onClose: () => void; onNext?: () => void; onPrev?: () => void; } const MEASURES = { narrow: '58ch', medium: '66ch', wide: '76ch' } as const; const FONTS = { serif: 'var(--font-reading)', sans: 'var(--font-sans)', legible: 'var(--font-legible)', } as const; function isTypingTarget(t: EventTarget | null) { const el = t as HTMLElement | null; return !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName)); } const Chevron = ({ dir }: { dir: 'left' | 'right' }) => ( ); /** * Article opened as a large card above the (dimmed) grid. * Close: ✕, Escape or a click beside the card. Previous / next: side arrows * on desktop, bottom bar on mobile, j/k or ←/→ on the keyboard. */ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, onPrev }: ReaderProps) { const qc = useQueryClient(); const { toggleRead, toggleFavorite } = useArticleActions(); const fontSize = usePrefs((s) => s.fontSize); const lineHeight = usePrefs((s) => s.lineHeight); const width = usePrefs((s) => s.width); const font = usePrefs((s) => s.font); const setFontSize = usePrefs((s) => s.setFontSize); const overlayRef = useRef(null); const progressRef = useRef(null); const titleRef = useRef(null); const [settingsOpen, setSettingsOpen] = useState(false); const [summarizing, setSummarizing] = useState(false); const [summaryError, setSummaryError] = useState(''); const { data: detail, isError, refetch } = useQuery({ queryKey: ['article', listArticle.id], queryFn: () => articlesApi.get(listArticle.id), staleTime: 5 * 60 * 1000, }); // Live flags (read/favorite/summary) come from the list cache, content from the detail. const article = { ...detail, ...listArticle, ai_summary: listArticle.ai_summary || detail?.ai_summary }; const html = useMemo( () => prepareArticleHtml(detail?.content || detail?.summary || '', listArticle.image_url), [detail?.content, detail?.summary, listArticle.image_url], ); // Prefetch the next article so "next" is instant. useEffect(() => { if (!next) return; const t = window.setTimeout(() => { qc.prefetchQuery({ queryKey: ['article', next.id], queryFn: () => articlesApi.get(next.id), staleTime: 5 * 60 * 1000 }); if (next.image_url) new Image().src = next.image_url; }, 500); return () => window.clearTimeout(t); }, [next, qc]); // Modal behaviour: the app behind is inert and doesn't scroll; focus // moves to the title and returns to the card that opened the article. useLayoutEffect(() => { const root = document.getElementById('root'); const previous = document.activeElement as HTMLElement | null; root?.setAttribute('inert', ''); document.body.style.overflow = 'hidden'; overlayRef.current?.scrollTo({ top: 0 }); titleRef.current?.focus({ preventScroll: true }); return () => { root?.removeAttribute('inert'); document.body.style.overflow = ''; previous?.focus?.({ preventScroll: true }); }; }, []); // Reading progress without React re-renders. useEffect(() => { const el = overlayRef.current; if (!el) return; let frame = 0; const update = () => { frame = 0; const max = el.scrollHeight - el.clientHeight; const ratio = max > 0 ? Math.min(1, el.scrollTop / max) : 1; if (progressRef.current) progressRef.current.style.transform = `scaleX(${ratio})`; }; const onScroll = () => { if (!frame) frame = requestAnimationFrame(update); }; update(); el.addEventListener('scroll', onScroll, { passive: true }); return () => { el.removeEventListener('scroll', onScroll); cancelAnimationFrame(frame); }; }, [html]); const openOriginal = useCallback(() => { if (listArticle.url) window.open(listArticle.url, '_blank', 'noopener,noreferrer'); }, [listArticle.url]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || isTypingTarget(e.target)) return; const el = overlayRef.current; switch (e.key) { case 'Escape': onClose(); break; case 'j': case 'ArrowRight': if (onNext) onNext(); break; case 'k': case 'ArrowLeft': if (onPrev && hasPrev) onPrev(); break; case 's': toggleFavorite(listArticle); break; case 'm': toggleRead(listArticle); break; case 'v': openOriginal(); break; case '+': case '=': setFontSize(fontSize + 1); break; case '-': setFontSize(fontSize - 1); break; case ' ': { if (!el || e.shiftKey) return; const atEnd = el.scrollTop + el.clientHeight >= el.scrollHeight - 8; if (atEnd && onNext) { e.preventDefault(); onNext(); } return; } default: return; } e.preventDefault(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose, onNext, onPrev, hasPrev, toggleFavorite, toggleRead, listArticle, openOriginal, fontSize, setFontSize]); // Optional touch shortcut on phones; the buttons below do the same. const swipe = useSwipeable({ onSwipedLeft: (ev) => { if (!(ev.event.target as Element).closest('pre,table') && onNext) onNext(); }, onSwipedRight: (ev) => { if (!(ev.event.target as Element).closest('pre,table') && hasPrev && onPrev) onPrev(); }, trackMouse: false, delta: 90, swipeDuration: 450, preventScrollOnSwipe: false, }); const summarize = async () => { setSummarizing(true); setSummaryError(''); try { const res = await articlesApi.summarize(listArticle.id); patchArticle(qc, listArticle.id, { ai_summary: res.summary }); } catch { setSummaryError('Le résumé n’a pas pu être généré. Réessayez plus tard.'); } finally { setSummarizing(false); } }; const style = { '--reader-size': `${fontSize}px`, '--reader-leading': String(lineHeight), '--reader-font': FONTS[font], letterSpacing: font === 'legible' ? '0.02em' : undefined, } as React.CSSProperties; const date = listArticle.published_at || listArticle.created_at; const body = (
{ // Click beside the card closes it. if (e.target === e.currentTarget) onClose(); }} > {/* Side arrows (desktop) */} {hasPrev && onPrev && ( )} {onNext && ( )}
{/* Card header: source on the left, actions and close on the right */}

{listArticle.feed_title || 'Article'}

, {readingTimeLabel(listArticle.reading_time)} de lecture

{settingsOpen && setSettingsOpen(false)} />}
{listArticle.image_url && ( (e.currentTarget.style.display = 'none')} /> )}

{listArticle.title}

{listArticle.author &&

Par {listArticle.author}

} {article.ai_summary ? ( ) : (
{summaryError &&

{summaryError}

}
)}
{detail ? ( html ? (
) : (

Ce flux ne publie que le titre de l’article.

) ) : isError ? (

Impossible de charger l’article.{' '}

) : (
{[92, 100, 85, 97, 60].map((w, i) => (
))}
)} {listArticle.url && ( Lire sur le site d’origine )}
{/* Bottom navigation: always visible on mobile, "up next" on every size */}
); return createPortal(body, document.body); }