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
133 changes: 14 additions & 119 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,122 +1,17 @@
import { useState } from 'react'
import heroImg from './assets/hero.png'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'
import './App.css'

function App() {
const [count, setCount] = useState(0)
import WineCatalog from './components/WineCatalog';

/**
* Root component for Wine not?
*
* <main> marks the page's main content, so screen reader and keyboard users
* can jump straight to it. The page has one <h1>, and WineList uses <h2>,
* so the heading hierarchy is correct.
*/
export default function App() {
return (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
type="button"
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>

<div className="ticks"></div>

<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>

<div className="ticks"></div>
<section id="spacer"></section>
</>
)
<main>
<h1>Wine not?</h1>
<WineCatalog />
</main>
);
}

export default App
54 changes: 54 additions & 0 deletions client/src/components/WineCatalog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { gql } from '@apollo/client';
import { useQuery } from '@apollo/client/react';
import WineList from './WineList';
import type { WineListItem } from '../types/wine';

/**
* Fetches wines from the GraphQL backend and passes them to WineList.
*
* Data fetching is kept here and display in WineList, so WineList can be
* reused and tested without Apollo.
*
* We fetch only the fields the list shows, and always with a limit, so we
* never load the whole dataset (scalability and sustainability).
* Pagination comes in issue 10.
*/
export const WINES_QUERY = gql`
query Wines($offset: Int, $limit: Int) {
wines(offset: $offset, limit: $limit) {
totalCount
items {
id
name
type
winery
region
country
avgRating
}
}
}
`;

type WinesData = {
wines: { totalCount: number; items: WineListItem[] };
};

export default function WineCatalog() {
const { data, loading, error } = useQuery<WinesData>(WINES_QUERY, {
variables: { offset: 0, limit: 20 },
});

// role="status" and role="alert" make screen readers announce the messages
if (loading) return <p role="status">Laster viner …</p>;
if (error) {
return <p role="alert">Kunne ikke hente viner. Prøv igjen senere.</p>;
}

return (
<WineList
wines={data?.wines.items ?? []}
totalCount={data?.wines.totalCount ?? 0}
/>
);
}
42 changes: 42 additions & 0 deletions client/src/components/WineList.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/* Grid of wine cards: adapts the number of columns to the screen width. */
.wine-list {
list-style: none;
padding: 0;
margin: 1.5rem 0 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: 1rem;
}

.wine-card {
height: 100%;
padding: 1.25rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
}

.wine-card h3 {
margin: 0 0 0.75rem;
font-size: 1.125rem;
}

/* Label and value side by side: label on the left, value on the right */
.wine-card dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 1rem;
margin: 0;
}

.wine-card dt {
color: var(--color-muted);
}

.wine-card dd {
margin: 0;
}

.wine-count {
color: var(--color-muted);
}
71 changes: 71 additions & 0 deletions client/src/components/WineList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { WineListItem } from '../types/wine';
import './WineList.css';

type Props = {
wines: WineListItem[];
/** Total number of wines in the database, not just the ones shown */
totalCount: number;
};

/**
* Shows a list of wines as cards.
*
* Display only: the component receives the wines as props and knows nothing
* about where they come from (GraphQL, example data, tests). That makes it
* easy to test and to reuse in e.g. Find wine by food and Favourites.
*
* Accessibility: we use semantic elements instead of divs, so screen readers
* understand the structure without extra ARIA:
* - <section> labelled by the heading via aria-labelledby
* - <ul>/<li> so the list is announced with its number of items
* - <article> per wine, labelled by the wine's name
* - <dl>/<dt>/<dd> so each property is read with its label
*/
export default function WineList({ wines, totalCount }: Props) {
if (wines.length === 0) {
return <p role="status">Ingen viner funnet.</p>;
}

return (
<section aria-labelledby="catalog-heading">
<h2 id="catalog-heading">Vinkatalog</h2>
<p className="wine-count">
Viser {wines.length} av {totalCount} viner
</p>

{/*
role="list" is needed because list-style: none in CSS makes
Safari/VoiceOver stop announcing the element as a list.
*/}
<ul className="wine-list" role="list">
{wines.map((wine) => (
<li key={wine.id}>
<article className="wine-card" aria-labelledby={`wine-${wine.id}`}>
<h3 id={`wine-${wine.id}`}>{wine.name}</h3>
<dl>
<dt>Produsent</dt>
<dd>{wine.winery}</dd>

<dt>Type</dt>
<dd>{wine.type}</dd>

<dt>Region</dt>
<dd>
{wine.region}, {wine.country}
</dd>

<dt>Rating</dt>
<dd>
{/* avgRating can be null, so check before .toFixed */}
{wine.avgRating != null
? wine.avgRating.toFixed(1)
: 'Ingen vurderinger'}
</dd>
</dl>
</article>
</li>
))}
</ul>
</section>
);
}
45 changes: 45 additions & 0 deletions client/src/data/exampleWines.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { WineListItem } from '../types/wine';

/**
* Temporary example data with the same fields as the backend.
* Used until Apollo Client (issue 8) is in place, and in tests.
* One wine has avgRating null to show that case.
*/
export const exampleWines: WineListItem[] = [
{
id: '1',
name: 'Barolo Riserva',
type: 'Red',
winery: 'Giacomo Conterno',
region: 'Piemonte',
country: 'Italy',
avgRating: 4.5,
},
{
id: '2',
name: 'Chablis Premier Cru',
type: 'White',
winery: 'William Fèvre',
region: 'Bourgogne',
country: 'France',
avgRating: 4.1,
},
{
id: '3',
name: 'Rioja Gran Reserva',
type: 'Red',
winery: 'La Rioja Alta',
region: 'Rioja',
country: 'Spain',
avgRating: 3.9,
},
{
id: '4',
name: 'Riesling Kabinett',
type: 'White',
winery: 'Dr. Loosen',
region: 'Mosel',
country: 'Germany',
avgRating: null,
},
];
Loading