@withwiz/gallery
v0.1.0
Published
Headless gallery module — Prisma 7 + Next 16, host-agnostic. Drop-in admin + public surface.
Readme
@withwiz/gallery
English | 한국어
Host-independent gallery module for Next.js 16 + Prisma 7. Self-contained admin UI, headless lightbox, drop-in API route factories.
Status
v0.1.0 — Sprints 1–6 complete (@withwiz/gallery scaffold + validators + services + server layer + hooks/primitive UI + admin composite UI + Prisma partial + ballet preset + README). Sprint 7 (ballet migration) not yet executed.
Features
- Headless — drop into any Next.js host via
setGalleryConfig(Prisma client / API wrapper / storage / revalidate / i18n / limits). - Admin UI — 5 composite components (
GalleryAdminManager,GalleryEditForm,GalleryHomePreview,GalleryManagerLayout,CategoryAdminManager) + 2 primitives (ImageDropZone,ToggleSwitch). Self-contained 3-pane layout. - API routes —
createGalleryRoutes(config)returns Next.js Route Handlers for 6 endpoint groups (collection / item / publish toggle / bulk / category collection / category item). - RSC loaders —
getGalleryItems,getFeaturedGalleries,getRecentGalleries,getGalleryCountfor server components / dashboards. - Public preset —
PublicGalleryMosaic(presets/ballet) — 1–7 tile adaptive mosaic + lightbox. - Headless lightbox hook —
useGalleryLightboxwith ESC / Arrow key bindings + wrap-around. - Image upload primitive —
useImageDropZone+<ImageDropZone>(host-side validate + accept/maxSize). - Storage-agnostic — R2 / S3 / local filesystem — host injects via
config.storage. - Host-model-name-agnostic — the Prisma
Gallerymodel makes no assumption about the host's User/Admin/Account model name. Only anauthorId: Stringcolumn. - Typed error hierarchy —
GalleryNotFoundError,CategoryNotFoundError,CategoryInUseError,PermissionDeniedErrorfor the host to catch and handle.
Install
Currently consumed via file: reference inside the monorepo (not yet published to npm):
# host root
npm install file:../node-packages/withwiz-galleryAfter publishing:
npm install @withwiz/galleryPeer dependencies
| Package | Range |
|---|---|
| next | >=16 |
| react / react-dom | >=19 |
| @prisma/client | >=7 |
| zod | >=4 |
| clsx | >=2 |
| tailwind-merge | >=3 |
| sonner | >=2 (optional) |
Quick start
1. Merge the Prisma schema
Copy or symlink this package's partial schema into the host's prisma/ directory.
// host package.json
{
"prisma": { "schema": "./prisma" }
}# in the host
cp node_modules/@withwiz/gallery/prisma/gallery.schema.prisma prisma/
npx prisma generate
npx prisma migrate dev --create-onlyThe partial defines two models (GalleryCategory, Gallery) plus indexes and @@map. Because authorId is a plain String column (no @relation), the host's User-model name is irrelevant and the multi-file schema validates cleanly.
Migrating from a ballet-style enum-based schema? See Migration from ballet enum-based schema below.
2. setGalleryConfig (host bootstrap)
Call this at module top-level — client components invoke getGalleryConfig() on mount.
// host: lib/gallery-config.ts
import { setGalleryConfig } from "@withwiz/gallery/server";
import type { GalleryConfig } from "@withwiz/gallery/server";
import { prisma } from "./prisma";
import { withAdminApi } from "@/lib/api-middleware"; // host's auth wrapper
import { isR2Enabled, collectR2Keys, deleteR2Keys } from "@/lib/r2";
import { revalidatePath } from "next/cache";
export const galleryConfig: GalleryConfig = {
prisma,
apiWrapper: withAdminApi,
authorIdFromContext: (ctx) => ctx.user!.id,
limits: {
maxFeatured: 7,
mosaicCount: 7,
batchMax: 20,
captionMaxLength: 200,
},
revalidate: revalidatePath,
revalidatePaths: ["/", "/home/v1"],
storage: {
isEnabled: isR2Enabled,
collectKeys: collectR2Keys,
deleteKeys: deleteR2Keys,
},
i18n: {
"admin.title": "Gallery",
"admin.newButton": "New image",
"form.caption": "Caption",
"form.save": "Save",
"form.cancel": "Cancel",
// see @withwiz/gallery/types → GalleryI18nKey for the full key list
},
};
setGalleryConfig(galleryConfig);3. Mount route handlers
Thin re-export per endpoint.
// host: app/api/admin/galleries/route.ts
import { createGalleryRoutes } from "@withwiz/gallery/server";
import { galleryConfig } from "@/lib/gallery-config";
const { collection } = createGalleryRoutes(galleryConfig);
export const { GET, POST, DELETE } = collection;// host: app/api/admin/galleries/[id]/route.ts
import { createGalleryRoutes } from "@withwiz/gallery/server";
import { galleryConfig } from "@/lib/gallery-config";
const { item } = createGalleryRoutes(galleryConfig);
export const { GET, PUT, DELETE } = item;// host: app/api/admin/galleries/[id]/publish/route.ts
import { createGalleryRoutes } from "@withwiz/gallery/server";
import { galleryConfig } from "@/lib/gallery-config";
const { publishToggle } = createGalleryRoutes(galleryConfig);
export const { PATCH } = publishToggle;// host: app/api/admin/galleries/bulk/route.ts
import { createGalleryRoutes } from "@withwiz/gallery/server";
import { galleryConfig } from "@/lib/gallery-config";
const { bulk } = createGalleryRoutes(galleryConfig);
export const { POST, PATCH } = bulk;// host: app/api/admin/gallery-categories/route.ts
import { createGalleryRoutes } from "@withwiz/gallery/server";
import { galleryConfig } from "@/lib/gallery-config";
const { categoryCollection } = createGalleryRoutes(galleryConfig);
export const { GET, POST } = categoryCollection;// host: app/api/admin/gallery-categories/[id]/route.ts
import { createGalleryRoutes } from "@withwiz/gallery/server";
import { galleryConfig } from "@/lib/gallery-config";
const { categoryItem } = createGalleryRoutes(galleryConfig);
export const { GET, PUT, DELETE } = categoryItem;4. Mount the admin UI
// host: app/admin/galleries/page.tsx
import { GalleryAdminManager } from "@withwiz/gallery/components";
import "@withwiz/gallery/components/gallery.css";
import "@/lib/gallery-config"; // setGalleryConfig as a module side effect
export default function GalleryAdminPage() {
return <GalleryAdminManager />;
}// host: app/admin/galleries/new/page.tsx
import { GalleryAdminManager } from "@withwiz/gallery/components";
import "@/lib/gallery-config";
export default function GalleryNewPage() {
return <GalleryAdminManager initialMode="new" />;
}// host: app/admin/galleries/[id]/page.tsx
import { GalleryAdminManager } from "@withwiz/gallery/components";
import "@/lib/gallery-config";
export default async function GalleryEditPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <GalleryAdminManager initialSelectedId={id} />;
}// host: app/admin/gallery-categories/page.tsx
import { CategoryAdminManager } from "@withwiz/gallery/components";
import "@/lib/gallery-config";
export default function CategoryAdminPage() {
return <CategoryAdminManager />;
}5. Public gallery (RSC + client)
// host: app/page.tsx (RSC)
import { PublicGalleryMosaic } from "@withwiz/gallery/presets/ballet";
import { getFeaturedGalleries } from "@withwiz/gallery/server";
import { galleryConfig } from "@/lib/gallery-config";
import "@withwiz/gallery/components/gallery.css";
export default async function HomePage() {
const featured = await getFeaturedGalleries(galleryConfig, 7);
const images = featured.map((g) => ({
src: g.imageUrl,
alt: g.caption ?? "Gallery",
}));
return (
<PublicGalleryMosaic
images={images}
count={7}
i18n={{ sectionLabel: "Gallery", moments: "Moments from the stage" }}
/>
);
}API reference
@withwiz/gallery (main entry)
import {
setGalleryConfig,
getGalleryConfig,
cn,
getVariantUrl,
GalleryError,
GalleryNotFoundError,
CategoryNotFoundError,
CategoryInUseError,
PermissionDeniedError,
} from "@withwiz/gallery";@withwiz/gallery/server
| Export | Signature |
|---|---|
| setGalleryConfig(config) | DI setter — call once at module top-level |
| getGalleryConfig() | Returns the registered config (throws if unset) |
| createGalleryService(config) | CRUD object (list / get / create / update / remove / bulk*) |
| createCategoryService(config) | Category CRUD (list / getBySlug / create / update / remove / reorder) |
| createGalleryRoutes(config) | 6 Next.js Route Handler groups (collection / item / publishToggle / bulk / categoryCollection / categoryItem) |
| getGalleryItems(config, opts) | RSC pagination loader |
| getFeaturedGalleries(config, limit?) | RSC featured list |
| getRecentGalleries(config, limit) | RSC recent items (dashboard) |
| getGalleryCount(config, opts?) | RSC count (dashboard) |
| buildPaginatedResult(items, page, limit, total) | helper |
Typed errors (also re-exported from the server entry):
GalleryError(base)GalleryNotFoundErrorCategoryNotFoundErrorCategoryInUseErrorPermissionDeniedError
@withwiz/gallery/components
| Export | Description |
|---|---|
| GalleryAdminManager | 3-pane admin mount point (initialMode? / initialSelectedId?) |
| GalleryManagerLayout | 3-pane primitive (left = list / center = form / right = preview) |
| GalleryEditForm | Single/multi form (value / multipleMode / onSubmit / onSubmitMany) |
| GalleryHomePreview | 7-tile mosaic + drag reorder + featured toggle |
| CategoryAdminManager | Category CRUD UI |
| ImageDropZone | Image dropzone (accept / maxSize / multiple / disabled / validate) |
| ToggleSwitch | size sm/md/lg, ARIA switch role |
CSS (host imports explicitly):
import "@withwiz/gallery/components/gallery.css";@withwiz/gallery/hooks
| Export | Signature |
|---|---|
| useGalleryLightbox(images) | { isOpen, currentIndex, current, open, close, next, prev } — auto-bound ESC / Arrow, wrap-around |
| useImageDropZone(opts) | Headless dropzone — inputProps / containerProps / files / rejectedReasons / clear |
| useScrollReveal(opts?) | IntersectionObserver-based fade-in — { ref, isVisible } |
@withwiz/gallery/validators
import { createGallerySchemas } from "@withwiz/gallery/validators";
const schemas = createGallerySchemas({
batchMax: 20,
captionMaxLength: 200,
});
// schemas.CreateGallerySchema / UpdateGallerySchema / BatchCreateGallerySchema
// / BulkUpdateSchema / CreateCategorySchema / UpdateCategorySchema / ReorderCategorySchema@withwiz/gallery/types
import type {
GalleryConfig,
GalleryI18nKey,
GallerySlotName,
GalleryListItem,
GalleryDetail,
GalleryCategoryItem,
CreateGalleryInput,
UpdateGalleryInput,
CreateCategoryInput,
UpdateCategoryInput,
PaginatedResult,
PaginationMeta,
SortOrder,
PrismaLike,
ApiContext,
RouteHandler,
ApiWrapper,
} from "@withwiz/gallery/types";@withwiz/gallery/presets/ballet
import { PublicGalleryMosaic } from "@withwiz/gallery/presets/ballet";
import type { PublicGalleryMosaicProps } from "@withwiz/gallery/presets/ballet";Signature:
function PublicGalleryMosaic(props: {
images: { src: string; alt?: string }[];
count?: number; // default 7
i18n?: {
sectionLabel?: string;
moments?: string;
expandAria?: string;
lbClose?: string;
lbPrev?: string;
lbNext?: string;
};
className?: string;
scrollReveal?: boolean; // default true
hideHeader?: boolean; // default false
}): JSX.Element | null;Host-independence guarantees
- Zero
@withwiz/pmsimports — this package does not depend on any domain package. - Zero direct
process.envreads — environment variables are read by the host and bound intoconfig.storage, etc. - No assumption about the host's User model name — the Prisma
Gallerymodel has only anauthorId: Stringcolumn and no@relation. - All infrastructure is injected via
GalleryConfig— Prisma client / API wrapper / storage / revalidate / i18n / limits. - Storage / auth / revalidate are the host's responsibility — this package only deals with keys/paths; actual R2/S3/local calls are delegated to the host via
config.storage.deleteKeys, etc. presets/balletdoes not importnext/image,next/navigation, oruseI18n— images use plain<img>, labels come from props.
CSS customization
Override the CSS variables from the host's root CSS.
/* host: app/globals.css */
:root {
--gallery-accent: 212 175 55; /* RGB triplet (NOT hex) */
--gallery-bg: 10 10 10;
--gallery-fg: 254 254 254;
--gallery-border: 30 30 30;
}These four variables are defined as fallbacks inside :where(.gallery-toggle, .gallery-dropzone, .gallery-manager, .gallery-edit-form, .gallery-home-preview, .gallery-category-admin, .gallery-public-mosaic, .gallery-lightbox, ...), so the package works out of the box (dark tone + gold accent) without any host override.
The admin components also support per-slot customization via config.ui.classNames[slot] / config.ui.slots[slot].
Migration from ballet enum-based schema
ballet's existing schema:
enum GalleryCategory { PERFORMANCE | REHEARSAL | ACTIVITY | ARTIST }
model Gallery {
category GalleryCategory
// ...
}This package's new schema replaces the enum with a gallery_categories table + a Gallery.categoryId FK. Migration steps:
- Merge this package's
gallery.schema.prismainto the host'sprisma/(copy or symlink). - Run
npx prisma migrate dev --create-onlyto auto-generate the migration SQL. - Manually remove
DROP COLUMN category/DROP TYPE "GalleryCategory"from the generated SQL (separate them into a follow-up migration). - Insert the contents of
prisma/migrations/2026-05-24-enum-to-table.sql(shipped with this package) at that location — seeds the 4 categories, backfillsgalleries.category_id, and includes a PL/pgSQL verification block. npx prisma migrate devto apply.- Once verification passes, run the second migration (separate file) to
DROPthe enum column and type.
Reference the shipped prisma/migrations/2026-05-24-enum-to-table.sql as-is.
The ballet code (src/components/sections/Gallery.tsx — gallery-mosaic / lightbox-* classes) and this package (gallery-public-mosaic / gallery-lightbox__* classes) use different prefixes and are isolated. During ballet migration, remove those classes from the existing main.css and import only this package's gallery.css.
Architecture
@withwiz/toolkit (lowest)
↑
@withwiz/pms (performance-management domain CMS)
↑ ↑
│ │ consumed as a host
host app (ballet, yeroom) │
↓ │
└─→ @withwiz/gallery ┘ (this package — depends on no host)@withwiz/gallery depends on no host project (ballet, yeroom, etc.), no domain package (@withwiz/pms, etc.), and no host domain model (User, Admin, Account, etc.). Whatever the host uses (Next 16 / Prisma 7 assumed), a single setGalleryConfig call integrates it.
Repository layout
node-packages/withwiz-gallery/
├── package.json
├── tsup.config.ts
├── README.md
├── prisma/
│ ├── gallery.schema.prisma # GalleryCategory + Gallery partial models
│ └── migrations/
│ └── 2026-05-24-enum-to-table.sql # ballet enum → table backfill
├── docs/superpowers/{specs,plans}/ # design docs + sprint contracts/reports
└── src/
├── index.ts # main entry — config, types, errors, utils
├── config.ts
├── errors.ts
├── types/ # GalleryConfig / domain / pagination / api-context / i18n
├── validators/ # createGallerySchemas
├── services/ # createGalleryService / createCategoryService / helpers
├── server/ # createGalleryRoutes + loaders + server re-exports
├── components/ # admin UI + ImageDropZone + ToggleSwitch + gallery.css
├── hooks/ # useGalleryLightbox / useImageDropZone / useScrollReveal
├── utils/ # cn / image-variants / api-helpers
└── presets/
└── ballet.tsx # PublicGalleryMosaicScripts
npm run build # tsup multi-entry CJS/ESM/DTS
npm test # vitest run (221 tests, 23 files)
npm run test:watch # vitest watchLicense
MIT
