project-portfolio 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # project-portfolio
2
2
 
3
- A suite of self-contained project portfolio components for Next.js App Router. Drop in a `clientSlug` and `apiBase` — each component fetches, caches, and renders everything on the server with zero client-side waterfall requests.
3
+ A suite of self-contained project portfolio components for Next.js App Router. Drop in a `clientSlug` and `apiBase` — each component fetches, caches, and renders everything it needs with zero client-side waterfall requests.
4
4
 
5
5
  ## Requirements
6
6
 
@@ -17,39 +17,97 @@ npm install project-portfolio
17
17
 
18
18
  ---
19
19
 
20
- ## Components
21
-
22
- ### `ProjectPortfolio`
20
+ ## Quick Start
23
21
 
24
- A full project grid page. Fetches all projects for a client and renders them as responsive cards (1 column on mobile, 2 on tablet, 3 on desktop).
22
+ Here is the most common full setup a projects grid page, a detail page with similar projects, and a megamenu in the nav.
25
23
 
26
24
  ```tsx
27
25
  // app/projects/page.tsx
28
26
  import { ProjectPortfolio } from "project-portfolio"
29
27
 
30
- // Must be a Server Component — do NOT add "use client"
31
- export default async function ProjectsPage() {
28
+ export default async function ProjectsPage({
29
+ searchParams,
30
+ }: {
31
+ searchParams: { [key: string]: string | string[] | undefined }
32
+ }) {
32
33
  return (
33
34
  <ProjectPortfolio
34
35
  clientSlug="your-client-slug"
35
36
  apiBase="https://your-api.com"
36
37
  basePath="/projects"
38
+ searchParams={searchParams}
37
39
  />
38
40
  )
39
41
  }
40
42
  ```
41
43
 
42
- | Prop | Type | Required | Default | Description |
43
- |---|---|---|---|---|
44
- | `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
45
- | `apiBase` | `string` | Yes | — | Base URL of the projects API |
46
- | `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
47
- | `searchParams` | `Record<string, string \| string[] \| undefined>` | No | `{}` | Filter params forwarded to the API — pass Next.js `searchParams` directly |
48
- | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds (24 hours) |
44
+ ```tsx
45
+ // app/projects/[slug]/page.tsx
46
+ import { ProjectDetail, SimilarProjects } from "project-portfolio"
47
+
48
+ export default async function ProjectPage({ params }: { params: { slug: string } }) {
49
+ return (
50
+ <>
51
+ <ProjectDetail
52
+ slug={params.slug}
53
+ clientSlug="your-client-slug"
54
+ apiBase="https://your-api.com"
55
+ backPath="/projects"
56
+ />
57
+
58
+ {/* Hardcode the slugs you want shown — update per client request */}
59
+ <SimilarProjects
60
+ projectSlugs={[
61
+ "jacob-javits-convention-center",
62
+ "tillamook-bay-community-college",
63
+ "lcisd-liberty-hill-high-school",
64
+ ]}
65
+ excludeSlug={params.slug}
66
+ clientSlug="your-client-slug"
67
+ apiBase="https://your-api.com"
68
+ basePath="/projects"
69
+ />
70
+ </>
71
+ )
72
+ }
73
+ ```
49
74
 
50
- #### URL-driven filter integration
75
+ ```ts
76
+ // app/api/chisel-menu/route.ts
77
+ import { createMenuHandler } from "project-portfolio"
51
78
 
52
- `ProjectPortfolio` works together with `ProjectMenu` to form a complete filter flow. When a user clicks a filter link in the megamenu, they are navigated to the project grid with filter query params appended to the URL. Pass Next.js `searchParams` to `ProjectPortfolio` so it can forward them to the API:
79
+ export const GET = createMenuHandler({
80
+ clientSlug: "your-client-slug",
81
+ apiBase: "https://your-api.com",
82
+ })
83
+ ```
84
+
85
+ ```tsx
86
+ // components/Nav.tsx — "use client" component in your header
87
+ "use client"
88
+ import { ProjectMenuClient } from "project-portfolio"
89
+
90
+ export function Nav() {
91
+ return (
92
+ <nav>
93
+ {/* ... other nav items ... */}
94
+ <ProjectMenuClient
95
+ dataUrl="/api/chisel-menu"
96
+ basePath="/projects"
97
+ viewAllPath="/projects"
98
+ />
99
+ </nav>
100
+ )
101
+ }
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Components
107
+
108
+ ### `ProjectPortfolio`
109
+
110
+ A full server-rendered project grid page. Fetches all projects for a client and renders them as responsive baseball cards (1 column on mobile, 2 on tablet, 3 on desktop). Supports URL-driven filtering via `searchParams`.
53
111
 
54
112
  ```tsx
55
113
  // app/projects/page.tsx
@@ -71,12 +129,23 @@ export default async function ProjectsPage({
71
129
  }
72
130
  ```
73
131
 
132
+ | Prop | Type | Required | Default | Description |
133
+ |---|---|---|---|---|
134
+ | `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
135
+ | `apiBase` | `string` | Yes | — | Base URL of the projects API |
136
+ | `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
137
+ | `searchParams` | `Record<string, string \| string[] \| undefined>` | No | `{}` | Filter params forwarded to the API — pass Next.js `searchParams` directly |
138
+ | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds (24 hours) |
139
+
140
+ #### URL-driven filtering
141
+
142
+ When a user clicks a "Browse By" filter link in the menu, they land on the grid with query params in the URL. Pass `searchParams` from the page and `ProjectPortfolio` forwards them to the API automatically.
143
+
74
144
  Filter URLs follow this pattern — the key matches the custom field key in the schema:
75
145
 
76
146
  ```
77
- /projects?type=commercial
78
- /projects?type=educational-facilities
79
- /projects?system=spacematic
147
+ /projects?filter[type]=commercial
148
+ /projects?filter[type]=educational-facilities
80
149
  ```
81
150
 
82
151
  When active filters are applied, a filter banner is shown above the grid with a "Clear filters" link back to `basePath`.
@@ -85,7 +154,7 @@ When active filters are applied, a filter banner is shown above the grid with a
85
154
 
86
155
  ### `ProjectPortfolioClient`
87
156
 
88
- A `"use client"` component that fetches all projects once on mount (module-level cached — no re-fetch on remount) and filters them locally in memory. It ships with **no filter UI** build your own dropdowns, buttons, or search inputs and drive the component with the `filters` prop.
157
+ A `"use client"` version of the project grid. Fetches all projects once on mount (module-level cached — no re-fetch on remount) and filters them locally in memory. Use this when you need the grid inside a client component tree, or when you want to build your own custom filter UI.
89
158
 
90
159
  ```tsx
91
160
  // Works in any component — no RSC required
@@ -102,7 +171,9 @@ export default function ProjectsPage() {
102
171
  }
103
172
  ```
104
173
 
105
- Wire up your own filter UI with the `filters` prop:
174
+ #### With a custom filter UI
175
+
176
+ Wire up your own dropdowns, buttons, or search inputs using the `filters` prop. Filter changes are instant — no API call on each change, all filtering happens in memory.
106
177
 
107
178
  ```tsx
108
179
  "use client"
@@ -121,7 +192,6 @@ export default function ProjectsPage() {
121
192
  <option value="educational-facilities">Educational</option>
122
193
  </select>
123
194
 
124
- {/* Component receives filters and applies them in memory */}
125
195
  <ProjectPortfolioClient
126
196
  clientSlug="your-client-slug"
127
197
  apiBase="https://your-api.com"
@@ -142,19 +212,16 @@ export default function ProjectsPage() {
142
212
  | `columns` | `2 \| 3` | No | `3` | Number of columns in the project grid |
143
213
  | `font` | `string` | No | System font stack | Font family string applied to all text |
144
214
 
145
- **How filtering works:** Pass any `Record<string, string>` of field key/value pairs and the component filters the already-fetched project list in memory. All filter logic uses AND matching with case-insensitive comparison. The field keys must match the custom field keys in the API schema.
146
-
147
215
  ---
148
216
 
149
217
  ### `ProjectDetail`
150
218
 
151
- A full project detail page. Fetches a single project by slug and renders its gallery, custom fields, related projects, and a back link.
219
+ A full server-rendered project detail page. Fetches a single project by slug and renders a hero image, a dynamic stats bar, a "Project Overview" section with description and specs sidebar, a photo gallery, and a back link.
152
220
 
153
221
  ```tsx
154
222
  // app/projects/[slug]/page.tsx
155
223
  import { ProjectDetail } from "project-portfolio"
156
224
 
157
- // Must be a Server Component — do NOT add "use client"
158
225
  export default async function ProjectPage({ params }: { params: { slug: string } }) {
159
226
  return (
160
227
  <ProjectDetail
@@ -168,6 +235,36 @@ export default async function ProjectPage({ params }: { params: { slug: string }
168
235
  }
169
236
  ```
170
237
 
238
+ #### Stats bar
239
+
240
+ The stats bar below the hero is driven by an explicit ordered key list. Fields are shown in this order when present in the schema and populated on the project:
241
+
242
+ | Schema key | Label shown |
243
+ |---|---|
244
+ | `location` | Location |
245
+ | `type` (badge field) | field `name` from schema |
246
+ | `coverage` | Coverage |
247
+ | `year-completed` | Completed |
248
+ | `architect` | Architect |
249
+ | `general-contractor` | General Contractor |
250
+
251
+ If a field doesn't exist in the schema for a given client, or the project has no value for it, that stat is silently omitted. The column count adjusts automatically — 2 columns on mobile, 3 on tablet, up to 6 on desktop.
252
+
253
+ #### Project Overview specs sidebar
254
+
255
+ The "Project Overview" section renders the project description on the left and a specs sidebar on the right (amber accent border). The sidebar shows the following fields when populated, in this order:
256
+
257
+ | Schema key | Label shown |
258
+ |---|---|
259
+ | `systems-used` | Systems Used |
260
+ | `systems` | Track Systems |
261
+ | `series-used` | Series Used |
262
+ | `operation-type` | Operation Type |
263
+ | `finishes` | Finishes |
264
+ | `specifications` | Specifications |
265
+
266
+ Each group renders values as outlined pills. Fields with no value for the current project are omitted. Both the stats bar and the specs sidebar are fully schema-driven — if a key doesn't exist in a client's schema it is simply not shown, making `ProjectDetail` safe to reuse across clients with wildly different field configurations.
267
+
171
268
  | Prop | Type | Required | Default | Description |
172
269
  |---|---|---|---|---|
173
270
  | `slug` | `string` | Yes | — | The project slug to load |
@@ -179,47 +276,48 @@ export default async function ProjectPage({ params }: { params: { slug: string }
179
276
 
180
277
  ---
181
278
 
182
- ### `ProjectMenu`
279
+ ### `GalleryCarousel`
183
280
 
184
- A compact megamenu component. Shows featured projects as list-style cards on the left and "Browse By" filter links on the right. Designed to be dropped directly into a navigation megamenu dropdown.
281
+ A `"use client"` image carousel with previous/next arrows, a counter badge, and a scrollable thumbnail strip. Used internally by `ProjectDetail` but can also be used standalone if you fetch your own project data.
185
282
 
186
283
  ```tsx
187
- // components/navigation/MegaMenu.tsx
188
- import { ProjectMenu } from "project-portfolio"
284
+ "use client"
285
+ import { GalleryCarousel } from "project-portfolio"
189
286
 
190
- // Must be a Server Component do NOT add "use client"
191
- export async function ProjectsMegaMenu() {
287
+ // `media` is the array of image objects returned by the projects API
288
+ export function ProjectGallery({ media, title }: { media: Media[]; title: string }) {
192
289
  return (
193
- <ProjectMenu
194
- clientSlug="your-client-slug"
195
- apiBase="https://your-api.com"
196
- basePath="/projects"
197
- subtitle="Our systems are installed in every geographic region of the U.S. and in a variety of applications."
198
- maxProjects={6}
290
+ <GalleryCarousel
291
+ images={media}
292
+ projectTitle={title}
199
293
  />
200
294
  )
201
295
  }
202
296
  ```
203
297
 
298
+ The parent component is responsible for injecting the `.chisel-gallery-main-img` CSS class to control the main image height:
299
+
300
+ ```css
301
+ .chisel-gallery-main-img {
302
+ height: 400px; /* adjust to your layout */
303
+ }
304
+ @media (min-width: 768px) {
305
+ .chisel-gallery-main-img {
306
+ height: 560px;
307
+ }
308
+ }
309
+ ```
310
+
204
311
  | Prop | Type | Required | Default | Description |
205
312
  |---|---|---|---|---|
206
- | `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
207
- | `apiBase` | `string` | Yes | — | Base URL of the projects API |
208
- | `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
209
- | `viewAllPath` | `string` | No | Same as `basePath` | Path for the "View All Projects" link |
210
- | `subtitle` | `string` | No | — | Description paragraph shown above the project cards |
211
- | `font` | `string` | No | System font stack | Font family string applied to all inline styles |
212
- | `maxProjects` | `number` | No | `6` | Maximum number of projects to display |
213
- | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds (24 hours) |
214
- | `noCache` | `boolean` | No | `false` | Disable caching entirely — useful for development |
215
-
216
- The filter options in the right sidebar are driven automatically by the `is_filterable` fields returned from the API schema — no manual configuration needed.
313
+ | `images` | `Media[]` | Yes | — | Array of media objects from the projects API |
314
+ | `projectTitle` | `string` | Yes | — | Used as the alt text fallback for the main image |
217
315
 
218
316
  ---
219
317
 
220
318
  ### `SimilarProjects`
221
319
 
222
- A standalone similar projects section. Fetches all projects for a client, filters to the same type as the current project, and renders up to 3 matching cards. Designed to be placed after `ProjectDetail` on a project detail page.
320
+ A server-rendered similar projects section. Fetches all projects for a client, filters to those matching the provided field values, excludes the current project, and renders up to 3 matching results. Designed to be placed after `ProjectDetail` on a project detail page.
223
321
 
224
322
  ```tsx
225
323
  // app/projects/[slug]/page.tsx
@@ -245,32 +343,176 @@ export default async function ProjectPage({ params }: { params: { slug: string }
245
343
  }
246
344
  ```
247
345
 
248
- `filters` accepts any combination of custom field key/value pairs — all must match (AND logic). `excludeSlug` optionally removes the current project from results.
346
+ #### Deriving filters from the current project
347
+
348
+ In most real-world cases you want to match similar projects based on the current project's own field values. Fetch the project first, then pass its field values as filters:
349
+
350
+ ```tsx
351
+ // app/projects/[slug]/page.tsx
352
+ import { ProjectDetail, SimilarProjects } from "project-portfolio"
353
+
354
+ async function getProject(slug: string, clientSlug: string, apiBase: string) {
355
+ const res = await fetch(
356
+ `${apiBase}/api/v1/clients/${clientSlug}/projects/${slug}?api_key=YOUR_API_KEY`,
357
+ { next: { revalidate: 86400 } }
358
+ )
359
+ return res.ok ? (await res.json())?.data : null
360
+ }
361
+
362
+ export default async function ProjectPage({ params }: { params: { slug: string } }) {
363
+ const project = await getProject(params.slug, "your-client-slug", "https://your-api.com")
364
+ const projectType = project?.custom_field_values?.type ?? null
365
+
366
+ return (
367
+ <>
368
+ <ProjectDetail slug={params.slug} clientSlug="your-client-slug" apiBase="https://your-api.com" />
369
+ {projectType && (
370
+ <SimilarProjects
371
+ filters={{ type: projectType }}
372
+ excludeSlug={params.slug}
373
+ clientSlug="your-client-slug"
374
+ apiBase="https://your-api.com"
375
+ basePath="/projects"
376
+ />
377
+ )}
378
+ </>
379
+ )
380
+ }
381
+ ```
382
+
383
+ #### Manually specifying projects (recommended for quick setup)
384
+
385
+ Pass `projectSlugs` to hand-pick exactly which projects appear, in the order you specify. This overrides `filters` entirely — useful when you want to curate the section rather than rely on field matching, or when you need a reliable result quickly without worrying about field values matching.
386
+
387
+ This is the recommended approach for most client sites. The slugs are hardcoded in the page file. If a client wants different projects shown, update the slugs and redeploy.
388
+
389
+ ```tsx
390
+ // app/projects/[slug]/page.tsx
391
+ import { ProjectDetail, SimilarProjects } from "project-portfolio"
392
+
393
+ export default async function ProjectPage({ params }: { params: { slug: string } }) {
394
+ return (
395
+ <>
396
+ {/* All your other page content above */}
397
+ <ProjectDetail
398
+ slug={params.slug}
399
+ clientSlug="your-client-slug"
400
+ apiBase="https://your-api.com"
401
+ backPath="/projects"
402
+ />
403
+
404
+ {/* Similar projects hardcoded — update slugs per client request */}
405
+ <SimilarProjects
406
+ projectSlugs={[
407
+ "jacob-javits-convention-center",
408
+ "tillamook-bay-community-college",
409
+ "lcisd-liberty-hill-high-school",
410
+ ]}
411
+ excludeSlug={params.slug}
412
+ clientSlug="your-client-slug"
413
+ apiBase="https://your-api.com"
414
+ basePath="/projects"
415
+ />
416
+ </>
417
+ )
418
+ }
419
+ ```
420
+
421
+ > `excludeSlug` is still respected even in `projectSlugs` mode — if the current page's slug appears in the list it is automatically removed so a project never links to itself.
422
+
423
+ To update which projects appear, find the `projectSlugs` array in the page file and swap in the new slugs. Project slugs are visible in the URL when browsing the portfolio: `/projects/jacob-javits-convention-center` → slug is `jacob-javits-convention-center`.
424
+
425
+ #### Card variant
426
+
427
+ Use `variant="card"` to render the same baseball-card style used in `ProjectPortfolio` instead of the default list style:
428
+
429
+ ```tsx
430
+ <SimilarProjects
431
+ filters={{ type: "commercial" }}
432
+ excludeSlug={params.slug}
433
+ clientSlug="your-client-slug"
434
+ apiBase="https://your-api.com"
435
+ basePath="/projects"
436
+ variant="card"
437
+ />
438
+ ```
249
439
 
250
440
  | Prop | Type | Required | Default | Description |
251
441
  |---|---|---|---|---|
442
+ | `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
443
+ | `apiBase` | `string` | Yes | — | Base URL of the projects API |
252
444
  | `filters` | `Record<string, string>` | No | `{}` | Key/value pairs to filter projects by custom field values. All filters must match (AND logic) |
253
445
  | `excludeSlug` | `string` | No | — | Slug of a project to exclude from results (e.g. the current project) |
446
+ | `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
447
+ | `projectSlugs` | `string[]` | No | — | Explicit list of project slugs to show, in order. When provided, overrides `filters` entirely. e.g. `["jacob-javits", "tillamook-bay"]` |
448
+ | `maxItems` | `number` | No | `3` | Maximum number of projects to show |
449
+ | `variant` | `"list" \| "card"` | No | `"list"` | `"list"` uses the border-bottom separator style; `"card"` renders full baseball-card style matching `ProjectPortfolio` |
450
+ | `font` | `string` | No | System font stack | Font family string applied to all inline styles |
451
+ | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds (24 hours) |
452
+
453
+ ---
454
+
455
+ ### `ProjectMenu`
456
+
457
+ A server-rendered megamenu component. Shows featured projects as compact cards on the left and "Browse By" filter links on the right. Designed to be dropped directly into a navigation dropdown.
458
+
459
+ ```tsx
460
+ // components/MegaMenu.tsx
461
+ import { ProjectMenu } from "project-portfolio"
462
+
463
+ // Must be a Server Component — do NOT add "use client"
464
+ export async function ProjectsMegaMenu() {
465
+ return (
466
+ <ProjectMenu
467
+ clientSlug="your-client-slug"
468
+ apiBase="https://your-api.com"
469
+ basePath="/projects"
470
+ subtitle="Our systems are installed in every geographic region of the U.S."
471
+ maxProjects={6}
472
+ />
473
+ )
474
+ }
475
+ ```
476
+
477
+ #### With a curated menu
478
+
479
+ Pass `menuId` to show a specific curated set of projects instead of all projects. The Browse By filters on the right always reflect the full schema regardless of which menu is active. Available menu IDs can be retrieved from `GET /api/v1/clients/{clientSlug}/menus`.
480
+
481
+ ```tsx
482
+ <ProjectMenu
483
+ clientSlug="your-client-slug"
484
+ apiBase="https://your-api.com"
485
+ menuId="main-nav"
486
+ basePath="/projects"
487
+ />
488
+ ```
489
+
490
+ | Prop | Type | Required | Default | Description |
491
+ |---|---|---|---|---|
254
492
  | `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
255
493
  | `apiBase` | `string` | Yes | — | Base URL of the projects API |
494
+ | `menuId` | `string` | No | — | ID of a curated menu. When provided, fetches from `/menus/{menuId}` instead of all projects. Filters always shown regardless. |
256
495
  | `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
257
- | `maxItems` | `number` | No | `3` | Maximum number of projects to show |
496
+ | `viewAllPath` | `string` | No | Same as `basePath` | Path for the "View All Projects" link |
497
+ | `subtitle` | `string` | No | — | Description paragraph shown above the project cards |
258
498
  | `font` | `string` | No | System font stack | Font family string applied to all inline styles |
259
- | `revalidate` | `number` | No | `60` | Cache revalidation period in seconds |
499
+ | `maxProjects` | `number` | No | `6` | Maximum number of projects to display |
500
+ | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds (24 hours) |
501
+ | `noCache` | `boolean` | No | `false` | Disable caching entirely — useful for development |
260
502
 
261
503
  ---
262
504
 
263
505
  ### `ProjectMenuClient` + `createMenuHandler`
264
506
 
265
- `ProjectMenuClient` is a `"use client"` component that works in any React tree no RSC, no `Suspense` wrapper, no server file needed. It fetches its own data and caches it in memory for the lifetime of the page session so the API is never called twice on a hover/re-hover.
507
+ `ProjectMenuClient` is a `"use client"` megamenu component. Use it when your nav or header is a client component. It fetches and caches data on first mount so the API is never called twice on re-hover or remount.
266
508
 
267
- There are two ways to use it:
509
+ There are two ways to set it up:
268
510
 
269
511
  ---
270
512
 
271
- **Option 1 — `dataUrl` + `createMenuHandler` (recommended for production)**
513
+ #### Option 1 — `dataUrl` + `createMenuHandler` (recommended for production)
272
514
 
273
- Set up one API route once. The data is server-cached for 24 hours — most users never trigger a call to the upstream API at all.
515
+ Create one API route once. The data is server-cached for 24 hours — most users never trigger a call to the upstream API at all.
274
516
 
275
517
  ```ts
276
518
  // app/api/chisel-menu/route.ts
@@ -282,29 +524,66 @@ export const GET = createMenuHandler({
282
524
  })
283
525
  ```
284
526
 
527
+ For a curated menu, pass `menuId` to the handler:
528
+
529
+ ```ts
530
+ // app/api/chisel-menu/route.ts
531
+ export const GET = createMenuHandler({
532
+ clientSlug: "your-client-slug",
533
+ apiBase: "https://your-api.com",
534
+ menuId: "main-nav",
535
+ })
536
+ ```
537
+
538
+ Then pass `dataUrl` to the component:
539
+
285
540
  ```tsx
286
- // In your nav/header — works in any "use client" component
541
+ // components/Nav.tsx
542
+ "use client"
287
543
  import { ProjectMenuClient } from "project-portfolio"
288
544
 
289
- <ProjectMenuClient
290
- dataUrl="/api/chisel-menu"
291
- basePath="/projects"
292
- viewAllPath="/projects"
293
- />
545
+ export function Nav() {
546
+ return (
547
+ <ProjectMenuClient
548
+ dataUrl="/api/chisel-menu"
549
+ basePath="/projects"
550
+ viewAllPath="/projects"
551
+ subtitle="Explore our portfolio of projects."
552
+ maxProjects={6}
553
+ />
554
+ )
555
+ }
294
556
  ```
295
557
 
296
558
  ---
297
559
 
298
- **Option 2 — Direct fetch (quick setup / non-Next.js environments)**
560
+ #### Option 2 — Direct fetch (quick setup / non-Next.js environments)
299
561
 
300
- No API route needed. The component fetches directly from the upstream API on first mount and caches the result in memory for the session.
562
+ No API route needed. The component fetches directly from the upstream API on first mount and caches the result in memory for the session. Note: this exposes the API call to the client browser.
301
563
 
302
564
  ```tsx
565
+ "use client"
303
566
  import { ProjectMenuClient } from "project-portfolio"
304
567
 
568
+ export function Nav() {
569
+ return (
570
+ <ProjectMenuClient
571
+ clientSlug="your-client-slug"
572
+ apiBase="https://your-api.com"
573
+ basePath="/projects"
574
+ viewAllPath="/projects"
575
+ />
576
+ )
577
+ }
578
+ ```
579
+
580
+ With a curated menu:
581
+
582
+ ```tsx
305
583
  <ProjectMenuClient
306
584
  clientSlug="your-client-slug"
307
585
  apiBase="https://your-api.com"
586
+ menuId="main-nav"
308
587
  basePath="/projects"
309
588
  viewAllPath="/projects"
310
589
  />
@@ -314,22 +593,23 @@ import { ProjectMenuClient } from "project-portfolio"
314
593
 
315
594
  | Prop | Type | Required | Default | Description |
316
595
  |---|---|---|---|---|
317
- | `dataUrl` | `string` | No | — | URL of a local API route created with `createMenuHandler()`. Recommended for production |
596
+ | `dataUrl` | `string` | No* | — | URL of a local API route created with `createMenuHandler()`. Recommended for production |
318
597
  | `clientSlug` | `string` | No* | — | Client slug for direct fetch mode |
319
598
  | `apiBase` | `string` | No* | — | API base URL for direct fetch mode |
599
+ | `menuId` | `string` | No | — | ID of a curated menu. Fetches from `/menus/{menuId}` for projects. Filters are always shown regardless. |
320
600
  | `basePath` | `string` | Yes | — | Base path for project detail links |
321
601
  | `viewAllPath` | `string` | Yes | — | Path for the "View All Projects" link |
322
602
  | `subtitle` | `string` | No | — | Description shown above the project cards (hidden on mobile) |
323
603
  | `font` | `string` | No | System font stack | Font family string |
324
604
  | `maxProjects` | `number` | No | `6` | Maximum number of projects to display (capped at 3 on mobile) |
325
605
 
326
- *One of `dataUrl` or `clientSlug + apiBase` must be provided. Pre-fetched `projects`, `schema`, `filterOptions`, `filterFieldKey`, and `fieldOptionsMap` can also be passed directly for SSR usage.
606
+ *One of `dataUrl` or `clientSlug + apiBase` must be provided.
327
607
 
328
608
  ---
329
609
 
330
- ## Important: Server Component Usage
610
+ ## Server vs Client Components
331
611
 
332
- All top-level components (`ProjectPortfolio`, `ProjectDetail`, `ProjectMenu`, `SimilarProjects`) are **async Server Components**. They must be rendered in a server context:
612
+ All top-level components except `ProjectMenuClient`, `ProjectPortfolioClient`, and `GalleryCarousel` are **async Server Components**. They must be rendered in a server context:
333
613
 
334
614
  ```tsx
335
615
  // CORRECT
@@ -337,58 +617,63 @@ export default async function Page() {
337
617
  return <ProjectPortfolio clientSlug="..." apiBase="..." />
338
618
  }
339
619
 
340
- // WRONG — causes infinite client-side fetch loop
620
+ // WRONG — causes a runtime error
341
621
  "use client"
342
622
  export default function Page() {
343
623
  return <ProjectPortfolio clientSlug="..." apiBase="..." />
344
624
  }
345
625
  ```
346
626
 
347
- If a parent component uses `"use client"`, these components cannot be rendered inside it directly. Pass them as children from a server component instead.
627
+ If your parent component uses `"use client"`, use the client variants instead (`ProjectMenuClient`, `ProjectPortfolioClient`) or pass the server components as `children` from a server parent.
628
+
629
+ | Component | Type | Use when |
630
+ |---|---|---|
631
+ | `ProjectPortfolio` | Server | Page-level grid, URL-driven filters |
632
+ | `ProjectPortfolioClient` | Client | Inside a client tree, custom filter UI |
633
+ | `ProjectDetail` | Server | Project detail page |
634
+ | `GalleryCarousel` | Client | Standalone image gallery |
635
+ | `ProjectMenu` | Server | Server-rendered nav dropdown |
636
+ | `ProjectMenuClient` | Client | Client-rendered nav/header |
637
+ | `SimilarProjects` | Server | After `ProjectDetail` on detail pages |
348
638
 
349
639
  ---
350
640
 
351
641
  ## Caching
352
642
 
353
- Data is cached at multiple levels depending on which component is used.
354
-
355
- **Server Components (`ProjectMenu`, `ProjectPortfolio`, `ProjectDetail`, `SimilarProjects`)**
356
- 1. **Per-render** `React.cache()` deduplicates duplicate calls within the same render pass
357
- 2. **Cross-request** — `next: { revalidate: 86400 }` (24 hours) caches responses in Next.js's Data Cache across all users and sessions
358
-
359
- **`ProjectMenuClient` with `createMenuHandler` (recommended)**
360
- 3. **Server-side route cache** the `/api/chisel-menu` route is cached by Next.js for 24 hours. The upstream API is called at most once per day regardless of traffic
361
- 4. **Module-level client cache** after the first fetch within a session, the result is held in memory. Re-hovering the menu or remounting the component never triggers another network request
362
-
363
- **`ProjectMenuClient` with direct fetch / `ProjectPortfolioClient`**
364
- 4. **Module-level client cache only** — the upstream API is called once per session per user (on first mount), then cached in memory for the rest of that page session. Filter changes on `ProjectPortfolioClient` never trigger a fetch — filtering is done entirely in memory
643
+ | Component | Server Cache | Client Cache |
644
+ |---|---|---|
645
+ | `ProjectMenu` (RSC) | 24h via `next.revalidate` | — |
646
+ | `ProjectMenuClient` + `createMenuHandler` | 24h (route handler) | Per-session module cache |
647
+ | `ProjectMenuClient` (direct fetch) | None | Per-session module cache |
648
+ | `ProjectPortfolio` (RSC) | 24h via `next.revalidate` | — |
649
+ | `ProjectPortfolioClient` | None | Per-session module cache |
650
+ | `ProjectDetail` (RSC) | 24h via `next.revalidate` | |
651
+ | `SimilarProjects` (RSC) | 24h via `next.revalidate` | |
365
652
 
366
- **Bypassing the cache**
653
+ #### Bypassing the cache
367
654
 
368
655
  | Method | Scope | Use case |
369
656
  |---|---|---|
370
657
  | `?bust=1` on `/api/chisel-menu` | Single request | Dev/testing after a CMS change |
371
658
  | `CHISEL_CACHE_BYPASS=true` env var | Entire deployment | Staging environments |
372
- | `revalidateTag("chisel-menu-{clientSlug}")` | Server cache only | CMS webhook on content publish |
659
+ | `revalidateTag("chisel-menu-{clientSlug}")` | Server cache | CMS webhook on content publish |
373
660
  | `noCache: true` on `ProjectMenu` | Single render | Debug during development |
374
661
 
375
662
  ```ts
376
- // CMS webhook example — invalidate server cache immediately on publish
663
+ // CMS webhook — invalidate server cache on publish
377
664
  import { revalidateTag } from "next/cache"
378
665
  revalidateTag("chisel-menu-your-client-slug")
379
- ```
380
666
 
381
- No third-party caching libraries are required.
667
+ // For a curated menu, the tag includes the menuId
668
+ revalidateTag("chisel-menu-your-client-slug-main-nav")
669
+ ```
382
670
 
383
671
  ---
384
672
 
385
673
  ## Publishing to npm
386
674
 
387
675
  ```bash
388
- # Log in to npm
389
676
  npm login
390
-
391
- # Build and publish
392
677
  cd package
393
678
  npm run build
394
679
  npm publish --access public