3. bouwen - wingsvn/proof-of-concept GitHub Wiki
Vandaag heb ik de server opgezet, dit heb ik als volgt gedaan:
- de repository clonen en openen in mijn editor.
-
helpers
,public
enviews
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')}`)
})
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
})
})
})
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>
<% }) %>
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;
}
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;
}
}
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:
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;
}
}
}
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));
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:
.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.
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;
}
<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.
.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));
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>
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 */
}
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
});
});
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.
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.
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">
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>
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">
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;
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.