From 2da867c485f9509583b2abad1d3d2ad6ee61fbb1 Mon Sep 17 00:00:00 2001 From: sofiesty Date: Wed, 7 Oct 2026 13:08:48 +0200 Subject: [PATCH 1/2] Fjern Vite-mal og legg til enkel styling for vinkatalogen (#18) --- client/src/App.tsx | 136 ++++------------------------- client/src/components/WineList.css | 42 +++++++++ client/src/components/WineList.tsx | 71 +++++++++++++++ client/src/data/exampleWines.ts | 45 ++++++++++ client/src/index.css | 120 ++++++------------------- client/src/types/wine.ts | 15 ++++ 6 files changed, 216 insertions(+), 213 deletions(-) create mode 100644 client/src/components/WineList.css create mode 100644 client/src/components/WineList.tsx create mode 100644 client/src/data/exampleWines.ts create mode 100644 client/src/types/wine.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 9ca8e8d..06c1e72 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,122 +1,20 @@ -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 WineList from './components/WineList'; +import { exampleWines } from './data/exampleWines'; +/** + * Root component for Wine not? + * + *
marks the page's main content, so screen reader and keyboard users + * can jump straight to it. The page has one

, and WineList uses

, + * so the heading hierarchy is correct. + * + * Example data is used until Apollo Client (issue 8) is in place. + */ +export default function App() { return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- - ) +
+

Wine not?

+ +
+ ); } - -export default App diff --git a/client/src/components/WineList.css b/client/src/components/WineList.css new file mode 100644 index 0000000..7d35e49 --- /dev/null +++ b/client/src/components/WineList.css @@ -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); +} diff --git a/client/src/components/WineList.tsx b/client/src/components/WineList.tsx new file mode 100644 index 0000000..c558c43 --- /dev/null +++ b/client/src/components/WineList.tsx @@ -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: + * -
labelled by the heading via aria-labelledby + * -
    /
  • so the list is announced with its number of items + * -
    per wine, labelled by the wine's name + * -
    /
    /
    so each property is read with its label + */ +export default function WineList({ wines, totalCount }: Props) { + if (wines.length === 0) { + return

    Ingen viner funnet.

    ; + } + + return ( +
    +

    Vinkatalog

    +

    + Viser {wines.length} av {totalCount} viner +

    + + {/* + role="list" is needed because list-style: none in CSS makes + Safari/VoiceOver stop announcing the element as a list. + */} +
      + {wines.map((wine) => ( +
    • +
      +

      {wine.name}

      +
      +
      Produsent
      +
      {wine.winery}
      + +
      Type
      +
      {wine.type}
      + +
      Region
      +
      + {wine.region}, {wine.country} +
      + +
      Rating
      +
      + {/* avgRating can be null, so check before .toFixed */} + {wine.avgRating != null + ? wine.avgRating.toFixed(1) + : 'Ingen vurderinger'} +
      +
      +
      +
    • + ))} +
    +
    + ); +} diff --git a/client/src/data/exampleWines.ts b/client/src/data/exampleWines.ts new file mode 100644 index 0000000..c454650 --- /dev/null +++ b/client/src/data/exampleWines.ts @@ -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, + }, +]; diff --git a/client/src/index.css b/client/src/index.css index 5fb3313..148f63a 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1,64 +1,23 @@ +/* + * Global base styling for Wine not? + * Replaces the Vite template. Colours are chosen for sufficient contrast + * (WCAG AA, at least 4.5:1 for normal text). + */ :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + --color-bg: #faf7f5; + --color-surface: #ffffff; + --color-text: #2b1d1f; + --color-muted: #5f4b4e; + --color-accent: #7b1e2b; /* wine red */ + --color-border: #e4dcd9; - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } -} - -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + line-height: 1.5; + color: var(--color-text); + background: var(--color-bg); } -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; +* { box-sizing: border-box; } @@ -66,46 +25,19 @@ body { margin: 0; } -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); +main { + max-width: 72rem; + margin: 0 auto; + padding: 2rem 1rem; } h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); + color: var(--color-accent); + margin-top: 0; } -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); +/* Clear focus outline for keyboard navigation */ +:focus-visible { + outline: 3px solid var(--color-accent); + outline-offset: 2px; } diff --git a/client/src/types/wine.ts b/client/src/types/wine.ts new file mode 100644 index 0000000..d6804b0 --- /dev/null +++ b/client/src/types/wine.ts @@ -0,0 +1,15 @@ +/** + * A wine as shown in the catalog list. + * The fields match the Wine type in the GraphQL schema (backend/src), + * but only the ones the list needs. Details are fetched on the detail page. + */ +export type WineListItem = { + id: string; + name: string; + type: string; + winery: string; + region: string; + country: string; + /** null when the wine has no ratings */ + avgRating: number | null; +}; From cb28b383c4e0cb047ddbd908431dc5b0d928ccbd Mon Sep 17 00:00:00 2001 From: sofiesty Date: Wed, 7 Oct 2026 14:00:11 +0200 Subject: [PATCH 2/2] Hent viner fra backend i vinkatalogen (#18) --- client/src/App.tsx | 7 +--- client/src/components/WineCatalog.tsx | 54 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 client/src/components/WineCatalog.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 06c1e72..ec50821 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,5 +1,4 @@ -import WineList from './components/WineList'; -import { exampleWines } from './data/exampleWines'; +import WineCatalog from './components/WineCatalog'; /** * Root component for Wine not? @@ -7,14 +6,12 @@ import { exampleWines } from './data/exampleWines'; *
    marks the page's main content, so screen reader and keyboard users * can jump straight to it. The page has one

    , and WineList uses

    , * so the heading hierarchy is correct. - * - * Example data is used until Apollo Client (issue 8) is in place. */ export default function App() { return (

    Wine not?

    - +
    ); } diff --git a/client/src/components/WineCatalog.tsx b/client/src/components/WineCatalog.tsx new file mode 100644 index 0000000..17a3fa2 --- /dev/null +++ b/client/src/components/WineCatalog.tsx @@ -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(WINES_QUERY, { + variables: { offset: 0, limit: 20 }, + }); + + // role="status" and role="alert" make screen readers announce the messages + if (loading) return

    Laster viner …

    ; + if (error) { + return

    Kunne ikke hente viner. Prøv igjen senere.

    ; + } + + return ( + + ); +}