Merge pull request #7 from R0m1k3/claude/quirky-bardeen-97n4j4

Images des articles rétablies, grille pleine largeur, passe de revue design
This commit is contained in:
LogiFlow authored and GitHub committed 2026-10-09 11:37:47 +02:00
commit cd73b3d09d
9 files changed
+32 -26

No files matched your search

+8 -3
View File
@@ -15,9 +15,10 @@ interface ArticleCardProps {
/** Initial-letter cover used when a feed provides no image (no third-party fallback). */ /** Initial-letter cover used when a feed provides no image (no third-party fallback). */
function CoverFallback({ title, feed }: { title: string; feed?: string }) { function CoverFallback({ title, feed }: { title: string; feed?: string }) {
const initial = (feed || title).trim().charAt(0).toUpperCase();
return ( return (
<div className="w-full h-full bg-gradient-to-br from-nature/90 to-nature-dark flex items-end p-5"> <div className="w-full h-full bg-nature/12 flex items-center justify-center" aria-hidden="true">
<span className="font-serif italic text-on-nature/90 text-lg leading-snug line-clamp-3">{feed || title}</span> <span className="font-serif italic text-nature/45 text-7xl leading-none select-none">{initial}</span>
</div> </div>
); );
} }
@@ -25,6 +26,7 @@ function CoverFallback({ title, feed }: { title: string; feed?: string }) {
function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onToggleFavorite }: ArticleCardProps) { function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onToggleFavorite }: ArticleCardProps) {
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0);
const [imgFailed, setImgFailed] = useState(false); const [imgFailed, setImgFailed] = useState(false);
const [imgLoaded, setImgLoaded] = useState(false);
// Touch-only swipe: right = read/unread, left = favorite. Mouse drags (text // Touch-only swipe: right = read/unread, left = favorite. Mouse drags (text
// selection) never trigger it. // selection) never trigger it.
@@ -80,12 +82,15 @@ function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onTo
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
width={640} width={640}
height={360} height={360}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-[1.03]" className={`w-full h-full object-cover transition-[transform,opacity] duration-700 group-hover:scale-[1.03] ${imgLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setImgLoaded(true)}
onError={() => setImgFailed(true)} onError={() => setImgFailed(true)}
/> />
) : ( ) : (
<CoverFallback title={article.title} feed={article.feed_title} /> <CoverFallback title={article.title} feed={article.feed_title} />
)} )}
{/* Light bottom shade: gives the cover depth and lifts the feed chip. */}
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/30 to-transparent pointer-events-none" aria-hidden="true" />
<span className="absolute bottom-3 left-3 chip bg-carbon-light/95 shadow-sm max-w-[75%] truncate"> <span className="absolute bottom-3 left-3 chip bg-carbon-light/95 shadow-sm max-w-[75%] truncate">
{article.feed_title || 'Journal'} {article.feed_title || 'Journal'}
</span> </span>
+2 -2
View File
@@ -38,7 +38,7 @@ export function AuthShell({ eyebrow, title, subtitle, children }: AuthShellProps
transition={{ duration: 0.7, delay: 0.1 }} transition={{ duration: 0.7, delay: 0.1 }}
className="relative z-10 space-y-6" className="relative z-10 space-y-6"
> >
<h2 className="text-5xl font-serif italic leading-[1.1] text-balance"> <h2 className="text-5xl font-serif italic leading-[1.1] text-balance text-white">
Le calme dans le chaos de l'information. Le calme dans le chaos de l'information.
</h2> </h2>
<p className="text-white/70 font-reading text-lg max-w-sm leading-relaxed"> <p className="text-white/70 font-reading text-lg max-w-sm leading-relaxed">
@@ -46,7 +46,7 @@ export function AuthShell({ eyebrow, title, subtitle, children }: AuthShellProps
</p> </p>
</motion.div> </motion.div>
<p className="relative z-10 text-white/40 text-[10px] uppercase tracking-[0.4em] font-bold"> <p className="relative z-10 text-white/50 text-xs">
FlowReader · Édition 2026 FlowReader · Édition 2026
</p> </p>
</aside> </aside>
+1 -1
View File
@@ -211,7 +211,7 @@ export function TopBar({ onSelectFeed, selectedFeedId, onEnterFocus }: TopBarPro
return ( return (
<> <>
<header className="sticky top-0 z-30 bg-carbon/95 border-b border-paper-muted/12" style={{ paddingTop: 'env(safe-area-inset-top)' }}> <header className="sticky top-0 z-30 bg-carbon/95 border-b border-paper-muted/12" style={{ paddingTop: 'env(safe-area-inset-top)' }}>
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 md:px-12 h-16 flex items-center gap-3 sm:gap-6"> <div className="max-w-[2400px] mx-auto px-4 sm:px-6 md:px-12 h-16 flex items-center gap-3 sm:gap-6">
<span className="hidden sm:block text-nature text-lg font-serif shrink-0" aria-hidden="true"> <span className="hidden sm:block text-nature text-lg font-serif shrink-0" aria-hidden="true">
FlowReader FlowReader
</span> </span>
+6 -6
View File
@@ -24,19 +24,19 @@ export function FocusCard({ article, isTop = false }: FocusCardProps) {
onError={(e) => (e.currentTarget.style.display = 'none')} onError={(e) => (e.currentTarget.style.display = 'none')}
/> />
) : ( ) : (
<div className="w-full h-full bg-gradient-to-br from-nature to-nature-dark p-8 flex items-end"> <div className="w-full h-full bg-nature/12 p-8 flex items-end">
<span className="text-2xl font-serif italic text-on-nature/95 leading-tight line-clamp-3">{article.feed_title}</span> <span className="text-2xl font-serif italic text-nature leading-tight line-clamp-3">{article.feed_title}</span>
</div> </div>
)} )}
<span className="absolute top-4 right-4 bg-black/55 text-white text-[11px] font-bold uppercase tracking-widest px-3 py-1.5 rounded-full"> <span className="absolute top-4 right-4 bg-black/55 text-white text-xs font-semibold px-3 py-1.5 rounded-full">
{readingTimeLabel(article.reading_time)} {readingTimeLabel(article.reading_time)}
</span> </span>
</div> </div>
{/* Body */} {/* Body */}
<div className="flex-1 p-5 sm:p-6 flex flex-col min-h-0"> <div className="flex-1 p-5 sm:p-6 flex flex-col min-h-0">
<div className="flex items-center justify-between gap-3 mb-3 text-[11px] uppercase tracking-widest font-bold text-paper-muted"> <div className="flex items-center justify-between gap-3 mb-3 text-xs text-paper-muted">
<span className="flex items-center gap-2 text-nature truncate"> <span className="flex items-center gap-2 text-nature font-semibold truncate">
<span className="w-1.5 h-1.5 rounded-full bg-nature shrink-0" aria-hidden="true" /> <span className="w-1.5 h-1.5 rounded-full bg-nature shrink-0" aria-hidden="true" />
<span className="truncate">{article.feed_title || 'Journal'}</span> <span className="truncate">{article.feed_title || 'Journal'}</span>
</span> </span>
@@ -54,7 +54,7 @@ export function FocusCard({ article, isTop = false }: FocusCardProps) {
)} )}
{isTop && ( {isTop && (
<p className="mt-auto pt-3 text-center text-[11px] uppercase tracking-widest font-bold text-paper-muted hidden sm:block"> <p className="mt-auto pt-3 text-center text-xs text-paper-muted hidden sm:block">
← Garder · Lu → · Entrée pour lire ← Garder · Lu → · Entrée pour lire
</p> </p>
)} )}
+1 -1
View File
@@ -144,7 +144,7 @@ export function FocusCardStack({ articles, onEmpty, onExit }: FocusCardStackProp
<div className="relative w-full h-full flex flex-col items-center justify-center gap-6"> <div className="relative w-full h-full flex flex-col items-center justify-center gap-6">
<p className="sr-only" aria-live="polite">{announce}</p> <p className="sr-only" aria-live="polite">{announce}</p>
<span className="inline-block bg-nature-dark/80 px-4 py-2 rounded-full text-white text-[11px] uppercase font-bold tracking-widest"> <span className="inline-block bg-nature-dark/80 px-4 py-2 rounded-full text-white text-xs font-semibold">
{articles.length - currentIndex} restant{articles.length - currentIndex > 1 ? 's' : ''} {articles.length - currentIndex} restant{articles.length - currentIndex > 1 ? 's' : ''}
</span> </span>
+1 -1
View File
@@ -255,7 +255,7 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
<div className="flex-1 min-w-0 text-sm"> <div className="flex-1 min-w-0 text-sm">
<p className="font-semibold text-nature truncate">{listArticle.feed_title || 'Article'}</p> <p className="font-semibold text-nature truncate">{listArticle.feed_title || 'Article'}</p>
<p className="text-paper-muted truncate"> <p className="text-paper-muted truncate">
<time dateTime={date}>{formatLong(date)}</time>, {readingTimeLabel(listArticle.reading_time)} de lecture <time dateTime={date}>{formatLong(date)}</time> · {readingTimeLabel(listArticle.reading_time)}<span className="hidden sm:inline"> de lecture</span>
</p> </p>
</div> </div>
<button <button
+7
View File
@@ -113,6 +113,13 @@
text-wrap: pretty; text-wrap: pretty;
} }
/* Article grid: as many ~20rem columns as the width allows. */
@utility article-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
}
/* Skip rendering work for off-screen cards in long lists. */ /* Skip rendering work for off-screen cards in long lists. */
@utility cv-auto { @utility cv-auto {
content-visibility: auto; content-visibility: auto;
+3 -3
View File
@@ -224,7 +224,7 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
return ( return (
<main ref={mainRef} className={`flex-1 overflow-y-auto bg-carbon relative ${hidden ? 'hidden' : ''}`} aria-busy={isFetching}> <main ref={mainRef} className={`flex-1 overflow-y-auto bg-carbon relative ${hidden ? 'hidden' : ''}`} aria-busy={isFetching}>
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 md:px-12 py-6 md:py-8"> <div className="max-w-[2400px] mx-auto px-4 sm:px-6 md:px-12 py-6 md:py-8">
{/* The page title is the feed selector in the top bar. */} {/* The page title is the feed selector in the top bar. */}
<div className="mb-6 flex flex-wrap items-center justify-between gap-x-6 gap-y-3"> <div className="mb-6 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
<p className="text-paper-muted text-sm" aria-live="polite"> <p className="text-paper-muted text-sm" aria-live="polite">
@@ -284,7 +284,7 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
{/* Grid */} {/* Grid */}
{isPending ? ( {isPending ? (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3" aria-label="Chargement des articles" role="status"> <div className="article-grid" aria-label="Chargement des articles" role="status">
{Array.from({ length: 6 }, (_, i) => ( {Array.from({ length: 6 }, (_, i) => (
<div key={i} className="rounded-2xl bg-carbon-light border border-paper-muted/10 overflow-hidden"> <div key={i} className="rounded-2xl bg-carbon-light border border-paper-muted/10 overflow-hidden">
<div className="aspect-[16/9] bg-carbon-dark animate-pulse" /> <div className="aspect-[16/9] bg-carbon-dark animate-pulse" />
@@ -306,7 +306,7 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
) : articles.length > 0 ? ( ) : articles.length > 0 ? (
<> <>
<div <div
className={`grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 transition-opacity ${isPlaceholderData ? 'opacity-60' : ''}`} className={`article-grid transition-opacity ${isPlaceholderData ? 'opacity-60' : ''}`}
> >
{articles.map((article, i) => ( {articles.map((article, i) => (
<ArticleCard <ArticleCard
+3 -9
View File
@@ -67,15 +67,9 @@ export default defineConfig({
cacheableResponse: { statuses: [200] }, cacheableResponse: { statuses: [200] },
}, },
}, },
{ // No image route: feed images are cross-origin, and a fetch() from
urlPattern: ({ request }) => request.destination === 'image', // the worker is bound by the CSP's connect-src 'self', so it would
handler: 'StaleWhileRevalidate', // fail every cover. The browser's HTTP cache handles them instead.
options: {
cacheName: 'images',
expiration: { maxEntries: 400, maxAgeSeconds: 60 * 60 * 24 * 30, purgeOnQuotaError: true },
cacheableResponse: { statuses: [0, 200] },
},
},
{ {
urlPattern: ({ request }) => request.destination === 'font', urlPattern: ({ request }) => request.destination === 'font',
handler: 'CacheFirst', handler: 'CacheFirst',