project-portfolio 2.0.0 → 2.1.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 | `60` | Cache revalidation period in seconds |
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
+ ```
74
+
75
+ ```ts
76
+ // app/api/chisel-menu/route.ts
77
+ import { createMenuHandler } from "project-portfolio"
78
+
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
+ ---
49
105
 
50
- #### URL-driven filter integration
106
+ ## Components
107
+
108
+ ### `ProjectPortfolio`
51
109
 
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:
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 its image gallery, custom fields, 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
@@ -175,51 +242,52 @@ export default async function ProjectPage({ params }: { params: { slug: string }
175
242
  | `apiBase` | `string` | Yes | — | Base URL of the projects API |
176
243
  | `backPath` | `string` | No | `"/projects"` | Path for the back navigation link |
177
244
  | `backLabel` | `string` | No | `"All Projects"` | Label for the back navigation link |
178
- | `revalidate` | `number` | No | `60` | Cache revalidation period in seconds |
245
+ | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds (24 hours) |
179
246
 
180
247
  ---
181
248
 
182
- ### `ProjectMenu`
249
+ ### `GalleryCarousel`
183
250
 
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.
251
+ 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
252
 
186
253
  ```tsx
187
- // components/navigation/MegaMenu.tsx
188
- import { ProjectMenu } from "project-portfolio"
254
+ "use client"
255
+ import { GalleryCarousel } from "project-portfolio"
189
256
 
190
- // Must be a Server Component do NOT add "use client"
191
- export async function ProjectsMegaMenu() {
257
+ // `media` is the array of image objects returned by the projects API
258
+ export function ProjectGallery({ media, title }: { media: Media[]; title: string }) {
192
259
  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}
260
+ <GalleryCarousel
261
+ images={media}
262
+ projectTitle={title}
199
263
  />
200
264
  )
201
265
  }
202
266
  ```
203
267
 
268
+ The parent component is responsible for injecting the `.chisel-gallery-main-img` CSS class to control the main image height:
269
+
270
+ ```css
271
+ .chisel-gallery-main-img {
272
+ height: 400px; /* adjust to your layout */
273
+ }
274
+ @media (min-width: 768px) {
275
+ .chisel-gallery-main-img {
276
+ height: 560px;
277
+ }
278
+ }
279
+ ```
280
+
204
281
  | Prop | Type | Required | Default | Description |
205
282
  |---|---|---|---|---|
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.
283
+ | `images` | `Media[]` | Yes | — | Array of media objects from the projects API |
284
+ | `projectTitle` | `string` | Yes | — | Used as the alt text fallback for the main image |
217
285
 
218
286
  ---
219
287
 
220
288
  ### `SimilarProjects`
221
289
 
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.
290
+ 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
291
 
224
292
  ```tsx
225
293
  // app/projects/[slug]/page.tsx
@@ -245,32 +313,176 @@ export default async function ProjectPage({ params }: { params: { slug: string }
245
313
  }
246
314
  ```
247
315
 
248
- `filters` accepts any combination of custom field key/value pairs — all must match (AND logic). `excludeSlug` optionally removes the current project from results.
316
+ #### Deriving filters from the current project
317
+
318
+ 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:
319
+
320
+ ```tsx
321
+ // app/projects/[slug]/page.tsx
322
+ import { ProjectDetail, SimilarProjects } from "project-portfolio"
323
+
324
+ async function getProject(slug: string, clientSlug: string, apiBase: string) {
325
+ const res = await fetch(
326
+ `${apiBase}/api/v1/clients/${clientSlug}/projects/${slug}?api_key=YOUR_API_KEY`,
327
+ { next: { revalidate: 86400 } }
328
+ )
329
+ return res.ok ? (await res.json())?.data : null
330
+ }
331
+
332
+ export default async function ProjectPage({ params }: { params: { slug: string } }) {
333
+ const project = await getProject(params.slug, "your-client-slug", "https://your-api.com")
334
+ const projectType = project?.custom_field_values?.type ?? null
335
+
336
+ return (
337
+ <>
338
+ <ProjectDetail slug={params.slug} clientSlug="your-client-slug" apiBase="https://your-api.com" />
339
+ {projectType && (
340
+ <SimilarProjects
341
+ filters={{ type: projectType }}
342
+ excludeSlug={params.slug}
343
+ clientSlug="your-client-slug"
344
+ apiBase="https://your-api.com"
345
+ basePath="/projects"
346
+ />
347
+ )}
348
+ </>
349
+ )
350
+ }
351
+ ```
352
+
353
+ #### Manually specifying projects (recommended for quick setup)
354
+
355
+ 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.
356
+
357
+ 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.
358
+
359
+ ```tsx
360
+ // app/projects/[slug]/page.tsx
361
+ import { ProjectDetail, SimilarProjects } from "project-portfolio"
362
+
363
+ export default async function ProjectPage({ params }: { params: { slug: string } }) {
364
+ return (
365
+ <>
366
+ {/* All your other page content above */}
367
+ <ProjectDetail
368
+ slug={params.slug}
369
+ clientSlug="your-client-slug"
370
+ apiBase="https://your-api.com"
371
+ backPath="/projects"
372
+ />
373
+
374
+ {/* Similar projects hardcoded — update slugs per client request */}
375
+ <SimilarProjects
376
+ projectSlugs={[
377
+ "jacob-javits-convention-center",
378
+ "tillamook-bay-community-college",
379
+ "lcisd-liberty-hill-high-school",
380
+ ]}
381
+ excludeSlug={params.slug}
382
+ clientSlug="your-client-slug"
383
+ apiBase="https://your-api.com"
384
+ basePath="/projects"
385
+ />
386
+ </>
387
+ )
388
+ }
389
+ ```
390
+
391
+ > `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.
392
+
393
+ 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`.
394
+
395
+ #### Card variant
396
+
397
+ Use `variant="card"` to render the same baseball-card style used in `ProjectPortfolio` instead of the default list style:
398
+
399
+ ```tsx
400
+ <SimilarProjects
401
+ filters={{ type: "commercial" }}
402
+ excludeSlug={params.slug}
403
+ clientSlug="your-client-slug"
404
+ apiBase="https://your-api.com"
405
+ basePath="/projects"
406
+ variant="card"
407
+ />
408
+ ```
249
409
 
250
410
  | Prop | Type | Required | Default | Description |
251
411
  |---|---|---|---|---|
412
+ | `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
413
+ | `apiBase` | `string` | Yes | — | Base URL of the projects API |
252
414
  | `filters` | `Record<string, string>` | No | `{}` | Key/value pairs to filter projects by custom field values. All filters must match (AND logic) |
253
415
  | `excludeSlug` | `string` | No | — | Slug of a project to exclude from results (e.g. the current project) |
416
+ | `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
417
+ | `projectSlugs` | `string[]` | No | — | Explicit list of project slugs to show, in order. When provided, overrides `filters` entirely. e.g. `["jacob-javits", "tillamook-bay"]` |
418
+ | `maxItems` | `number` | No | `3` | Maximum number of projects to show |
419
+ | `variant` | `"list" \| "card"` | No | `"list"` | `"list"` uses the border-bottom separator style; `"card"` renders full baseball-card style matching `ProjectPortfolio` |
420
+ | `font` | `string` | No | System font stack | Font family string applied to all inline styles |
421
+ | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds (24 hours) |
422
+
423
+ ---
424
+
425
+ ### `ProjectMenu`
426
+
427
+ 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.
428
+
429
+ ```tsx
430
+ // components/MegaMenu.tsx
431
+ import { ProjectMenu } from "project-portfolio"
432
+
433
+ // Must be a Server Component — do NOT add "use client"
434
+ export async function ProjectsMegaMenu() {
435
+ return (
436
+ <ProjectMenu
437
+ clientSlug="your-client-slug"
438
+ apiBase="https://your-api.com"
439
+ basePath="/projects"
440
+ subtitle="Our systems are installed in every geographic region of the U.S."
441
+ maxProjects={6}
442
+ />
443
+ )
444
+ }
445
+ ```
446
+
447
+ #### With a curated menu
448
+
449
+ 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`.
450
+
451
+ ```tsx
452
+ <ProjectMenu
453
+ clientSlug="your-client-slug"
454
+ apiBase="https://your-api.com"
455
+ menuId="main-nav"
456
+ basePath="/projects"
457
+ />
458
+ ```
459
+
460
+ | Prop | Type | Required | Default | Description |
461
+ |---|---|---|---|---|
254
462
  | `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
255
463
  | `apiBase` | `string` | Yes | — | Base URL of the projects API |
464
+ | `menuId` | `string` | No | — | ID of a curated menu. When provided, fetches from `/menus/{menuId}` instead of all projects. Filters always shown regardless. |
256
465
  | `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
257
- | `maxItems` | `number` | No | `3` | Maximum number of projects to show |
466
+ | `viewAllPath` | `string` | No | Same as `basePath` | Path for the "View All Projects" link |
467
+ | `subtitle` | `string` | No | — | Description paragraph shown above the project cards |
258
468
  | `font` | `string` | No | System font stack | Font family string applied to all inline styles |
259
- | `revalidate` | `number` | No | `60` | Cache revalidation period in seconds |
469
+ | `maxProjects` | `number` | No | `6` | Maximum number of projects to display |
470
+ | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds (24 hours) |
471
+ | `noCache` | `boolean` | No | `false` | Disable caching entirely — useful for development |
260
472
 
261
473
  ---
262
474
 
263
475
  ### `ProjectMenuClient` + `createMenuHandler`
264
476
 
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.
477
+ `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
478
 
267
- There are two ways to use it:
479
+ There are two ways to set it up:
268
480
 
269
481
  ---
270
482
 
271
- **Option 1 — `dataUrl` + `createMenuHandler` (recommended for production)**
483
+ #### Option 1 — `dataUrl` + `createMenuHandler` (recommended for production)
272
484
 
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.
485
+ 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
486
 
275
487
  ```ts
276
488
  // app/api/chisel-menu/route.ts
@@ -282,29 +494,66 @@ export const GET = createMenuHandler({
282
494
  })
283
495
  ```
284
496
 
497
+ For a curated menu, pass `menuId` to the handler:
498
+
499
+ ```ts
500
+ // app/api/chisel-menu/route.ts
501
+ export const GET = createMenuHandler({
502
+ clientSlug: "your-client-slug",
503
+ apiBase: "https://your-api.com",
504
+ menuId: "main-nav",
505
+ })
506
+ ```
507
+
508
+ Then pass `dataUrl` to the component:
509
+
285
510
  ```tsx
286
- // In your nav/header — works in any "use client" component
511
+ // components/Nav.tsx
512
+ "use client"
287
513
  import { ProjectMenuClient } from "project-portfolio"
288
514
 
289
- <ProjectMenuClient
290
- dataUrl="/api/chisel-menu"
291
- basePath="/projects"
292
- viewAllPath="/projects"
293
- />
515
+ export function Nav() {
516
+ return (
517
+ <ProjectMenuClient
518
+ dataUrl="/api/chisel-menu"
519
+ basePath="/projects"
520
+ viewAllPath="/projects"
521
+ subtitle="Explore our portfolio of projects."
522
+ maxProjects={6}
523
+ />
524
+ )
525
+ }
294
526
  ```
295
527
 
296
528
  ---
297
529
 
298
- **Option 2 — Direct fetch (quick setup / non-Next.js environments)**
530
+ #### Option 2 — Direct fetch (quick setup / non-Next.js environments)
299
531
 
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.
532
+ 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
533
 
302
534
  ```tsx
535
+ "use client"
303
536
  import { ProjectMenuClient } from "project-portfolio"
304
537
 
538
+ export function Nav() {
539
+ return (
540
+ <ProjectMenuClient
541
+ clientSlug="your-client-slug"
542
+ apiBase="https://your-api.com"
543
+ basePath="/projects"
544
+ viewAllPath="/projects"
545
+ />
546
+ )
547
+ }
548
+ ```
549
+
550
+ With a curated menu:
551
+
552
+ ```tsx
305
553
  <ProjectMenuClient
306
554
  clientSlug="your-client-slug"
307
555
  apiBase="https://your-api.com"
556
+ menuId="main-nav"
308
557
  basePath="/projects"
309
558
  viewAllPath="/projects"
310
559
  />
@@ -314,22 +563,23 @@ import { ProjectMenuClient } from "project-portfolio"
314
563
 
315
564
  | Prop | Type | Required | Default | Description |
316
565
  |---|---|---|---|---|
317
- | `dataUrl` | `string` | No | — | URL of a local API route created with `createMenuHandler()`. Recommended for production |
566
+ | `dataUrl` | `string` | No* | — | URL of a local API route created with `createMenuHandler()`. Recommended for production |
318
567
  | `clientSlug` | `string` | No* | — | Client slug for direct fetch mode |
319
568
  | `apiBase` | `string` | No* | — | API base URL for direct fetch mode |
569
+ | `menuId` | `string` | No | — | ID of a curated menu. Fetches from `/menus/{menuId}` for projects. Filters are always shown regardless. |
320
570
  | `basePath` | `string` | Yes | — | Base path for project detail links |
321
571
  | `viewAllPath` | `string` | Yes | — | Path for the "View All Projects" link |
322
- | `subtitle` | `string` | No | — | Description shown above the project cards |
572
+ | `subtitle` | `string` | No | — | Description shown above the project cards (hidden on mobile) |
323
573
  | `font` | `string` | No | System font stack | Font family string |
324
- | `maxProjects` | `number` | No | `6` | Maximum number of projects to display |
574
+ | `maxProjects` | `number` | No | `6` | Maximum number of projects to display (capped at 3 on mobile) |
325
575
 
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.
576
+ *One of `dataUrl` or `clientSlug + apiBase` must be provided.
327
577
 
328
578
  ---
329
579
 
330
- ## Important: Server Component Usage
580
+ ## Server vs Client Components
331
581
 
332
- All top-level components (`ProjectPortfolio`, `ProjectDetail`, `ProjectMenu`, `SimilarProjects`) are **async Server Components**. They must be rendered in a server context:
582
+ All top-level components except `ProjectMenuClient`, `ProjectPortfolioClient`, and `GalleryCarousel` are **async Server Components**. They must be rendered in a server context:
333
583
 
334
584
  ```tsx
335
585
  // CORRECT
@@ -337,58 +587,63 @@ export default async function Page() {
337
587
  return <ProjectPortfolio clientSlug="..." apiBase="..." />
338
588
  }
339
589
 
340
- // WRONG — causes infinite client-side fetch loop
590
+ // WRONG — causes a runtime error
341
591
  "use client"
342
592
  export default function Page() {
343
593
  return <ProjectPortfolio clientSlug="..." apiBase="..." />
344
594
  }
345
595
  ```
346
596
 
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.
597
+ 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.
598
+
599
+ | Component | Type | Use when |
600
+ |---|---|---|
601
+ | `ProjectPortfolio` | Server | Page-level grid, URL-driven filters |
602
+ | `ProjectPortfolioClient` | Client | Inside a client tree, custom filter UI |
603
+ | `ProjectDetail` | Server | Project detail page |
604
+ | `GalleryCarousel` | Client | Standalone image gallery |
605
+ | `ProjectMenu` | Server | Server-rendered nav dropdown |
606
+ | `ProjectMenuClient` | Client | Client-rendered nav/header |
607
+ | `SimilarProjects` | Server | After `ProjectDetail` on detail pages |
348
608
 
349
609
  ---
350
610
 
351
611
  ## Caching
352
612
 
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
613
+ | Component | Server Cache | Client Cache |
614
+ |---|---|---|
615
+ | `ProjectMenu` (RSC) | 24h via `next.revalidate` | — |
616
+ | `ProjectMenuClient` + `createMenuHandler` | 24h (route handler) | Per-session module cache |
617
+ | `ProjectMenuClient` (direct fetch) | None | Per-session module cache |
618
+ | `ProjectPortfolio` (RSC) | 24h via `next.revalidate` | — |
619
+ | `ProjectPortfolioClient` | None | Per-session module cache |
620
+ | `ProjectDetail` (RSC) | 24h via `next.revalidate` | |
621
+ | `SimilarProjects` (RSC) | 24h via `next.revalidate` | |
365
622
 
366
- **Bypassing the cache**
623
+ #### Bypassing the cache
367
624
 
368
625
  | Method | Scope | Use case |
369
626
  |---|---|---|
370
627
  | `?bust=1` on `/api/chisel-menu` | Single request | Dev/testing after a CMS change |
371
628
  | `CHISEL_CACHE_BYPASS=true` env var | Entire deployment | Staging environments |
372
- | `revalidateTag("chisel-menu-{clientSlug}")` | Server cache only | CMS webhook on content publish |
629
+ | `revalidateTag("chisel-menu-{clientSlug}")` | Server cache | CMS webhook on content publish |
373
630
  | `noCache: true` on `ProjectMenu` | Single render | Debug during development |
374
631
 
375
632
  ```ts
376
- // CMS webhook example — invalidate server cache immediately on publish
633
+ // CMS webhook — invalidate server cache on publish
377
634
  import { revalidateTag } from "next/cache"
378
635
  revalidateTag("chisel-menu-your-client-slug")
379
- ```
380
636
 
381
- No third-party caching libraries are required.
637
+ // For a curated menu, the tag includes the menuId
638
+ revalidateTag("chisel-menu-your-client-slug-main-nav")
639
+ ```
382
640
 
383
641
  ---
384
642
 
385
643
  ## Publishing to npm
386
644
 
387
645
  ```bash
388
- # Log in to npm
389
646
  npm login
390
-
391
- # Build and publish
392
647
  cd package
393
648
  npm run build
394
649
  npm publish --access public
@@ -1,6 +1,7 @@
1
1
  import type { Media } from "./types";
2
- export declare function GalleryCarousel({ images, projectTitle, }: {
2
+ export interface GalleryCarouselProps {
3
3
  images: Media[];
4
4
  projectTitle: string;
5
- }): import("react/jsx-runtime").JSX.Element | null;
5
+ }
6
+ export declare function GalleryCarousel({ images, projectTitle, }: GalleryCarouselProps): import("react/jsx-runtime").JSX.Element | null;
6
7
  //# sourceMappingURL=GalleryCarousel.d.ts.map