Documentation menu

Documentation / Foundation / portfolio

Portfolio

Package
@wabbit/tome-portfolio
Version
v0.1.0
npm install @wabbit/tome-portfolio
First install from our registry?
  1. Get a free registry token from your credentials page. Every install from our registry needs one, free packages included.

  2. Add the registry and your token to the .npmrc at the root of your project, with your token in place of YOUR_TOKEN:

    @wabbit:registry=https://npm.wabbit.com/
    //npm.wabbit.com/:_authToken=YOUR_TOKEN
FoundationPreview

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

PeerRangeOptional?
@wabbit/tome-core>=1.25.0 <2.0.0no — the layer registry, the capability check, the field-shape vocabulary and the shared unstable_cache loader.
payload>=3.67.0no — 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.0yes — ./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

EntrySafe in a client bundleWhat it carries
@wabbit/tome-portfoliono (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/typesyes (types only)the stored document, the story items, the index entry, the filter and facet shapes, the settings shapes
@wabbit/tome-portfolio/filtersyesparsePortfolioFilter, serializePortfolioFilter, applyPortfolioFilter, facetPortfolioEntries, sortPortfolioEntries, localityKey, formatJobNumberSortKey, numberPlates, formatPlateNumbers, formatCredit, formatCreditList, formatPlateCredit
@wabbit/tome-portfolio/serverno (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

  1. 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.

  1. Fill in the types first. Add the site's project types under Work → Project types (a title, a lower-case key such as residential, and an order). Types are seed data the site owns; nothing is built in.
  1. 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.
  1. Set the vocabulary under Work → Portfolio settings: the index path (default /work), the number prefix (default No.), and the labels the Title Block cells and meta lines print. Clearing a field restores the built-in English word.
  1. 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 }) : null

Collections and global

KindSlug (default)dbNamePurpose
Collectionportfolio-projectspf_projectsOne project: its number, facts, lead image, story and credits. Drafts on, autosave off.
Collectionportfolio-typespf_typesThe site's project taxonomy. Counts and filters key on it. Kept apart from blog categories so a filter's counts never mix with posts.
Globalportfolio-settingspf_settingsThe 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.

FieldNotes
title, slugslug is URL-safe, unique, and derived from the title when empty.
jobNumber, numberSeriesEntered, 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.
sortKeyRead-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, yearEnd1800 to next year; the end year is not before the start.
place, localityplace is the descriptive line; locality is the short, filterable name that the "places worked" count uses.
typesRelationship to the types collection. The first is the primary type.
statuscompleted, in-progress or concept.
scope, client, lead, summary, teaser, description, resultLine, figuresThe facts and copy. figures holds at most three value and label pairs; lead.href is validated like a link.
leadImage, phoneCrop, leadCreditThe lead image uses the media document's own focal point. leadCredit is { name, source, label }.
storyOne 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, archivedfeatured 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.
plateCountRead-only. The lead image plus every image in the story, made on save.
layoutBlocks, 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 optional number and label, a rich-text body, and showFigures (places the project's figures after the body).
  • A plate has a mode, a size (bleed, column, offset take one image; pair takes two; trio takes three), an optional aspect, its images (each with alt, phoneCrop, a structured credit, and a read-only inlineSvg), a pairKind (detail or before-after), a caption, and a credit override.

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

GroupFields (defaults)
Top levelbasePath (/work), numberPrefix (No.)
labelsprojectNo "Project no.", client, place, year, scope, lead, collaborators, photographer, status, result, plate, of, photoCredit "photo", drawingCredit "drawing", before, after, privateClient "Private client", sampleLabel "Illustrative"
statusLabelscompleted, inProgress, concept
factsLabelssince "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:

OptionDefaultPurpose
slugsportfolio-projects, portfolio-types, portfolio-settingsRename any owned slug. The three must be distinct.
mediaSlugmediaThe upload collection for images.
layoutBlocksnoneBlocks for the project's layout field (the case study's body). Absent: no layout field.
editCapabilityportfolio:edit-projectsThe capability that may edit projects, types and settings.
grantRoles['admin', 'editor']Roles granted the capability at startup. false grants nothing.
adminGroupWorkAdmin sidebar group.
dbNamespf_projects, pf_types, pf_settingsdbName overrides.
settingstrueBuild 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

Slugreadcreate, update, deletereadVersions
portfolio-projectseditors: 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 recordportfolio:edit-projectsportfolio:edit-projects
portfolio-typesopenportfolio:edit-projectsnot applicable
portfolio-settingsopenupdate: portfolio:edit-projectsnot 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.

FunctionReturns
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 layout

The 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-projects and portfolio-types collections and the portfolio-settings global through createPortfolioLayer, 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 ./filters entry 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 carry slug.

    • Fix: the layer's reads no longer re-populate a project's layout blocks, so a block read hook that calls the read API on the same req cannot wait on the read that is waiting for it (a project update or a case-study render used to hang). getPortfolioProject omits layout unless withLayout: 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 except layout, and a read asked for again from inside itself logs and returns null instead of hanging.

Full changelog across all packages