# Build a projects site

A tutorial: from a page in plain HTML to editable text, structured data, detail pages and the API.

This tutorial builds the projects site of Fieldwork, an example design studio, top-down. It starts with a page in plain HTML and adds editable text, structured project data, detail pages and the API, one step at a time. The result is the example shown on the home page and in our demo application. The complete source is in [projects.zip](/examples/projects.zip).

You need a project with a running local site. See [Installation](/en/docs/learn/installation/).

## 1. Write the page

A route is a folder in `ext/uri/`. The home page is the folder itself. Replace the starter page's `ext/uri/index.tpl`:

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

`index.tpl` sets a few page variables and hands over to `[#html#]`, the standard page shell. The shell renders the document head, the stylesheets and scripts, and the page body from `main.tpl` in the same folder. Write `ext/uri/main.tpl` as plain HTML:

```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>
```

The classes are Tailwind CSS, which the project builds by default. Any HTML and CSS work here: the page is yours. Run `./nimbly watch` while you edit, so new classes end up in the stylesheet.

## 2. Add a header and footer

The shell also renders `header.tpl` and `footer.tpl` from the route's folder. The header will be shared by several pages, so it becomes a reusable template. A folder in `ext/tpl/` with an `index.tpl` is callable by its name. Create `ext/tpl/site-header/index.tpl`:

```html
<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>
```

On phones the logo and the menu stack; from the `sm` breakpoint up they share one row. Then `ext/tpl/site-footer/index.tpl`:

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

Then call them from the route. `ext/uri/header.tpl` holds one line, `[#site-header#]`, and `ext/uri/footer.tpl` holds `[#site-footer#]`. `[#base-url#]` keeps links working when the site runs in a subfolder.

## 3. Make the text editable

Editors should change the heading and the introduction without a developer. Mark both as editable in `main.tpl`:

```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"
      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#]` names a content field that belongs to this page, and `[#get-html#]` prints it. `data-nb-edit` lets a signed-in editor change it on the page: open the site while signed in, choose **Edit** in the page actions and type. Nimbly stores the text per page. No data structure is needed.

## 4. Define the projects

The studio's projects repeat and have the same fields, so they become a resource. A resource is a folder in `ext/data/` with a `.meta` file that describes its fields. Create `ext/data/projects/.meta`:

```json
{
  "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"}
}
```

That one file gives you:

- an overview at `/nb-admin/projects` with columns, a search and filters on sector and publication,
- forms to add and edit projects, with an image picker and a text editor,
- validation of the required title,
- the REST API at `/api/v1/projects`,
- permissions per role and a history of every change.

The `title_slug` field is derived from the title and indexed, so a project can be found by its address. Add four or five projects in the admin, with a cover image, and mark them published. Each record is saved as a JSON file in `ext/data/projects/`.

## 5. Show the projects on the page

A card presents one project. It is only used on this page, so it can live in the route's folder: a `.tpl` file next to `index.tpl` is a template for that route, not a new address. Create `ext/uri/card-project.tpl`:

```html
<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>
```

Add the list to `main.tpl`, below the introduction:

```html
<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#]` selects the published projects, newest first, and `[#repeat#]` renders the card once for each, with the record in `item`. `[#get-img-html#]` writes a responsive image: the server makes the sizes the layout asks for, in WebP. The description and the cover are editable on the page as well as in the admin form, because they point at the record's field.

The template contains no logic: it selects, repeats and prints. When a page needs a decision or a calculation, that goes in a [library](/en/docs/concepts/libraries/).

## 6. Give each project a page

A project's address is `/projects/` followed by its slug. A folder name in parentheses is a dynamic segment. Create `ext/uri/projects/(slug)/route.inc`, which decides whether the address belongs to a published project:

```php
<?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();
```

The route checks the input, looks the slug up in the index, and accepts the request only for a published project. Anything else falls through to a 404. Add `index.tpl`:

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

a `header.tpl` with `[#site-header#]`, a `footer.tpl` with `[#site-footer#]`, and `main.tpl`:

```html
<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>
```

Dynamic routes are registered, so the router knows to ask them. Run:

```bash
./nimbly routes:sync
```

The project titles on the home page now lead to their own pages.

## 7. Preview from the admin

Editors work in the admin and want to see the page they are editing. Add a button to each project's action panel. Create `ext/tpl/btn-preview/index.tpl`:

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

and list it in the resource's `.meta`, next to `fields`:

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

## 8. Use the data anywhere

The same records answer over the REST API, for an app, a screen or another system. A caller signs in as a Nimbly user, gets a token and asks for records.

A user may request a token only when API access is on for their account. Switch on **API access** in the user's action panel in the admin, beside the edit form at `/nb-admin/users/<uuid>`; it needs a role that may edit users. Switching it off stops the user's token at once.

Then:

```bash
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"
```

What a caller may do follows its role, exactly as in the admin. For a real integration, give the caller its own user with a role that may only read projects. See [Integrations](/en/docs/guides/integrations/).

## 9. Go further: a map with another library

The example also has a map of the projects at `/map`, built with Leaflet, a library the project does not include by default. Its `.meta` adds a `location-picker` field, so editors place each project on a map in the admin form. The route loads Leaflet and its own script only on that page, and reads the projects with `[#data#]`, passed to the script as JSON. That pattern works for any library or framework. See `uri/map/` in the download and [Frontend stack](/en/docs/guides/frontend/).

## What you built

| Building block | Files | Role |
|---|---|---|
| Routes | `ext/uri/`, `ext/uri/projects/(slug)/` | Addresses and page composition |
| Templates | `site-header`, `site-footer`, `card-project`, `btn-preview` | Presentation, reused across pages |
| Resource | `ext/data/projects/.meta` | Data, admin, API and permissions |
| Content | page fields through `[#cfield#]` | Text editors change in place |

From here:

- [Atomic composition](/en/docs/concepts/atomic-design/) explains how these blocks combine.
- [Resources](/en/docs/concepts/resources/) covers fields, indexes and history.
- [Users and permissions](/en/docs/guides/permissions/) sets up an editor role.
