Documentation menu
Documentation / Foundation / portfolio
Portfolio
npm install @wabbit/tome-portfolioFirst install from our registry?
Get a free registry token from your credentials page. Every install from our registry needs one, free packages included.
Add the registry and your token to the
.npmrcat the root of your project, with your token in place ofYOUR_TOKEN:@wabbit:registry=https://npm.wabbit.com/ //npm.wabbit.com/:_authToken=YOUR_TOKEN
Tome portfolio layer — a site's body of work as records: numbered projects typed by the site's own taxonomy, a story of passages and plates with an image mode per item, structured credits, a vocabulary global, a server read API (filtered list with conditional facet counts, featured, next, computed facts) and an isomorphic filters entry for URL filter state.
Overview
A Tome site's body of work, as records. One portfolio-projects document per project, typed by the site's own portfolio-types taxonomy, filed by a permanent job number, with a small portfolio-settings global for the site's vocabulary ("Project no." or "Shoot no."). The package ships the collections and the global, a server read API that a render pack and a site's routes share (a filtered list with conditional facet counts, exactly one "next" project, a computed facts line, one project with its numbered story), and an isomorphic ./filters entry that parses and serializes the index's URL filter state and recomputes the same counts in the browser, so a filtered view can be shared and restored.
It is vertical-neutral. Architects, design-build firms, interior designers, landscape architects, photographers and small agencies all keep a numbered archive of work. Nothing here names a trade: types are a collection the site fills, status values are fixed (completed, in-progress, concept) so counts stay stable, and every label comes from the settings global.
Peer dependencies
| Peer | Range | Optional? |
|---|---|---|
@wabbit/tome-core | >=1.25.0 <2.0.0 | no — the layer registry, the capability check, the field-shape vocabulary and the shared unstable_cache loader. |
payload | >=3.67.0 | no — the collections, the global and their hooks are Payload config. The site needs an upload collection for images (media by default) and a rich-text editor. |
next | >=15.0.0 | yes — ./server caches reads with Next's unstable_cache, and saves revalidate through next/cache. Without Next, reads go straight to Payload and revalidation is skipped. |
server-only is a runtime dependency, imported by ./server alone.
Module surface
| Entry | Safe in a client bundle | What it carries |
|---|---|---|
@wabbit/tome-portfolio | no (Payload config) | createPortfolioLayer, the collection and global factories, access helpers, slugs, markers, cache tags, the capability name, the vocabulary defaults, the rule validators, and the ./filters and ./types exports re-exported |
@wabbit/tome-portfolio/types | yes (types only) | the stored document, the story items, the index entry, the filter and facet shapes, the settings shapes |
@wabbit/tome-portfolio/filters | yes | parsePortfolioFilter, serializePortfolioFilter, applyPortfolioFilter, facetPortfolioEntries, sortPortfolioEntries, localityKey, formatJobNumberSortKey, numberPlates, formatPlateNumbers, formatCredit, formatCreditList, formatPlateCredit |
@wabbit/tome-portfolio/server | no (server-only) | listPortfolioProjects, featuredProject, nextPortfolioProject, getPortfolioFacts, getPortfolioProject, portfolioTypeOptions, getPortfolioSettings, sanitizeDrawingSvg, the cache tags and revalidatePortfolio |
Server/client posture: ./filters and ./types import no payload, server-only, React, Node module, date library or other Tome package, so they run in a server component, a Payload field hook and a client island alike. The server render and the island call the same functions over the same entries, so a reload, a shared link and an in-place chip toggle agree on what is shown and on every count. ./server is for server components and route handlers; code that runs inside Payload (hooks, jobs, the CLI) uses the layer's own modules, because the server-only guard throws outside a React Server Components build.
Setting it up
- Register the layer in
payload.config.ts:
import { createPortfolioLayer } from '@wabbit/tome-portfolio'
const portfolio = createPortfolioLayer({ adminGroup: 'Work' })
export default buildConfig({
// ...
collections: [...otherCollections, ...portfolio.collections],
globals: [...otherGlobals, ...portfolio.globals], // or plugins: [portfolio.plugin]
})Then regenerate types and, on a SQL database, generate and run a migration as for any new collection. The package ships no migration; the consumer owns it.
- Fill in the types first. Add the site's project types under Work → Project types (a title, a lower-case
keysuch asresidential, and anorder). Types are seed data the site owns; nothing is built in.
- Add projects. Each project takes a job number you type, a title, a year, a status and a lead image (required to publish). The address (
slug) is made from the title when you leave it empty.
- Set the vocabulary under Work → Portfolio settings: the index path (default
/work), the number prefix (defaultNo.), and the labels the Title Block cells and meta lines print. Clearing a field restores the built-in English word.
- Read it. In a server component or route handler:
import { getPortfolioFacts, getPortfolioProject, listPortfolioProjects, nextPortfolioProject } from '@wabbit/tome-portfolio/server'
import { parsePortfolioFilter } from '@wabbit/tome-portfolio/filters'
const { filter, view } = parsePortfolioFilter(searchParams)
const list = await listPortfolioProjects(payload, { filter, limit: 6 })
const facts = await getPortfolioFacts(payload)
const project = await getPortfolioProject(payload, { slug })
const next = project ? await nextPortfolioProject(payload, { id: project.id }, { filter }) : nullCollections and global
| Kind | Slug (default) | dbName | Purpose |
|---|---|---|---|
| Collection | portfolio-projects | pf_projects | One project: its number, facts, lead image, story and credits. Drafts on, autosave off. |
| Collection | portfolio-types | pf_types | The site's project taxonomy. Counts and filters key on it. Kept apart from blog categories so a filter's counts never mix with posts. |
| Global | portfolio-settings | pf_settings | The site's vocabulary: labels, status labels, the number prefix, the index path. |
The portfolio- prefix is reserved for this layer. projects belongs to @wabbit/tome-accounts and is never a default here; if you rename a slug onto another layer's, registerLayer refuses to boot with a collision error rather than letting Payload merge two schemas. The short dbNames keep nested array tables (pf_projects_story_images, _pf_projects_v_version_story_images with drafts) and enum names under Postgres's 63-character identifier limit; a schema test pins that.
Project fields
Admin tabs: Record, Images, Story, Credits, Index, and Layout when the site passes layoutBlocks.
| Field | Notes |
|---|---|
title, slug | slug is URL-safe, unique, and derived from the title when empty. |
jobNumber, numberSeries | Entered, never generated. The number is unique within its series across every document, drafts and archived included. numberSeries is empty for most sites; it scopes uniqueness for a firm with two practices or demo data beside a main site. |
sortKey | Read-only. Made from the job number on every save: each run of digits is left-padded to 8 places and the rest lower-cased, so text numbers sort the way a studio reads them. |
year, yearEnd | 1800 to next year; the end year is not before the start. |
place, locality | place is the descriptive line; locality is the short, filterable name that the "places worked" count uses. |
types | Relationship to the types collection. The first is the primary type. |
status | completed, in-progress or concept. |
scope, client, lead, summary, teaser, description, resultLine, figures | The facts and copy. figures holds at most three value and label pairs; lead.href is validated like a link. |
leadImage, phoneCrop, leadCredit | The lead image uses the media document's own focal point. leadCredit is { name, source, label }. |
story | One ordered list of passages and plates, each with an image mode: finished, progress or drawing. See below. |
credits | { kind, role, name, href } with kind one of photographer, collaborator, builder, other. |
featured, newUntil, claim, sample, archived | featured may be set on many projects; the featured pick is deterministic. newUntil is a date: the "new" mark shows while today (UTC) is before it. claim is verified or build-only. sample marks demo figures. archived hides a project and keeps its number. |
plateCount | Read-only. The lead image plus every image in the story, made on save. |
layout | Blocks, present only when layoutBlocks is passed. |
The story
story is one ordered array, rendered exactly as written, so reading order is the stored order. A plate-sequence block renders one mode at a time, so the three image modes (finished work, work in progress, drawings) never mix in one band.
- A passage has a
mode, an optionalnumberandlabel, a rich-textbody, andshowFigures(places the project's figures after the body). - A plate has a
mode, asize(bleed,column,offsettake one image;pairtakes two;triotakes three), an optionalaspect, itsimages(each withalt,phoneCrop, a structuredcredit, and a read-onlyinlineSvg), apairKind(detailorbefore-after), acaption, and acreditoverride.
On publish the structure is enforced: a plate holds exactly the images its size takes, before-after belongs to a pair, a passage needs a label or a body, and at most one passage per mode shows the figures. A draft may be half built.
numberPlates(story, { hasLead }) numbers every image in story order across all modes, the lead image being Plate 1, so "Plate 06 of 14" means the same in the drawings band as in the finished band. A pair takes two numbers and a trio three; formatPlateNumbers prints 04, 05 or a range such as 10–12.
Drawings
A drawing must take the palette's ink, which an <img> cannot. A plate image whose media is image/svg+xml gets a read-only inlineSvg, stamped on save: the file is read once and rebuilt from an allow-list by sanitizeDrawingSvg, so a render never fetches the file and never inlines unsanitized markup. The allow-list keeps shapes, paths, text, patterns, clips, masks, same-document use references, title and desc, presentation attributes and a style limited to fill, stroke, font and opacity properties and custom properties. It drops scripts, on* attributes, foreignObject, any link that is not a fragment, any url() that is not a fragment, doctypes, entity declarations, comments and processing instructions. Over 256 KB, or malformed, it stores nothing and the plate renders the upload as an ordinary image. The root gets role="img" and an aria-label from the image's alt text, and its width and height are removed so the frame sizes it. A value a client submits for inlineSvg never survives a save.
Settings
| Group | Fields (defaults) |
|---|---|
| Top level | basePath (/work), numberPrefix (No.) |
labels | projectNo "Project no.", client, place, year, scope, lead, collaborators, photographer, status, result, plate, of, photoCredit "photo", drawingCredit "drawing", before, after, privateClient "Private client", sampleLabel "Illustrative" |
statusLabels | completed, inProgress, concept |
factsLabels | since "Practicing since", completed "Projects completed", places "Places worked" |
A photographer's site sets projectNo to "Shoot no." and scope to "Format". createPortfolioLayer({ settings: false }) builds no global and every read uses the English defaults.
The factory
createPortfolioLayer(config) returns { collections, globals, plugin, slugs }. Its config speaks tome-core's LayerFactoryConfig vocabulary (adminGroup; access and hooks appended and merged one key at a time; extraFields, fieldOverrides, omitFields and fieldOrder keyed by slug, reaching into the projects' tabs). Layer options:
| Option | Default | Purpose |
|---|---|---|
slugs | portfolio-projects, portfolio-types, portfolio-settings | Rename any owned slug. The three must be distinct. |
mediaSlug | media | The upload collection for images. |
layoutBlocks | none | Blocks for the project's layout field (the case study's body). Absent: no layout field. |
editCapability | portfolio:edit-projects | The capability that may edit projects, types and settings. |
grantRoles | ['admin', 'editor'] | Roles granted the capability at startup. false grants nothing. |
adminGroup | Work | Admin sidebar group. |
dbNames | pf_projects, pf_types, pf_settings | dbName overrides. |
settings | true | Build the settings global. |
hrefFor | ${basePath}/${slug} | (project) => string: the address of a project in every entry.href (list, featured, next and project reads). See below. |
hrefFor
By default a project lives at ${settings.basePath}/${slug}. hrefFor replaces that one rule. It receives { id, slug, jobNumber, numberSeries, sample, basePath, defaultHref } (numberSeries is '' for the site's main work) and returns the path. Use it to keep a reserved demo project's slug out of public URLs, or to serve one layer under several base paths.
createPortfolioLayer({
hrefFor: (p) => (p.numberSeries === 'theme-demo-studio' ? `/themes/studio/work/${p.jobNumber.toLowerCase()}` : p.defaultHref),
})It must be pure and cheap: it runs once per row, and the result is cached with the row. A hook that throws, or returns an empty or non-string value, falls back to defaultHref. The hook rides on the projects collection's custom (server-only, never sent to the admin client). Index entries now also carry slug, so nextPortfolioProject({ slug }) finds a project whose address does not end in it. Your route must resolve the same address back to a record (look the project up by jobNumber and series, or by slug).
It registers the layer with tome-core's registry and grants the capability, each wrapped so a repeat evaluation under hot reload is harmless. A slug collision with another layer is the one failure that is never swallowed.
Access
| Slug | read | create, update, delete | readVersions |
|---|---|---|---|
portfolio-projects | editors: everything, drafts and archived included; everyone else: published and not archived, as a where-constraint, so REST and GraphQL never serve a draft or an archived record | portfolio:edit-projects | portfolio:edit-projects |
portfolio-types | open | portfolio:edit-projects | not applicable |
portfolio-settings | open | update: portfolio:edit-projects | not applicable |
The check is tome-core's sessionHasCapabilityOrLegacyAdmin. sortKey, plateCount and inlineSvg are made by hooks from trusted state on every save, so a client cannot write them.
Archive, don't delete. Delete stays available to capability holders, but archiving keeps the number and deleting frees it. Deleting a type that a project references is refused, with the count ("4 projects use Residential; move them first.").
The read API
Every function takes the Payload instance and optional { req, draft }. Reads go through Payload's local API with access control applied (overrideAccess: false and the caller's req), so a visitor never reaches a draft or an archived record, and the projection excludes archived projects in every public render, editors in draft mode included. A failed read is logged and returns the empty result, never a throw. Images are populated through access control, so the media collection needs public read, as on any public site.
| Function | Returns |
|---|---|
listPortfolioProjects(payload, { filter, order, limit, excludeIds, numberSeries, max }) | { entries, all, total, shown, facets, truncated }. entries are the visible projects matching the filter, in order (sortKey descending, or order: 'year'); limit trims entries only; all is every visible entry for the client island, capped at max (default 500). |
featuredProject(payload) | The featured project with the highest year, then the highest number, or null. |
nextPortfolioProject(payload, current, { filter, wrap }) | Exactly one entry: the next older visible project, within the filter, wrapping to the first entry by default. null when no other visible project qualifies. |
getPortfolioFacts(payload) | { practicingSince, completedCount, placesCount, projectsCount }, each null when the records cannot support it: no projects, no completed project (never "0 projects completed"), no project with a locality. |
getPortfolioProject(payload, { slug } or { id }) | The document with its entry projection, its numbered plates and the settings. Memoized per req, so a route and its blocks read the document once per request. The layout field is not read unless you pass withLayout: true: a route that renders the project's layout reads with withLayout: true; a block hook never does. |
portfolioTypeOptions(payload) | [{ label, value: key }] in the types' order, for an inquiry form's project-type select, so the filters and the inquiry never drift. |
getPortfolioSettings(payload) | The settings global merged over the English defaults, key by key. |
Reading the layout
A project's layout blocks carry afterRead hooks that call this read API on the same req. So the layer's own reads never populate layout unless asked: the index, list, facts and next reads select only the projection fields, and getPortfolioProject returns every field except layout (the numbered story included). A case-study route passes withLayout: true:
const project = await getPortfolioProject(payload, { slug }, { req, withLayout: true })
// project.layout is the populated blocks; a block hook calling getPortfolioProject(payload, { id }, { req }) inside it reads without layoutThe default read and the withLayout read are memoized apart. As a guard, asking for the same read again from inside itself (a hook on the layout calling getPortfolioProject with withLayout: true for its own project) logs an error and returns null instead of waiting on itself forever.
numberSeries scopes a read: omitted reads every series, null reads only projects with no series, a string reads that series. A site that keeps demo projects beside its own reads its main work with numberSeries: null.
Facet counts
Counts follow the standard conjunctive rule: each facet's counts are computed with every other facet's filter applied and its own ignored. With "Residential" selected, the status chips count within Residential, while the type chips still count every type under the current status filter. A value with a zero count is listed only while it is selected, so a shared link never silently drops a filter. Years report the min and max over the set the other facets keep.
Filters in the URL
parsePortfolioFilter reads ?type=residential,civic&status=completed&from=2015&to=2026&place=larch-point&view=plates from a string, a URLSearchParams or a searchParams record; serializePortfolioFilter writes it back with a stable key order and omits empty facets. Unknown parameters are ignored and a malformed value drops that facet only. place values are localityKey values ("Larch Point" becomes larch-point).
Caching
Only clock-independent work is cached, and only for visitor reads (no signed-in user, no draft): the visible rows, the type order and the settings. isNew depends on today and is computed on every call from newUntil. Saving a project revalidates portfolio-project:<id> and collection:portfolio-projects; a type save revalidates collection:portfolio-types and the list tag; a settings save revalidates global:portfolio-settings and the list tag. Each also revalidates the root layout, because blocks embed project data into page documents when they are read. A site that caches its own page queries adds PORTFOLIO_PROJECTS_TAG to them. Seed scripts set req.context.disableRevalidate to skip the calls; outside a Next runtime they are no-ops anyway.
Limits
The index sends every visible entry to the client island, capped at 500. There is no server-side filtering beyond that, no people collection (lead and credits are inline), no type landing pages, no tombstones for deleted job numbers, and no localized slugs. Titles and copy fields are localized when the site uses Payload localization.
Exports
| Import path |
|---|
@wabbit/tome-portfolio |
@wabbit/tome-portfolio/types |
@wabbit/tome-portfolio/filters |
@wabbit/tome-portfolio/server |
Changelog
0.1.xMinor
New layer: a site's body of work as records.
Adds the
portfolio-projectsandportfolio-typescollections and theportfolio-settingsglobal throughcreatePortfolioLayer, with permanent job numbers (entered, unique per number series, kept when a project is archived), a story of passages and plates with an image mode per item, structured credits, and inline SVG drawings sanitized on save by an allow-list. Ships a server read API (filtered list with conditional facet counts, featured, next within a filter, computed facts, one project with its numbered story, type options) and an isomorphic./filtersentry for URL filter state.createPortfolioLayer({ hrefFor })replaces the default${basePath}/${slug}project address (to keep reserved demo slugs out of URLs or serve several base paths); index entries carryslug.- Fix: the layer's reads no longer re-populate a project's
layoutblocks, so a block read hook that calls the read API on the samereqcannot wait on the read that is waiting for it (a project update or a case-study render used to hang).getPortfolioProjectomitslayoutunlesswithLayout: true(a route that renders the layout passes it; a block hook never does); its memo key includes it. The project read selects every field exceptlayout, and a read asked for again from inside itself logs and returnsnullinstead of hanging.
- Fix: the layer's reads no longer re-populate a project's