feat(frontend): add visual asset fallback strategy and QA notes
This commit is contained in:
parent
012a5362bb
commit
adf8386daf
|
|
@ -52,3 +52,11 @@ src/
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
See `/ARCHITECTURE.md` for full system architecture and patterns.
|
See `/ARCHITECTURE.md` for full system architecture and patterns.
|
||||||
|
|
||||||
|
|
||||||
|
## Visual Assets + Fallbacks
|
||||||
|
|
||||||
|
- Visual asset definitions live in `src/assets/visualAssets.ts`.
|
||||||
|
- The homepage hero uses a fallback chain: bundled `hero.png` first, then `/images/hero-fallback.svg`.
|
||||||
|
- Keep fallback assets lightweight (SVG preferred) and store browser-served fallbacks under `public/images/`.
|
||||||
|
- Any new UI-critical image should follow the same fallback pattern to avoid broken-image regressions in production.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<svg width="1600" height="900" viewBox="0 0 1600 900" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Recipe hero fallback illustration</title>
|
||||||
|
<desc id="desc">Soft gradient with simple food-themed shapes used when the hero image is unavailable.</desc>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#fde68a"/>
|
||||||
|
<stop offset="55%" stop-color="#fed7aa"/>
|
||||||
|
<stop offset="100%" stop-color="#bfdbfe"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||||
|
<g opacity="0.15" fill="#1e293b">
|
||||||
|
<circle cx="250" cy="180" r="120"/>
|
||||||
|
<circle cx="1430" cy="710" r="180"/>
|
||||||
|
<circle cx="1210" cy="180" r="90"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#0f172a" opacity="0.65" font-family="Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" text-anchor="middle">
|
||||||
|
<text x="800" y="420" font-size="84" font-weight="700">Recipe Manager</text>
|
||||||
|
<text x="800" y="500" font-size="34" font-weight="500">Fresh meals. Organized beautifully.</text>
|
||||||
|
</g>
|
||||||
|
<g font-size="84" text-anchor="middle">
|
||||||
|
<text x="620" y="640">🥗</text>
|
||||||
|
<text x="800" y="640">🍲</text>
|
||||||
|
<text x="980" y="640">🍰</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -0,0 +1,13 @@
|
||||||
|
import heroImage from './hero.png';
|
||||||
|
|
||||||
|
export const visualAssets = {
|
||||||
|
hero: {
|
||||||
|
primary: heroImage,
|
||||||
|
fallbacks: ['/images/hero-fallback.svg'],
|
||||||
|
alt: 'Fresh ingredients and plated food',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function heroImageChain(): string[] {
|
||||||
|
return [visualAssets.hero.primary, ...visualAssets.hero.fallbacks];
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import heroImage from '../assets/hero.png';
|
import { heroImageChain, visualAssets } from '../assets/visualAssets';
|
||||||
import { useRecipes } from '../hooks/useRecipes';
|
import { useRecipes } from '../hooks/useRecipes';
|
||||||
import { useTags } from '../hooks/useTags';
|
import { useTags } from '../hooks/useTags';
|
||||||
import { RecipeCard } from '../components/RecipeCard';
|
import { RecipeCard } from '../components/RecipeCard';
|
||||||
|
|
@ -18,6 +18,10 @@ export function RecipeListPage() {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedTagId, setSelectedTagId] = useState<number | null>(null);
|
const [selectedTagId, setSelectedTagId] = useState<number | null>(null);
|
||||||
|
const [heroCandidateIndex, setHeroCandidateIndex] = useState(0);
|
||||||
|
|
||||||
|
const heroCandidates = heroImageChain();
|
||||||
|
const heroSrc = heroCandidates[Math.min(heroCandidateIndex, heroCandidates.length - 1)];
|
||||||
|
|
||||||
const { recipes, loading, error, hasMore, loadMore } = useRecipes({
|
const { recipes, loading, error, hasMore, loadMore } = useRecipes({
|
||||||
search: searchQuery,
|
search: searchQuery,
|
||||||
|
|
@ -81,7 +85,16 @@ export function RecipeListPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative min-h-[220px] bg-slate-100 md:min-h-full">
|
<div className="relative min-h-[220px] bg-slate-100 md:min-h-full">
|
||||||
<img src={heroImage} alt="Fresh ingredients and plated food" className="h-full w-full object-cover" />
|
<img
|
||||||
|
src={heroSrc}
|
||||||
|
alt={visualAssets.hero.alt}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
onError={() => {
|
||||||
|
if (heroCandidateIndex < heroCandidates.length - 1) {
|
||||||
|
setHeroCandidateIndex((current) => current + 1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-900/20 via-transparent to-transparent" />
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-900/20 via-transparent to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue