Leren

Bouw een projectensite

Een tutorial: van een pagina in gewone HTML naar bewerkbare tekst, gestructureerde data, detailpagina's en de API.

Deze tutorial bouwt top-down de projectensite van Fieldwork, een voorbeeldstudio voor ontwerp. Hij begint met een pagina in gewone HTML en voegt stap voor stap bewerkbare tekst, gestructureerde projectdata, detailpagina's en de API toe. Het resultaat is het voorbeeld op de homepage en in onze demo-applicatie. De volledige broncode staat in projects.zip.

Je hebt een project nodig met een draaiende lokale site. Zie Installatie.

1. Schrijf de pagina

Een route is een map in ext/uri/. De homepage is de map zelf. Vervang ext/uri/index.tpl van de startpagina:

[#set page-title="Work"#]
[#set body-classes="bg-stone-50 text-stone-900"#]
[#html#]

index.tpl zet een paar paginavariabelen en geeft dan door aan [#html#], de standaard pagina-shell. De shell maakt de head van het document, de stylesheets en scripts, en de body van de pagina uit main.tpl in dezelfde map. Schrijf ext/uri/main.tpl als gewone HTML:

<section class="mx-auto max-w-7xl px-8 pb-10 pt-16">
  <h1 class="max-w-4xl text-6xl font-semibold leading-none tracking-tight">Places people use every day</h1>
  <div class="mt-8 max-w-2xl text-xl leading-relaxed text-stone-600">
    <p>Fieldwork designs wayfinding, services and digital tools for libraries, ports, schools and markets.</p>
  </div>
</section>

De classes zijn Tailwind CSS, dat het project standaard bouwt. Elke HTML en CSS werkt hier: de pagina is van jou. Draai ./nimbly watch terwijl je bewerkt, zodat nieuwe classes in de stylesheet komen.

De shell maakt ook header.tpl en footer.tpl uit de map van de route. De header wordt door meerdere pagina's gedeeld, dus wordt hij een herbruikbare template. Een map in ext/tpl/ met een index.tpl roep je aan bij zijn naam. Maak ext/tpl/site-header/index.tpl:

<div class="mx-auto flex max-w-7xl flex-col gap-4 px-8 py-6 sm:flex-row sm:items-center sm:justify-between sm:py-8">
  <a href="[#base-url#]/" class="text-xl font-semibold tracking-tight">Fieldwork</a>
  <nav class="flex flex-wrap gap-x-6 gap-y-2 text-stone-600 sm:gap-8">
    <a href="[#base-url#]/#work" class="text-stone-900">Work</a>
    <a href="[#base-url#]/#contact">Contact</a>
  </nav>
</div>

Op telefoons staan logo en menu onder elkaar; vanaf het breakpoint sm delen ze één rij. Daarna ext/tpl/site-footer/index.tpl:

<div id="contact" class="mx-auto max-w-7xl px-8 py-12 text-stone-500">Fieldwork is an example studio.</div>

Roep ze vervolgens aan vanuit de route. ext/uri/header.tpl bevat één regel, [#site-header#], en ext/uri/footer.tpl bevat [#site-footer#]. [#base-url#] houdt links werkend als de site in een submap draait.

3. Maak de tekst bewerkbaar

Redacteuren moeten de kop en de introductie kunnen aanpassen zonder developer. Maak beide bewerkbaar in main.tpl:

<section class="mx-auto max-w-7xl px-8 pb-10 pt-16">
  <h1 class="max-w-4xl text-6xl font-semibold leading-none tracking-tight"
      data-nb-edit="[#cfield title#]" data-nb-edit-options='{"plain":true}'>
    [#get-html [#cfield title#] plain#]
  </h1>
  <div class="mt-8 max-w-2xl text-xl leading-relaxed text-stone-600" data-nb-edit="[#cfield intro#]">
    [#get-html [#cfield intro#]#]
  </div>
</section>

[#cfield title#] noemt een contentveld dat bij deze pagina hoort, en [#get-html#] toont het. Met data-nb-edit kan een ingelogde redacteur het op de pagina aanpassen: open de site terwijl je bent ingelogd, kies Edit in de pagina-acties en typ. Nimbly bewaart de tekst per pagina. Er is geen datastructuur nodig.

4. Definieer de projecten

De projecten van de studio herhalen zich en hebben dezelfde velden, dus worden ze een resource. Een resource is een map in ext/data/ met een .meta-bestand dat de velden beschrijft. Maak ext/data/projects/.meta:

{
  "name": {"singular": "Project", "plural": "Projects"},
  "title_field": "title",
  "fields": {
    "title": {"name": "Title", "type": "text", "required": true},
    "title_slug": {"name": "URL slug", "type": "slug", "source": "title", "admin_col": false},
    "sector": {
      "name": "Sector", "type": "select", "admin_filter": true,
      "options": {
        "culture": "Culture", "public": "Public", "retail": "Retail",
        "education": "Education", "mobility": "Mobility"
      }
    },
    "year": {"name": "Year", "type": "text"},
    "published": {"name": "Published", "type": "boolean", "admin_filter": true},
    "cover": {"name": "Cover", "type": "image"},
    "description": {
      "name": "Description", "type": "html",
      "buttons": "bold,italic,anchor", "admin_col": false
    }
  },
  "index": ["title_slug"],
  "sort": {"field": "year", "flags": "numeric", "order": "desc"}
}

Dat ene bestand geeft je:

  • een overzicht op /nb-admin/projects met kolommen, zoeken en filters op sector en publicatie,
  • formulieren om projecten toe te voegen en te bewerken, met een afbeeldingskiezer en een teksteditor,
  • validatie van de verplichte titel,
  • de REST API op /api/v1/projects,
  • rechten per rol en een geschiedenis van elke wijziging.

Het veld title_slug wordt afgeleid van de titel en geïndexeerd, zodat een project via zijn adres te vinden is. Voeg in de admin vier of vijf projecten toe, met een omslagafbeelding, en zet ze op gepubliceerd. Elk record wordt opgeslagen als JSON-bestand in ext/data/projects/.

5. Toon de projecten op de pagina

Een kaart toont één project. Die wordt alleen op deze pagina gebruikt, dus mag hij in de map van de route staan: een .tpl-bestand naast index.tpl is een template voor die route, geen nieuw adres. Maak ext/uri/card-project.tpl:

<li id="project-[#item.uuid#]">
  <div class="overflow-hidden rounded-2xl bg-stone-200" data-nb-edit-img="projects.[#item.uuid#].cover">
    [#get-img-html [#item.cover#] sizes="sm-100,md-50,lg-33"#]
  </div>
  <p class="mt-5 text-sm uppercase tracking-widest text-stone-500">[#item.sector#] · [#item.year#]</p>
  <h3 class="mt-2 text-2xl font-semibold"><a href="[#base-url#]/projects/[#item.title_slug#]">[#item.title#]</a></h3>
  <div class="mt-3 leading-relaxed text-stone-600" data-nb-edit="projects.[#item.uuid#].description">
    [#get-html item.description#]
  </div>
</li>

Voeg de lijst toe aan main.tpl, onder de introductie:

<section id="work" class="mx-auto max-w-7xl px-8 py-12">
  [#data projects filter=published:1 sort=year|numeric|desc#]
  <ul class="grid gap-x-8 gap-y-14 md:grid-cols-2 lg:grid-cols-3">
    [#repeat data.projects tpl=card-project#]
  </ul>
</section>

[#data#] selecteert de gepubliceerde projecten, nieuwste eerst, en [#repeat#] toont de kaart voor elk ervan, met het record in item. [#get-img-html#] schrijft een responsieve afbeelding: de server maakt de formaten die de layout vraagt, in WebP. De beschrijving en de omslag zijn zowel op de pagina als in het admin-formulier bewerkbaar, omdat ze naar het veld van het record verwijzen.

De template bevat geen logica: hij selecteert, herhaalt en toont. Heeft een pagina een beslissing of berekening nodig, dan gaat die in een library.

6. Geef elk project een pagina

Het adres van een project is /projects/ gevolgd door zijn slug. Een mapnaam tussen haakjes is een dynamisch segment. Maak ext/uri/projects/(slug)/route.inc, dat bepaalt of het adres bij een gepubliceerd project hoort:

<?php
$parts = router_match(__FILE__);
if ($parts === false) return;

// A slug is lowercase letters, digits and dashes; anything else is not a project
$slug = $parts[0];
if (!preg_match('/^[a-z0-9-]{1,120}$/', $slug)) return;

load_library('data');
load_library('util');
$records = data_read_index('projects', 'title_slug', md5_uuid($slug));
$record = $records ? reset($records) : null;
if (!$record || empty($record['published'])) return;

set_variable_dot('project', $record);
router_accept();

De route controleert de invoer, zoekt de slug op in de index en accepteert het request alleen voor een gepubliceerd project. Al het andere eindigt in een 404. Voeg index.tpl toe:

[#set page-title="[#project.title#]"#]
[#set body-classes="bg-stone-50 text-stone-900"#]
[#html#]

een header.tpl met [#site-header#], een footer.tpl met [#site-footer#], en main.tpl:

<article class="mx-auto max-w-4xl px-8 pb-24 pt-10">
  <a href="[#base-url#]/#work" class="text-sm text-stone-500">← Work</a>
  <p class="mt-10 text-sm uppercase tracking-widest text-stone-500">[#project.sector#] · [#project.year#]</p>
  <h1 class="mt-3 text-5xl font-semibold leading-tight tracking-tight">[#project.title#]</h1>
  <div class="mt-10 overflow-hidden rounded-2xl bg-stone-200">
    [#get-img-html [#project.cover#] sizes="sm-100,md-100,lg-75"#]
  </div>
  <div class="mt-10 max-w-2xl text-xl leading-relaxed text-stone-600">
    [#get-html project.description#]
  </div>
</article>

Dynamische routes worden geregistreerd, zodat de router weet dat hij ze moet vragen. Draai:

./nimbly routes:sync

De projecttitels op de homepage leiden nu naar hun eigen pagina's.

7. Bekijk de pagina vanuit de admin

Redacteuren werken in de admin en willen de pagina zien die ze bewerken. Voeg een knop toe aan het actiepaneel van elk project. Maak ext/tpl/btn-preview/index.tpl:

<a href="[#base-url#]/projects/[#record.title_slug#]" class="[#btn-class-secondary#] flex w-full items-center justify-center text-center">[#text Preview#]</a>

en zet hem in de .meta van de resource, naast fields:

"record_actions": [
  {"template": "btn-preview", "scope": "view,edit"}
]

8. Gebruik de data overal

Dezelfde records zijn beschikbaar via de REST API, voor een app, een scherm of een ander systeem. Een aanroeper logt in als Nimbly-gebruiker, krijgt een token en vraagt records op.

Een gebruiker kan alleen een token aanvragen als API-toegang voor zijn account aan staat. Zet API access aan in het actiepaneel van de gebruiker in de admin, naast het bewerkformulier op /nb-admin/users/<uuid>; daarvoor is een rol nodig die gebruikers mag bewerken. Zet je het uit, dan werkt het token van die gebruiker meteen niet meer.

Daarna:

TOKEN=$(curl -s -X POST http://localhost/api/v1/auth/token \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com", "password": "your password"}' | jq -r .token)

curl "http://localhost/api/v1/projects?filter=published:1&sort=year|desc" \
  -H "Authorization: Bearer $TOKEN"

Wat een aanroeper mag, volgt uit zijn rol, precies zoals in de admin. Geef bij een echte integratie de aanroeper een eigen gebruiker met een rol die alleen projecten mag lezen. Zie Integraties.

9. Verder: een kaart met een andere library

Het voorbeeld heeft ook een kaart van de projecten op /map, gebouwd met Leaflet, een library die het project standaard niet bevat. De .meta voegt een veld location-picker toe, zodat redacteuren elk project in het admin-formulier op een kaart plaatsen. De route laadt Leaflet en zijn eigen script alleen op die pagina, en leest de projecten met [#data#], als JSON doorgegeven aan het script. Dat patroon werkt voor elke library of elk framework. Zie uri/map/ in de download en Frontendstack.

Wat je hebt gebouwd

Bouwsteen Bestanden Rol
Routes ext/uri/, ext/uri/projects/(slug)/ Adressen en paginaopbouw
Templates site-header, site-footer, card-project, btn-preview Presentatie, hergebruikt op meerdere pagina's
Resource ext/data/projects/.meta Data, admin, API en rechten
Content paginavelden via [#cfield#] Tekst die redacteuren ter plekke aanpassen

Verder vanaf hier:

Bekijk deze pagina als Markdown