3. bouwen - wingsvn/proof-of-concept GitHub Wiki

🥥 04.06.2024 opzetten van de server

Vandaag heb ik de server opgezet, dit heb ik als volgt gedaan:

  • de repository clonen en openen in mijn editor.
  • helpers, public en views mappen aanmaken.
  • node installeren met npm install
  • de server opzetten in server.js

het aanroepen van de juiste mappen

// opzetten van de webserver 

// Importeer het npm pakket express uit de node_modules map
import express from 'express'
// Importeer de zelfgemaakte functie fetchJson uit de ./helpers map
import fetchJson from './helpers/fetch-json.js'
// Stel het basis endpoint in
const apiUrl = 'https://fdnd-agency.directus.app/items/'
// Maak een nieuwe express app aan
const app = express()
// Stel ejs in als template engine
app.set('view engine', 'ejs')
// Stel de map met ejs templates in
app.set('views', './views')
// Gebruik de map 'public' voor statische resources, zoals stylesheets, afbeeldingen en client-side JavaScript
app.use(express.static('public'))
// Zorg dat werken met request data makkelijker wordt 
app.use(express.urlencoded({extended: true}))

een poortnummer meegeven

// start de webserver

// Stel het poortnummer in waar express op moet gaan luisteren
app.set('port', process.env.PORT || 8000)
// Start express op, haal daarbij het zojuist ingestelde poortnummer op
app.listen(app.get('port'), function () {
    // Toon een bericht in de console en geef het poortnummer door
  console.log(`Application started on http://localhost:${app.get('port')}`)
})

🥑 06.06.2024 data fetchen uit de API

ophalen van recepten op basis van de id en ophalen van de ingrediënten:

app.get('/recipe/:id', function(request, response) {
    // Haal gegevens van alle endpoints uit de directus API op
    Promise.all([
        fetchJson(apiUrl + 'plus_recipes/' + request.params.id), 
        fetchJson(apiUrl + 'plus_ingredients')
    ]).then(([recipeData, ingredientData]) => {
        // console.log(recipeData)
        // console.log(ingredientData)

        // Render recipe.ejs uit de views map en geef de opgehaalde data mee als variabele
        response.render('recipe', {
            recipe: recipeData.data,
            ingredients: ingredientData.data
        })
    })
})

🍇 10.06.2024 HTML opzet + CSS opzet

html opzet + data renderen

Om een detailpagina te creëren met een specifiek recept heb ik eerst een index/overview pagina gemaakt. Met de forEach loop, loop ik door de recepten uit de API heen. Vervolgens creëer ik een link a dat verwijst naar de detailpagina van dat specifieke recept dmv van een id.

<ul class="recipes">
    <% recipes.forEach (recipe => { %>
    <li>
        <a href="/recipe/<%= recipe.id %>"><%= recipe.title %> </a>
    </li>
    <% }) %>
</ul>

Ik heb op basis van mijn breakdown schets een opzet gemaakt in html. In mijn detailpagina hoef ik nu dus niet meer opnieuw door de recipes endpoint te loopen. Ik kan nu gewoon data ophalen:

<h1> <%= recipe.title %> </h1>

Bij het ophalen van de bereiding <p> <%= recipe.preperation %> </p> van het recept, werd html gegenereerd met html elementen erin. Dit heb ik aangepast naar: <p> <%- recipe.preperation %> </p>

<%= ... %> outputs the value into the template (HTML escaped)

<%- ... %> outputs the unescaped value into the template

Verder haal ik nog data op voor de ingrediënten van het recept.

<% ingredients.forEach(ingredient => { %>
    <ul class="ingredients">
        <li>
            <img src="../images/paprika.webp" height="60" alt="ingredient">
            <h3> <%= ingredient.title %> </h3>
            <p class="ingredient-weight"> <%= ingredient.measure %> <%= ingredient.unit %> </p>
            <p class="ingredient-price"><%= ingredient.price %> </p>
            <div class="ingredient-counter">
                <button class="counter-subtract" type="button" aria-label="voeg 1 ingrediënt toe">-</button>
                <p class="counter-display" aria-label="ingredient quantity">1</p>
                <button class="counter-add" type="button" aria-label="verwijder 1 ingrediënt">+</button>
            </div>
        </li>
    </ul>
    <% }) %>

CSS opzet: @font-face + custom properties

fonts importeren

@font-face {
    font-family: GothamBold;
    src: url(./fonts/GothamBold.otf)
}

custom properties

:root {
    --primary-color: #80bd1d;
    --primary-color-light: #e2f0bf;
    --primary-color-lighter: #f3f9e9;
    --secondary-color:#658d24;
    --secondary-color-dark: #007f38;
    --secondary-color-darker: #126436;

    --flag-color-green: #115013;
    --flag-color-orange: #ffa500;
    --flag-color-red: #dd350d;
    --flag-color-blue: #0b427f;

    --banner-color-generic: #f3f9e9;
    --banner-color-green: #cadfd3;
    --neutral-color: #ffffff;
    --background-color-button: #027F38;

    --text-color: #333333;
    --text-color-light: #666666;
    --text-color-lighter: #999999;
}
body {
    width: 100vw;
    height: 100%;
    font-family: GothamBook, Arial, Helvetica, sans-serif;
    font-size: 16px;
    color: var(--neutral-color-dark)
}

h1, h2, h3 {
    font-family: GothamBold, Arial, Helvetica, sans-serif;
    color: var(--neutral-color-dark);
}

h4, h5, h6, button {
    font-family: GothamMedium, Arial, Helvetica, sans-serif;
}

🥦 11.06.2024 CSS basic styling + list-items grid layout + media query

css styling

image styling Voor de afbeelding van het recept, wilde ik ervoor zorgen dat het de volledige breedte van de viewport in neemt, maar dat het wel in de breedte mee zou schalen, dit heb ik gedaan met object-fit: cover.

.recipe-information  {

    img { 
        width: 100%;
        height: 100%;
        object-fit: cover; /* meeschalen van de image */
        height: 15em;
    }
}

object-fit: cover; zorgt ervoor dat afbeeldingen worden geschaald en worden bijgesneden om de volledige ruimte van hun container te bedekken, terwijl de verhoudingen behouden blijven.

counter button styling

.ingredient-counter {
    display: flex;
    flex-direction: row;
    align-items: center;
    gap: 1em;

    button {
        width: 32px;
        height: 32px;
        text-align: center;
        border: 0.05em solid var(--primary-color);
        background-color: var(--neutral-color);
        color: var(--primary-color);
    }

    button.counter-subtract {
        border-radius: 0.9em 0.9em 0.2em 0.9em;
    }

    button.counter-add {
        border-radius: 0.9em 0.9em 0.9em 0.2em;
    }
}

boodschappenlijstje met css grid

indeling ingredients

<% ingredients.forEach(ingredient => { %>
<ul class="ingredients">
        <li>
            <img src="../images/paprika.webp" height="60" alt="ingredient">
            <h3> <%= ingredient.title %> </h3>
            <p class="ingredient-weight"> <%= ingredient.measure %> <%= ingredient.unit %> </p>
            <p class="ingredient-price"><%= ingredient.price %> </p>
            <div class="ingredient-counter">
                <button class="counter-subtract" type="button" aria-label="voeg 1 ingrediënt toe">-</button>
                <p class="counter-display" aria-label="ingredient quantity">1</p>
                <button class="counter-add" type="button" aria-label="verwijder 1 ingrediënt">+</button>
            </div>
        </li>
    </ul>
 <% }) %> 

Ik wilde de gegevens van de ingrediënten binnen de list-item op de volgende manier indelen: image

Dit heb ik gebouwd met css grid. Met grid-auto-columns bepaal ik de ruimte die de kolommen innemen binnen de list-item. Vervolgens geef ik aan hoe de lay-out eruit moet zien met grid-template-areas. Met grid-area geef ik elk element (content) binnen de list-item een naam mee dat correspondeert met de grid-template-areas.

ul {
    display: grid;

      li {
         display: grid;
         grid-auto-columns: auto 1fr auto;
         grid-template-areas:
         "image title title"
         "image measurement measurement"
         "image price button"; 
         margin: 0.5em;
         padding: 0.5em;
         border-radius: 1em;
         background-color: var(--neutral-color-white);
        
         img {
             grid-area: image;
             align-self: center;
             margin: 0.5em; 
         }

         h3 {
            grid-area: title;
            line-height: 1.5;
         }

         p.ingredient-weight {
             grid-area: measurement;
             color: var(--neutral-color-grey-light);
         }

         p.ingredient-price {
             grid-area: price;
             margin: 1em 0;
         }

         div {
             grid-area: button;
         }  
     }
}       
image

media query

Om de list-items over meerdere kolommen te verdelen bij een bredere schermbreedte heb ik een media query aangemaakt voor een schermbreedte groter dan 32em. Daarnaast heb ik gezorgd voor een andere indeling van de list-items/ingredient-gegevens.

@media (width > 32em) { 
    ul.ingredients {
        grid-template-columns:repeat(auto-fit, minmax(15em, 1fr));

        li {
            grid-template-columns: 1fr auto;
            grid-template-areas:
            "image image"
            "title title"
            "measurement measurement"
            "price button";
        }
    }
}

grid-template-columns hiermee definieer ik de kolom-structuur. repeat hiermee zorg ik voor herhaling van de kolommen. auto-fit hiermee zorg ik voor het automatisch aanpassen van het aantal kolommen op basis van de beschikbare ruimte. minmax dit is de minimale en maximale breedte van de kolommen.

Het verdelen van de list-items over meerdere kolommen werkt nog niet... Er gaat iets verkeerd hier 🧐:

ul.ingredients {
        grid-template-columns:repeat(auto-fit, minmax(15em, 1fr));

🌽 12.06.2024 progressive enhanced tabs

html functional core layer

Ik wilde tabs creëren voor de ingrediënten en de bereidingswijze. Om dit progressive enhanced te maken heb ik gekeken naar wat de core functionaliteit is en welke elementen hier dan bij zouden passen.

tabs core functionaliteit: het weergeven van informatie over een onderwerp wanneer er op geklikt wordt.

Omdat er altijd één tab op display komt te staan, is het een soort toggle-systeem. Ik heb daarom gebruik gemaakt van radiobuttons.

<div class="tabs">
        <input type="radio" id="ingredients" name="tab-recipe" checked="true" aria-label="klik voor de ingredienten" >
        <label for="ingredients">ingrediënten</label>
        <div class="tabs-content">
            <ul>
                <li>1 courgette</li>
                <li>2 rode uien</li>
                <li>1 rode parpika</li>
                <li>3 el olijfolie</li>
                <li>4 boerentrots van PLUS angusburgers</li>
                <li>4 PLUS hamburgerbroodjes</li>
                <li>25 gram rucola slamelange</li>
                <li>1 trostomaat</li>
                <li>4 el groene pesto</li>
                <li>1 grillschaal</li>
            </ul>
        </div> 

        <input type="radio" id="preparation" name="tab-recipe" aria-label="klik voor de bereidingswijze" > 
        <label for="preparation">bereiding</label> 
        <div class="tabs-content">
            <p> <%- recipe.preperation %> </p>
        </div>
    </div>

<input type="radio" id="ingredients" name="tab-recipe" checked="true"> hiermee creëer ik een radiobutton en geef ik deze een specifieke id mee. name="tab-recipe" ik geef beide radiobuttons dezelfde naam mee om ze te groeperen, dit zorgt ervoor dat je maar één tab tegelijkertijd kunt selecteren. checked="true"` ik geef de 1e tab een checked state mee, deze staat hiermee bij default op display.

Vervolgens maak ik de labels van de tabbladen aan en link ik deze aan de specifieke id van de bijbehorende radiobutton `<label for="ingredients>".

Als laatst maak ik content voor de tabs aan.

Dit ziet er dan als volgt uit:

image

basic css styling

.tabs {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    width: 100%;

    input {
        display: none; /* make the radio button invisible */
    }

    label {
        flex: 50%; /* each label takes up 50% of the container width */
        text-align: center;
        padding: 10px 16px;
        color: var(--neutral-color-grey-light);
        border-bottom: 0.2em solid var(--neutral-color-grey-lighter);
        cursor: pointer; 
    }

    .tabs-content {
        order: 1; 
        width: 100%;
        margin: 1em;
    }

De content staat nu onder elkaar. Om de tabs te stylen als tabs (inline) gebruik ik flex. 'flex-wrap:flex` flex-items op de volgende regel plaatsen.

De content wordt nu als volgt weergegeven: tab1 - content tab1 - tab2 - content tab2. Alle flex-items hebben by default namelijk een orde-waarde van 0 en staan dus op dezelfde volgorde als in de html. Door .tabs-content een orde: 1 mee te geven, komen de labels hoger te staan can de content van de tabs.

orde property het veranderen van de visuele weergave van elementen zonder wijzigingen aan te brengen in de html.

Verder style ik de tabs nog volgens huisstijl. Met flex: 50% zorg ik er bijvoorbeeld voor dat de breedte van de labels meeschaal met de breedte van de viewport.

image image

enhanced css

De content van beide tabs zijn nog zichtbaar, om dit te verbergen gebruik ik display: none.

.tabs-content {
    order: 1; 
    width: 100%;
    display: none; 
    margin: 1em;
}

Ik geef vervolgens een styling mee op de label van de radiobutton die op checked staat.

input:checked + label {
    color: var(--background-color-button);
    border-bottom: 0.2em solid var(--background-color-button);
}

Daarna zorg ik voor de display van de .tabs-content van de radiobutton die op checked staat.

input:checked + label + .tabs-content {
    display: initial; 
}

🍊 13.06.2024 boodschappenlijstje: grid + media query

<ul class="ingredients">
    <% ingredients.forEach(ingredient => { %>
    <li>
        <img src="../images/paprika.webp" height="60" alt="ingredient">
        <h3> <%= ingredient.title %> </h3>
        <p class="ingredient-weight"> <%= ingredient.measure %> <%= ingredient.unit %> </p>
        <p class="ingredient-price"><%= ingredient.price %> </p>
        <div class="ingredient-counter">
            <button class="counter-subtract" type="button" aria-label="voeg 1 ingrediënt toe">-</button>
            <p class="counter-display" aria-label="ingredient quantity">1</p>
            <button class="counter-add" type="button" aria-label="verwijder 1 ingrediënt">+</button>
        </div>
    </li>
    <% }) %> 
</ul>
@media (width > 32em) { 
    ul.ingredients {
        grid-template-columns: repeat(auto-fit, minmax(224px, 1fr));

        li {
            grid-auto-columns: 1fr auto;
            grid-template-areas:
            "image image"
            "title title"
            "measurement measurement"
            "price button";
        }
    }
}

Voorheen had ik de forEach loop om de ul elementen, dit zorgde ervoor dat er maar één ul was dat meerdere li elementen bevatten. Doordat er maar een ul aanwezig is, kunnen de ul elementen niet over meerdere kolommen worden geplaatst.

Door de forEach loop om de li te zetten, zorg ik er nu voor dat elke li element een ul element omvat. Er zijn dus meerdere ul elementen die over meerdere kolommen kunnen worden verspreid. De eerder geschreven media query werkt hierdoor weer.

🍊 13.06.2024 fade-in effect

.suggestion {
    margin-bottom: 6em;
    padding: 0.5em;
    border-radius: 1em;
    background-color: var(--banner-color-green);
    opacity: 0;
    transition: all 3s;
}


.suggestion.show {
    opacity: 1;
    filter: blur(0);
}
const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
        console.log(entry)
        // voeg de class show toe wanneer variatie tip in beeld komt
        if (entry.isIntersecting) {
            entry.target.classList.add('show')
        } else {
            entry.target.classList.remove('show');
        }
    })
});

const hiddenElements = document.querySelectorAll('.suggestion');
hiddenElements.forEach((el) => observer.observe(el));

🍊 13.06.2024 boodschappenlijstje slide up: progressive enhanced

html functional core layer

core functionaliteit: een overzicht van producten weergeven.

<ul class="ingredients">
    <% ingredients.forEach(ingredient => { %>
    <li>
        <img src="../images/paprika.webp" height="60" alt="ingredient">
        <h3> <%= ingredient.title %> </h3>
        <p class="ingredient-weight"> <%= ingredient.measure %> <%= ingredient.unit %> </p>
        <p class="ingredient-price"><%= ingredient.price %> </p>
        <div class="ingredient-counter">
            <button class="counter-subtract" type="button" aria-label="voeg 1 ingrediënt toe">-</button>
            <p class="counter-display" aria-label="ingredient quantity">1</p>
            <button class="counter-add" type="button" aria-label="verwijder 1 ingrediënt">+</button>
        </div>
    </li>
    <% }) %> 
</ul>

basic css styling

css grid + huisstijl

.grocery-list {
    position: fixed;
    bottom: -29em; /* show only the button part */
    left: 0;
    width: 100%;
    background-color: var(--primary-color-light);
    border-radius: 1em 1em 0 0;
    padding: 0.5em;
    z-index: 100;

    transition: transform 0.3s ease-in; /* Voeg een soepele overgang toe voor animatie */
    transform: translateY(0); /* Startpositie voor animatie */

    button {
        width: 100%;
        margin: 0.5em 0 0.5em 0;
        background-color: var(--primary-color-light);
    }

    /* h2 {
        margin: 0.5em 1em;
        text-align: center;
    } */

    ul {
        display: grid;
        height: 20em;
        overflow-y: scroll;

        li {
            display: grid;
            grid-auto-columns: auto 1fr auto;
            grid-template-areas:
            "image title title"
            "image measurement measurement"
            "image price button"; 
            margin: 0.5em;
            padding: 0.5em;
            border-radius: 1em;
            background-color: var(--neutral-color-white);
            /* height: 153px; */
        
            img {
                grid-area: image;
                align-self: center;
                margin: 0.5em; 
            }

            h3 {
                grid-area: title;
                line-height: 1.5;
                white-space: nowrap;
                overflow: hidden;
                text-overflow: ellipsis;
            }

            p.ingredient-weight {
                grid-area: measurement;
                color: var(--neutral-color-grey-light);
            }

            p.ingredient-price {
                grid-area: price;
                margin: 1em 0;
            }

            div {
                grid-area: button;
            }  
        }
    }       
}

.grocery-list-show {
    transform: translateY(-29em); /* Slide-up effect om te laten zien */
}

enhancement

het toggelen van de boodschappenlijst

document.addEventListener('DOMContentLoaded', function() {
    const button = document.querySelector('.grocery-list button');
    const groceryList = document.querySelector('.grocery-list');

    button.addEventListener('click', function() {
        groceryList.classList.toggle('grocery-list-show'); // Toggle class to show/hide grocery list
    });
});

extra enhancement: on scroll

document.addEventListener('DOMContentLoaded', function() {
    const startGrocery = document.querySelector('.suggestion');
    const groceryList = document.querySelector('.grocery-list');

    const observer = new IntersectionObserver(entries => {
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                groceryList.classList.add('grocery-list-show');
            } else {
                groceryList.classList.remove('grocery-list-show');
            }
        });
    }, {
        threshold: 1
    });

    observer.observe(startGrocery);
});

De IntersectionObserver is een geavanceerde JavaScript API die wordt gebruikt om te observeren wanneer een bepaald element in het zicht van een ander element of de viewport komt.

🧀 19.06.2024 refactoren + performance + fallbacks

refactoren

Ik had de styling van een aantal elementen op 2 verschillende plekken staan in mijn css file. Ik dacht dat dit in eerste instantie voor onderscheid en overzicht kon zorgen, maar uiteindelijk was dit toch niet zo handig. Dus heb ik alles samengevoegd wat dubbel stond.

cumulative layout shift

Om layout shifts te voorkomen tijdens het laden van de pagina, heb ik alle afbeeldingen een height meegegeven.

<img src="https://fdnd-agency.directus.app/assets/<%= recipe.photo %>" height="200" alt="burger">

responsive images

Ik heb ook de afbeeldingen gecomprimeerd in een avif en web formaat. Dit zorgt voor een kortere laadtijd en dus een betere performance. Dit is gelijk ook progressive enhanced. Mochten de gecomprimeerde afbeeldingen niet ondersteund worden door een browser dan krijg je alsnog de afbeelding in de niet-gecomprimeerde versie te zien.

<picture>
            <source srcset="https://fdnd-agency.directus.app/assets/<%= recipe.photo %>?format=avif" type="image/avif">
            <source srcset="https://fdnd-agency.directus.app/assets/<%= recipe.photo %>?format=webp" type="image/webp">
            <img src="https://fdnd-agency.directus.app/assets/<%= recipe.photo %>" height="200" alt="burger">
        </picture>

lazy loading

Ik heb ook lazy loading aan mijn images meegegeven. Dit zorgt ook voor een snellere laadtijd. De afbeeldingen worden alleen gerendeerd op het moment dat ze in beeld komen en nodig zijn.

<img src="https://fdnd-agency.directus.app/assets/<%= ingredient.photo %>" loading="lazy" height="60" alt="ingredient">

fallbacks

Ik heb een fallback toegevoegd aan de afbeeldingen binnen de boodschappenlijst, mocht er helemaal geen afbeelding gevonden uit de API worden.

<picture>
                <% if (ingredient.photo !=null) { %>
                <source srcset="https://fdnd-agency.directus.app/assets/<%= ingredient.photo %>?format=avif" type="image/avif">
                <source srcset="https://fdnd-agency.directus.app/assets/<%= ingredient.photo %>?format=webp" type="image=webp">
                <img src="https://fdnd-agency.directus.app/assets/<%= ingredient.photo %>" loading="lazy" height="60" alt="ingredient">
                <% } else { %>
                    <img src="../images/plus.png" width="80" alt="placeholder">
                    <% } %>
            </picture>

Ook heb ik voor het font Gotham een fallback toegevoegd.

    font-family: GothamBook Arial, Helvetica, sans-serif;

🧀 19.06.2024 converteren naar Nederlands prijsformaat

Met behulp van een code voorbeeld van Koop heb ik de prijzen van de ingrediënten verandert naar de Nederlandse prijsformaat.

// ik selecteer alle elementen waarvan deze een prijs bevatten
const price = document.querySelectorAll('.ingredient-price');

//ik loop vervolgens door elk element en geef die een functie mee
price.forEach(price => {
    // functie: ophalen van de prijstekst en vervangen van de tekens hiervan
    const amount = price.textContent.replace(/[^\d.-]/g, '').replace(',', '.');

    // het bedrag formatteren naar Nederlands prijsformaat
    price.textContent = new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR" }).format(amount);
});

price.textContent hiermee haal ik de inhoud van het '.ingredient-price` element op.

replace(/[^\d.-]/g, '') hiermee verwijder ik eventuele overige tekens en zorg ik ervoor dat er alleen nog nummers en decimalen overblijven in de tekst.

replace(',', '.') hiermee vervang ik de punten door komma's.

image image
⚠️ **GitHub.com Fallback** ⚠️