mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
fix(web): show feed images again, widen the grid, soften covers
- Drop the service worker's image cache: its cross-origin fetch() is bound by the CSP's connect-src 'self', so every cover failed and fell back to the green placeholder (in the grid and the reader). - Grid fills the width with as many ~20rem columns as fit (max 2400px), now that the sidebar is gone. - Covers fade in once loaded and get a light bottom shade. - Placeholder cover shows a centered initial instead of the feed name that overlapped the feed chip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NsXUyMEGFFGVRcHNVfcBLh
This commit is contained in:
5 files changed
+22
-16
No files matched your search
@@ -15,9 +15,10 @@ interface ArticleCardProps {
|
||||
|
||||
/** Initial-letter cover used when a feed provides no image (no third-party fallback). */
|
||||
function CoverFallback({ title, feed }: { title: string; feed?: string }) {
|
||||
const initial = (feed || title).trim().charAt(0).toUpperCase();
|
||||
return (
|
||||
<div className="w-full h-full bg-gradient-to-br from-nature/90 to-nature-dark flex items-end p-5">
|
||||
<span className="font-serif italic text-on-nature/90 text-lg leading-snug line-clamp-3">{feed || title}</span>
|
||||
<div className="w-full h-full bg-gradient-to-br from-nature/90 to-nature-dark flex items-center justify-center" aria-hidden="true">
|
||||
<span className="font-serif italic text-on-nature/35 text-7xl leading-none select-none">{initial}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +26,7 @@ function CoverFallback({ title, feed }: { title: string; feed?: string }) {
|
||||
function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onToggleFavorite }: ArticleCardProps) {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const [imgLoaded, setImgLoaded] = useState(false);
|
||||
|
||||
// Touch-only swipe: right = read/unread, left = favorite. Mouse drags (text
|
||||
// selection) never trigger it.
|
||||
@@ -80,12 +82,15 @@ function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onTo
|
||||
referrerPolicy="no-referrer"
|
||||
width={640}
|
||||
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)}
|
||||
/>
|
||||
) : (
|
||||
<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">
|
||||
{article.feed_title || 'Journal'}
|
||||
</span>
|
||||
|
||||
@@ -211,7 +211,7 @@ export function TopBar({ onSelectFeed, selectedFeedId, onEnterFocus }: TopBarPro
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
FlowReader
|
||||
</span>
|
||||
|
||||
@@ -113,6 +113,13 @@
|
||||
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. */
|
||||
@utility cv-auto {
|
||||
content-visibility: auto;
|
||||
|
||||
@@ -224,7 +224,7 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
|
||||
|
||||
return (
|
||||
<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. */}
|
||||
<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">
|
||||
@@ -284,7 +284,7 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
|
||||
|
||||
{/* Grid */}
|
||||
{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) => (
|
||||
<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" />
|
||||
@@ -306,7 +306,7 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
|
||||
) : articles.length > 0 ? (
|
||||
<>
|
||||
<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) => (
|
||||
<ArticleCard
|
||||
|
||||
+3
-9
@@ -67,15 +67,9 @@ export default defineConfig({
|
||||
cacheableResponse: { statuses: [200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: ({ request }) => request.destination === 'image',
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'images',
|
||||
expiration: { maxEntries: 400, maxAgeSeconds: 60 * 60 * 24 * 30, purgeOnQuotaError: true },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
// No image route: feed images are cross-origin, and a fetch() from
|
||||
// the worker is bound by the CSP's connect-src 'self', so it would
|
||||
// fail every cover. The browser's HTTP cache handles them instead.
|
||||
{
|
||||
urlPattern: ({ request }) => request.destination === 'font',
|
||||
handler: 'CacheFirst',
|
||||
|
||||
Reference in new issue
Block a user