@withwiz/gallery 0.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.
Files changed (77) hide show
  1. package/README.ko.md +484 -0
  2. package/README.md +484 -0
  3. package/dist/chunk-B3MQL6ZR.mjs +79 -0
  4. package/dist/chunk-B3MQL6ZR.mjs.map +1 -0
  5. package/dist/chunk-BC3TPJQY.mjs +122 -0
  6. package/dist/chunk-BC3TPJQY.mjs.map +1 -0
  7. package/dist/chunk-BTLELFKC.mjs +11 -0
  8. package/dist/chunk-BTLELFKC.mjs.map +1 -0
  9. package/dist/chunk-GDUGKVDQ.mjs +50 -0
  10. package/dist/chunk-GDUGKVDQ.mjs.map +1 -0
  11. package/dist/chunk-GFOWZGJX.js +50 -0
  12. package/dist/chunk-GFOWZGJX.js.map +1 -0
  13. package/dist/chunk-ILSZPLME.js +47 -0
  14. package/dist/chunk-ILSZPLME.js.map +1 -0
  15. package/dist/chunk-M7CCTDUG.js +122 -0
  16. package/dist/chunk-M7CCTDUG.js.map +1 -0
  17. package/dist/chunk-WVRTM6E7.js +19 -0
  18. package/dist/chunk-WVRTM6E7.js.map +1 -0
  19. package/dist/chunk-XLPMNJJV.js +11 -0
  20. package/dist/chunk-XLPMNJJV.js.map +1 -0
  21. package/dist/chunk-XSPWM4DD.js +79 -0
  22. package/dist/chunk-XSPWM4DD.js.map +1 -0
  23. package/dist/chunk-YUMB3GBN.mjs +47 -0
  24. package/dist/chunk-YUMB3GBN.mjs.map +1 -0
  25. package/dist/chunk-ZEZCUJJD.mjs +19 -0
  26. package/dist/chunk-ZEZCUJJD.mjs.map +1 -0
  27. package/dist/components/gallery.css +1074 -0
  28. package/dist/components/index.d.mts +156 -0
  29. package/dist/components/index.d.ts +156 -0
  30. package/dist/components/index.js +1215 -0
  31. package/dist/components/index.js.map +1 -0
  32. package/dist/components/index.mjs +1215 -0
  33. package/dist/components/index.mjs.map +1 -0
  34. package/dist/errors-B9gzUsX-.d.ts +39 -0
  35. package/dist/errors-DYrlDCzP.d.mts +39 -0
  36. package/dist/gallery.css +1074 -0
  37. package/dist/hooks/index.d.mts +65 -0
  38. package/dist/hooks/index.d.ts +65 -0
  39. package/dist/hooks/index.js +14 -0
  40. package/dist/hooks/index.js.map +1 -0
  41. package/dist/hooks/index.mjs +14 -0
  42. package/dist/hooks/index.mjs.map +1 -0
  43. package/dist/index.d.mts +17 -0
  44. package/dist/index.d.ts +17 -0
  45. package/dist/index.js +37 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/index.mjs +37 -0
  48. package/dist/index.mjs.map +1 -0
  49. package/dist/presets/ballet.d.mts +64 -0
  50. package/dist/presets/ballet.d.ts +64 -0
  51. package/dist/presets/ballet.js +162 -0
  52. package/dist/presets/ballet.js.map +1 -0
  53. package/dist/presets/ballet.mjs +162 -0
  54. package/dist/presets/ballet.mjs.map +1 -0
  55. package/dist/server/index.d.mts +157 -0
  56. package/dist/server/index.d.ts +157 -0
  57. package/dist/server/index.js +619 -0
  58. package/dist/server/index.js.map +1 -0
  59. package/dist/server/index.mjs +619 -0
  60. package/dist/server/index.mjs.map +1 -0
  61. package/dist/types/index.d.mts +137 -0
  62. package/dist/types/index.d.ts +137 -0
  63. package/dist/types/index.js +1 -0
  64. package/dist/types/index.js.map +1 -0
  65. package/dist/types/index.mjs +1 -0
  66. package/dist/types/index.mjs.map +1 -0
  67. package/dist/useGalleryLightbox-BYzPyBYB.d.mts +22 -0
  68. package/dist/useGalleryLightbox-BYzPyBYB.d.ts +22 -0
  69. package/dist/validators/index.d.mts +68 -0
  70. package/dist/validators/index.d.ts +68 -0
  71. package/dist/validators/index.js +7 -0
  72. package/dist/validators/index.js.map +1 -0
  73. package/dist/validators/index.mjs +7 -0
  74. package/dist/validators/index.mjs.map +1 -0
  75. package/package.json +85 -0
  76. package/prisma/gallery.schema.prisma +62 -0
  77. package/prisma/migrations/2026-05-24-enum-to-table.sql +65 -0
package/README.md ADDED
@@ -0,0 +1,484 @@
1
+ # @withwiz/gallery
2
+
3
+ **English** | [한국어](./README.ko.md)
4
+
5
+ Host-independent gallery module for Next.js 16 + Prisma 7. Self-contained admin UI, headless lightbox, drop-in API route factories.
6
+
7
+ ## Status
8
+
9
+ **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.
10
+
11
+ ## Features
12
+
13
+ - **Headless** — drop into any Next.js host via `setGalleryConfig` (Prisma client / API wrapper / storage / revalidate / i18n / limits).
14
+ - **Admin UI** — 5 composite components (`GalleryAdminManager`, `GalleryEditForm`, `GalleryHomePreview`, `GalleryManagerLayout`, `CategoryAdminManager`) + 2 primitives (`ImageDropZone`, `ToggleSwitch`). Self-contained 3-pane layout.
15
+ - **API routes** — `createGalleryRoutes(config)` returns Next.js Route Handlers for 6 endpoint groups (collection / item / publish toggle / bulk / category collection / category item).
16
+ - **RSC loaders** — `getGalleryItems`, `getFeaturedGalleries`, `getRecentGalleries`, `getGalleryCount` for server components / dashboards.
17
+ - **Public preset** — `PublicGalleryMosaic` (presets/ballet) — 1–7 tile adaptive mosaic + lightbox.
18
+ - **Headless lightbox hook** — `useGalleryLightbox` with ESC / Arrow key bindings + wrap-around.
19
+ - **Image upload primitive** — `useImageDropZone` + `<ImageDropZone>` (host-side validate + accept/maxSize).
20
+ - **Storage-agnostic** — R2 / S3 / local filesystem — host injects via `config.storage`.
21
+ - **Host-model-name-agnostic** — the Prisma `Gallery` model makes no assumption about the host's User/Admin/Account model name. Only an `authorId: String` column.
22
+ - **Typed error hierarchy** — `GalleryNotFoundError`, `CategoryNotFoundError`, `CategoryInUseError`, `PermissionDeniedError` for the host to catch and handle.
23
+
24
+ ## Install
25
+
26
+ Currently consumed via `file:` reference inside the monorepo (not yet published to npm):
27
+
28
+ ```bash
29
+ # host root
30
+ npm install file:../node-packages/withwiz-gallery
31
+ ```
32
+
33
+ After publishing:
34
+
35
+ ```bash
36
+ npm install @withwiz/gallery
37
+ ```
38
+
39
+ ### Peer dependencies
40
+
41
+ | Package | Range |
42
+ |---|---|
43
+ | `next` | `>=16` |
44
+ | `react` / `react-dom` | `>=19` |
45
+ | `@prisma/client` | `>=7` |
46
+ | `zod` | `>=4` |
47
+ | `clsx` | `>=2` |
48
+ | `tailwind-merge` | `>=3` |
49
+ | `sonner` | `>=2` (optional) |
50
+
51
+ ## Quick start
52
+
53
+ ### 1. Merge the Prisma schema
54
+
55
+ Copy or symlink this package's partial schema into the host's `prisma/` directory.
56
+
57
+ ```jsonc
58
+ // host package.json
59
+ {
60
+ "prisma": { "schema": "./prisma" }
61
+ }
62
+ ```
63
+
64
+ ```bash
65
+ # in the host
66
+ cp node_modules/@withwiz/gallery/prisma/gallery.schema.prisma prisma/
67
+ npx prisma generate
68
+ npx prisma migrate dev --create-only
69
+ ```
70
+
71
+ The 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.
72
+
73
+ Migrating from a ballet-style enum-based schema? See [Migration from ballet enum-based schema](#migration-from-ballet-enum-based-schema) below.
74
+
75
+ ### 2. `setGalleryConfig` (host bootstrap)
76
+
77
+ **Call this at module top-level** — client components invoke `getGalleryConfig()` on mount.
78
+
79
+ ```ts
80
+ // host: lib/gallery-config.ts
81
+ import { setGalleryConfig } from "@withwiz/gallery/server";
82
+ import type { GalleryConfig } from "@withwiz/gallery/server";
83
+ import { prisma } from "./prisma";
84
+ import { withAdminApi } from "@/lib/api-middleware"; // host's auth wrapper
85
+ import { isR2Enabled, collectR2Keys, deleteR2Keys } from "@/lib/r2";
86
+ import { revalidatePath } from "next/cache";
87
+
88
+ export const galleryConfig: GalleryConfig = {
89
+ prisma,
90
+ apiWrapper: withAdminApi,
91
+ authorIdFromContext: (ctx) => ctx.user!.id,
92
+ limits: {
93
+ maxFeatured: 7,
94
+ mosaicCount: 7,
95
+ batchMax: 20,
96
+ captionMaxLength: 200,
97
+ },
98
+ revalidate: revalidatePath,
99
+ revalidatePaths: ["/", "/home/v1"],
100
+ storage: {
101
+ isEnabled: isR2Enabled,
102
+ collectKeys: collectR2Keys,
103
+ deleteKeys: deleteR2Keys,
104
+ },
105
+ i18n: {
106
+ "admin.title": "Gallery",
107
+ "admin.newButton": "New image",
108
+ "form.caption": "Caption",
109
+ "form.save": "Save",
110
+ "form.cancel": "Cancel",
111
+ // see @withwiz/gallery/types → GalleryI18nKey for the full key list
112
+ },
113
+ };
114
+
115
+ setGalleryConfig(galleryConfig);
116
+ ```
117
+
118
+ ### 3. Mount route handlers
119
+
120
+ Thin re-export per endpoint.
121
+
122
+ ```ts
123
+ // host: app/api/admin/galleries/route.ts
124
+ import { createGalleryRoutes } from "@withwiz/gallery/server";
125
+ import { galleryConfig } from "@/lib/gallery-config";
126
+
127
+ const { collection } = createGalleryRoutes(galleryConfig);
128
+ export const { GET, POST, DELETE } = collection;
129
+ ```
130
+
131
+ ```ts
132
+ // host: app/api/admin/galleries/[id]/route.ts
133
+ import { createGalleryRoutes } from "@withwiz/gallery/server";
134
+ import { galleryConfig } from "@/lib/gallery-config";
135
+
136
+ const { item } = createGalleryRoutes(galleryConfig);
137
+ export const { GET, PUT, DELETE } = item;
138
+ ```
139
+
140
+ ```ts
141
+ // host: app/api/admin/galleries/[id]/publish/route.ts
142
+ import { createGalleryRoutes } from "@withwiz/gallery/server";
143
+ import { galleryConfig } from "@/lib/gallery-config";
144
+
145
+ const { publishToggle } = createGalleryRoutes(galleryConfig);
146
+ export const { PATCH } = publishToggle;
147
+ ```
148
+
149
+ ```ts
150
+ // host: app/api/admin/galleries/bulk/route.ts
151
+ import { createGalleryRoutes } from "@withwiz/gallery/server";
152
+ import { galleryConfig } from "@/lib/gallery-config";
153
+
154
+ const { bulk } = createGalleryRoutes(galleryConfig);
155
+ export const { POST, PATCH } = bulk;
156
+ ```
157
+
158
+ ```ts
159
+ // host: app/api/admin/gallery-categories/route.ts
160
+ import { createGalleryRoutes } from "@withwiz/gallery/server";
161
+ import { galleryConfig } from "@/lib/gallery-config";
162
+
163
+ const { categoryCollection } = createGalleryRoutes(galleryConfig);
164
+ export const { GET, POST } = categoryCollection;
165
+ ```
166
+
167
+ ```ts
168
+ // host: app/api/admin/gallery-categories/[id]/route.ts
169
+ import { createGalleryRoutes } from "@withwiz/gallery/server";
170
+ import { galleryConfig } from "@/lib/gallery-config";
171
+
172
+ const { categoryItem } = createGalleryRoutes(galleryConfig);
173
+ export const { GET, PUT, DELETE } = categoryItem;
174
+ ```
175
+
176
+ ### 4. Mount the admin UI
177
+
178
+ ```tsx
179
+ // host: app/admin/galleries/page.tsx
180
+ import { GalleryAdminManager } from "@withwiz/gallery/components";
181
+ import "@withwiz/gallery/components/gallery.css";
182
+ import "@/lib/gallery-config"; // setGalleryConfig as a module side effect
183
+
184
+ export default function GalleryAdminPage() {
185
+ return <GalleryAdminManager />;
186
+ }
187
+ ```
188
+
189
+ ```tsx
190
+ // host: app/admin/galleries/new/page.tsx
191
+ import { GalleryAdminManager } from "@withwiz/gallery/components";
192
+ import "@/lib/gallery-config";
193
+
194
+ export default function GalleryNewPage() {
195
+ return <GalleryAdminManager initialMode="new" />;
196
+ }
197
+ ```
198
+
199
+ ```tsx
200
+ // host: app/admin/galleries/[id]/page.tsx
201
+ import { GalleryAdminManager } from "@withwiz/gallery/components";
202
+ import "@/lib/gallery-config";
203
+
204
+ export default async function GalleryEditPage({
205
+ params,
206
+ }: {
207
+ params: Promise<{ id: string }>;
208
+ }) {
209
+ const { id } = await params;
210
+ return <GalleryAdminManager initialSelectedId={id} />;
211
+ }
212
+ ```
213
+
214
+ ```tsx
215
+ // host: app/admin/gallery-categories/page.tsx
216
+ import { CategoryAdminManager } from "@withwiz/gallery/components";
217
+ import "@/lib/gallery-config";
218
+
219
+ export default function CategoryAdminPage() {
220
+ return <CategoryAdminManager />;
221
+ }
222
+ ```
223
+
224
+ ### 5. Public gallery (RSC + client)
225
+
226
+ ```tsx
227
+ // host: app/page.tsx (RSC)
228
+ import { PublicGalleryMosaic } from "@withwiz/gallery/presets/ballet";
229
+ import { getFeaturedGalleries } from "@withwiz/gallery/server";
230
+ import { galleryConfig } from "@/lib/gallery-config";
231
+ import "@withwiz/gallery/components/gallery.css";
232
+
233
+ export default async function HomePage() {
234
+ const featured = await getFeaturedGalleries(galleryConfig, 7);
235
+ const images = featured.map((g) => ({
236
+ src: g.imageUrl,
237
+ alt: g.caption ?? "Gallery",
238
+ }));
239
+ return (
240
+ <PublicGalleryMosaic
241
+ images={images}
242
+ count={7}
243
+ i18n={{ sectionLabel: "Gallery", moments: "Moments from the stage" }}
244
+ />
245
+ );
246
+ }
247
+ ```
248
+
249
+ ## API reference
250
+
251
+ ### `@withwiz/gallery` (main entry)
252
+
253
+ ```ts
254
+ import {
255
+ setGalleryConfig,
256
+ getGalleryConfig,
257
+ cn,
258
+ getVariantUrl,
259
+ GalleryError,
260
+ GalleryNotFoundError,
261
+ CategoryNotFoundError,
262
+ CategoryInUseError,
263
+ PermissionDeniedError,
264
+ } from "@withwiz/gallery";
265
+ ```
266
+
267
+ ### `@withwiz/gallery/server`
268
+
269
+ | Export | Signature |
270
+ |---|---|
271
+ | `setGalleryConfig(config)` | DI setter — call once at module top-level |
272
+ | `getGalleryConfig()` | Returns the registered config (throws if unset) |
273
+ | `createGalleryService(config)` | CRUD object (`list` / `get` / `create` / `update` / `remove` / `bulk*`) |
274
+ | `createCategoryService(config)` | Category CRUD (`list` / `getBySlug` / `create` / `update` / `remove` / `reorder`) |
275
+ | `createGalleryRoutes(config)` | 6 Next.js Route Handler groups (`collection` / `item` / `publishToggle` / `bulk` / `categoryCollection` / `categoryItem`) |
276
+ | `getGalleryItems(config, opts)` | RSC pagination loader |
277
+ | `getFeaturedGalleries(config, limit?)` | RSC featured list |
278
+ | `getRecentGalleries(config, limit)` | RSC recent items (dashboard) |
279
+ | `getGalleryCount(config, opts?)` | RSC count (dashboard) |
280
+ | `buildPaginatedResult(items, page, limit, total)` | helper |
281
+
282
+ Typed errors (also re-exported from the server entry):
283
+
284
+ - `GalleryError` (base)
285
+ - `GalleryNotFoundError`
286
+ - `CategoryNotFoundError`
287
+ - `CategoryInUseError`
288
+ - `PermissionDeniedError`
289
+
290
+ ### `@withwiz/gallery/components`
291
+
292
+ | Export | Description |
293
+ |---|---|
294
+ | `GalleryAdminManager` | 3-pane admin mount point (`initialMode?` / `initialSelectedId?`) |
295
+ | `GalleryManagerLayout` | 3-pane primitive (left = list / center = form / right = preview) |
296
+ | `GalleryEditForm` | Single/multi form (`value` / `multipleMode` / `onSubmit` / `onSubmitMany`) |
297
+ | `GalleryHomePreview` | 7-tile mosaic + drag reorder + featured toggle |
298
+ | `CategoryAdminManager` | Category CRUD UI |
299
+ | `ImageDropZone` | Image dropzone (`accept` / `maxSize` / `multiple` / `disabled` / `validate`) |
300
+ | `ToggleSwitch` | size sm/md/lg, ARIA switch role |
301
+
302
+ CSS (host imports explicitly):
303
+
304
+ ```ts
305
+ import "@withwiz/gallery/components/gallery.css";
306
+ ```
307
+
308
+ ### `@withwiz/gallery/hooks`
309
+
310
+ | Export | Signature |
311
+ |---|---|
312
+ | `useGalleryLightbox(images)` | `{ isOpen, currentIndex, current, open, close, next, prev }` — auto-bound ESC / Arrow, wrap-around |
313
+ | `useImageDropZone(opts)` | Headless dropzone — `inputProps / containerProps / files / rejectedReasons / clear` |
314
+ | `useScrollReveal(opts?)` | IntersectionObserver-based fade-in — `{ ref, isVisible }` |
315
+
316
+ ### `@withwiz/gallery/validators`
317
+
318
+ ```ts
319
+ import { createGallerySchemas } from "@withwiz/gallery/validators";
320
+
321
+ const schemas = createGallerySchemas({
322
+ batchMax: 20,
323
+ captionMaxLength: 200,
324
+ });
325
+ // schemas.CreateGallerySchema / UpdateGallerySchema / BatchCreateGallerySchema
326
+ // / BulkUpdateSchema / CreateCategorySchema / UpdateCategorySchema / ReorderCategorySchema
327
+ ```
328
+
329
+ ### `@withwiz/gallery/types`
330
+
331
+ ```ts
332
+ import type {
333
+ GalleryConfig,
334
+ GalleryI18nKey,
335
+ GallerySlotName,
336
+ GalleryListItem,
337
+ GalleryDetail,
338
+ GalleryCategoryItem,
339
+ CreateGalleryInput,
340
+ UpdateGalleryInput,
341
+ CreateCategoryInput,
342
+ UpdateCategoryInput,
343
+ PaginatedResult,
344
+ PaginationMeta,
345
+ SortOrder,
346
+ PrismaLike,
347
+ ApiContext,
348
+ RouteHandler,
349
+ ApiWrapper,
350
+ } from "@withwiz/gallery/types";
351
+ ```
352
+
353
+ ### `@withwiz/gallery/presets/ballet`
354
+
355
+ ```ts
356
+ import { PublicGalleryMosaic } from "@withwiz/gallery/presets/ballet";
357
+ import type { PublicGalleryMosaicProps } from "@withwiz/gallery/presets/ballet";
358
+ ```
359
+
360
+ Signature:
361
+
362
+ ```ts
363
+ function PublicGalleryMosaic(props: {
364
+ images: { src: string; alt?: string }[];
365
+ count?: number; // default 7
366
+ i18n?: {
367
+ sectionLabel?: string;
368
+ moments?: string;
369
+ expandAria?: string;
370
+ lbClose?: string;
371
+ lbPrev?: string;
372
+ lbNext?: string;
373
+ };
374
+ className?: string;
375
+ scrollReveal?: boolean; // default true
376
+ hideHeader?: boolean; // default false
377
+ }): JSX.Element | null;
378
+ ```
379
+
380
+ ## Host-independence guarantees
381
+
382
+ - **Zero `@withwiz/pms` imports** — this package does not depend on any domain package.
383
+ - **Zero direct `process.env` reads** — environment variables are read by the host and bound into `config.storage`, etc.
384
+ - **No assumption about the host's User model name** — the Prisma `Gallery` model has only an `authorId: String` column and no `@relation`.
385
+ - **All infrastructure is injected via `GalleryConfig`** — Prisma client / API wrapper / storage / revalidate / i18n / limits.
386
+ - **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.
387
+ - **`presets/ballet` does not import `next/image`, `next/navigation`, or `useI18n`** — images use plain `<img>`, labels come from props.
388
+
389
+ ## CSS customization
390
+
391
+ Override the CSS variables from the host's root CSS.
392
+
393
+ ```css
394
+ /* host: app/globals.css */
395
+ :root {
396
+ --gallery-accent: 212 175 55; /* RGB triplet (NOT hex) */
397
+ --gallery-bg: 10 10 10;
398
+ --gallery-fg: 254 254 254;
399
+ --gallery-border: 30 30 30;
400
+ }
401
+ ```
402
+
403
+ 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.
404
+
405
+ The admin components also support per-slot customization via `config.ui.classNames[slot]` / `config.ui.slots[slot]`.
406
+
407
+ ## Migration from ballet enum-based schema
408
+
409
+ ballet's existing schema:
410
+
411
+ ```prisma
412
+ enum GalleryCategory { PERFORMANCE | REHEARSAL | ACTIVITY | ARTIST }
413
+ model Gallery {
414
+ category GalleryCategory
415
+ // ...
416
+ }
417
+ ```
418
+
419
+ This package's new schema replaces the enum with a `gallery_categories` table + a `Gallery.categoryId` FK. Migration steps:
420
+
421
+ 1. Merge this package's `gallery.schema.prisma` into the host's `prisma/` (copy or symlink).
422
+ 2. Run `npx prisma migrate dev --create-only` to auto-generate the migration SQL.
423
+ 3. Manually remove `DROP COLUMN category` / `DROP TYPE "GalleryCategory"` from the generated SQL (separate them into a follow-up migration).
424
+ 4. Insert the contents of `prisma/migrations/2026-05-24-enum-to-table.sql` (shipped with this package) at that location — seeds the 4 categories, backfills `galleries.category_id`, and includes a PL/pgSQL verification block.
425
+ 5. `npx prisma migrate dev` to apply.
426
+ 6. Once verification passes, run the second migration (separate file) to `DROP` the enum column and type.
427
+
428
+ Reference the shipped `prisma/migrations/2026-05-24-enum-to-table.sql` as-is.
429
+
430
+ 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`.
431
+
432
+ ## Architecture
433
+
434
+ ```
435
+ @withwiz/toolkit (lowest)
436
+
437
+ @withwiz/pms (performance-management domain CMS)
438
+ ↑ ↑
439
+ │ │ consumed as a host
440
+ host app (ballet, yeroom) │
441
+ ↓ │
442
+ └─→ @withwiz/gallery ┘ (this package — depends on no host)
443
+ ```
444
+
445
+ `@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.
446
+
447
+ ## Repository layout
448
+
449
+ ```
450
+ node-packages/withwiz-gallery/
451
+ ├── package.json
452
+ ├── tsup.config.ts
453
+ ├── README.md
454
+ ├── prisma/
455
+ │ ├── gallery.schema.prisma # GalleryCategory + Gallery partial models
456
+ │ └── migrations/
457
+ │ └── 2026-05-24-enum-to-table.sql # ballet enum → table backfill
458
+ ├── docs/superpowers/{specs,plans}/ # design docs + sprint contracts/reports
459
+ └── src/
460
+ ├── index.ts # main entry — config, types, errors, utils
461
+ ├── config.ts
462
+ ├── errors.ts
463
+ ├── types/ # GalleryConfig / domain / pagination / api-context / i18n
464
+ ├── validators/ # createGallerySchemas
465
+ ├── services/ # createGalleryService / createCategoryService / helpers
466
+ ├── server/ # createGalleryRoutes + loaders + server re-exports
467
+ ├── components/ # admin UI + ImageDropZone + ToggleSwitch + gallery.css
468
+ ├── hooks/ # useGalleryLightbox / useImageDropZone / useScrollReveal
469
+ ├── utils/ # cn / image-variants / api-helpers
470
+ └── presets/
471
+ └── ballet.tsx # PublicGalleryMosaic
472
+ ```
473
+
474
+ ## Scripts
475
+
476
+ ```bash
477
+ npm run build # tsup multi-entry CJS/ESM/DTS
478
+ npm test # vitest run (221 tests, 23 files)
479
+ npm run test:watch # vitest watch
480
+ ```
481
+
482
+ ## License
483
+
484
+ MIT
@@ -0,0 +1,79 @@
1
+ // src/hooks/useScrollReveal.ts
2
+ import { useEffect, useRef, useState } from "react";
3
+ function useScrollReveal(opts) {
4
+ const threshold = opts?.threshold ?? 0.1;
5
+ const once = opts?.once ?? true;
6
+ const ref = useRef(null);
7
+ const [isVisible, setIsVisible] = useState(false);
8
+ useEffect(() => {
9
+ if (typeof window === "undefined") return;
10
+ if (typeof globalThis.IntersectionObserver === "undefined") {
11
+ return;
12
+ }
13
+ const el = ref.current;
14
+ if (!el) return;
15
+ const observer = new IntersectionObserver(
16
+ (entries) => {
17
+ for (const entry of entries) {
18
+ if (entry.isIntersecting) {
19
+ setIsVisible(true);
20
+ if (once) observer.unobserve(entry.target);
21
+ } else if (!once) {
22
+ setIsVisible(false);
23
+ }
24
+ }
25
+ },
26
+ { threshold }
27
+ );
28
+ observer.observe(el);
29
+ return () => observer.disconnect();
30
+ }, [threshold, once]);
31
+ return { ref, isVisible };
32
+ }
33
+
34
+ // src/hooks/useGalleryLightbox.ts
35
+ import { useCallback, useEffect as useEffect2, useState as useState2 } from "react";
36
+ function useGalleryLightbox(images) {
37
+ const [currentIndex, setCurrentIndex] = useState2(-1);
38
+ const isOpen = currentIndex >= 0 && currentIndex < images.length;
39
+ const current = isOpen ? images[currentIndex] ?? null : null;
40
+ const open = useCallback(
41
+ (index) => {
42
+ if (images.length === 0) return;
43
+ if (index < 0 || index >= images.length) return;
44
+ setCurrentIndex(index);
45
+ },
46
+ [images.length]
47
+ );
48
+ const close = useCallback(() => {
49
+ setCurrentIndex(-1);
50
+ }, []);
51
+ const next = useCallback(() => {
52
+ if (images.length === 0) return;
53
+ setCurrentIndex((prev2) => prev2 < 0 ? prev2 : (prev2 + 1) % images.length);
54
+ }, [images.length]);
55
+ const prev = useCallback(() => {
56
+ if (images.length === 0) return;
57
+ setCurrentIndex((p) => p < 0 ? p : (p - 1 + images.length) % images.length);
58
+ }, [images.length]);
59
+ useEffect2(() => {
60
+ if (!isOpen) return;
61
+ if (typeof window === "undefined") return;
62
+ const handler = (e) => {
63
+ if (e.key === "Escape") close();
64
+ else if (e.key === "ArrowLeft") prev();
65
+ else if (e.key === "ArrowRight") next();
66
+ };
67
+ window.addEventListener("keydown", handler);
68
+ return () => {
69
+ window.removeEventListener("keydown", handler);
70
+ };
71
+ }, [isOpen, close, next, prev]);
72
+ return { isOpen, currentIndex: isOpen ? currentIndex : -1, current, open, close, next, prev };
73
+ }
74
+
75
+ export {
76
+ useScrollReveal,
77
+ useGalleryLightbox
78
+ };
79
+ //# sourceMappingURL=chunk-B3MQL6ZR.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hooks/useScrollReveal.ts","../src/hooks/useGalleryLightbox.ts"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\n\n/** IntersectionObserver 기반 스크롤 reveal hook.\n *\n * - SSR safe: `typeof window === \"undefined\"` 이면 effect 가 nothing.\n * - jsdom 등 `IntersectionObserver` 미정의 환경에서도 effect 가 nothing (가드).\n * - `once: true` (default) — 한번 visible 후 unobserve, 이후 다시 false 로 돌아가지 않음.\n * - `once: false` — visible 진입/이탈에 따라 toggle.\n *\n * 반환되는 ref 는 React 19 의 `RefObject<HTMLElement | null>` 시그니처 — host 가 어떤\n * 구체 element 타입에든 마운트 가능하도록 광폭 타입을 둔다.\n */\nexport function useScrollReveal(opts?: { threshold?: number; once?: boolean }): {\n ref: React.RefObject<HTMLElement | null>;\n isVisible: boolean;\n} {\n const threshold = opts?.threshold ?? 0.1;\n const once = opts?.once ?? true;\n const ref = useRef<HTMLElement | null>(null);\n const [isVisible, setIsVisible] = useState(false);\n\n useEffect(() => {\n if (typeof window === \"undefined\") return;\n if (typeof (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver === \"undefined\") {\n return;\n }\n const el = ref.current;\n if (!el) return;\n\n const observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n setIsVisible(true);\n if (once) observer.unobserve(entry.target);\n } else if (!once) {\n setIsVisible(false);\n }\n }\n },\n { threshold },\n );\n\n observer.observe(el);\n return () => observer.disconnect();\n }, [threshold, once]);\n\n return { ref, isVisible };\n}\n","import { useCallback, useEffect, useState } from \"react\";\n\nexport interface LightboxImage {\n src: string;\n alt?: string;\n}\n\nexport interface UseGalleryLightboxReturn {\n isOpen: boolean;\n currentIndex: number;\n current: LightboxImage | null;\n open: (index: number) => void;\n close: () => void;\n next: () => void;\n prev: () => void;\n}\n\n/** headless 갤러리 라이트박스. open 상태에서 ESC / ←/→ 키바인딩 자동 등록.\n *\n * - 이미지 0개일 때 `open` 호출은 no-op.\n * - `next` / `prev` 는 wrap-around (마지막 다음 = 처음).\n * - SSR safe: window keydown listener 는 isOpen && typeof window !== \"undefined\" 일 때만 등록.\n */\nexport function useGalleryLightbox(images: LightboxImage[]): UseGalleryLightboxReturn {\n const [currentIndex, setCurrentIndex] = useState(-1);\n const isOpen = currentIndex >= 0 && currentIndex < images.length;\n const current = isOpen ? images[currentIndex] ?? null : null;\n\n const open = useCallback(\n (index: number) => {\n if (images.length === 0) return;\n if (index < 0 || index >= images.length) return;\n setCurrentIndex(index);\n },\n [images.length],\n );\n\n const close = useCallback(() => {\n setCurrentIndex(-1);\n }, []);\n\n const next = useCallback(() => {\n if (images.length === 0) return;\n setCurrentIndex((prev) => (prev < 0 ? prev : (prev + 1) % images.length));\n }, [images.length]);\n\n const prev = useCallback(() => {\n if (images.length === 0) return;\n setCurrentIndex((p) => (p < 0 ? p : (p - 1 + images.length) % images.length));\n }, [images.length]);\n\n useEffect(() => {\n if (!isOpen) return;\n if (typeof window === \"undefined\") return;\n const handler = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") close();\n else if (e.key === \"ArrowLeft\") prev();\n else if (e.key === \"ArrowRight\") next();\n };\n window.addEventListener(\"keydown\", handler);\n return () => {\n window.removeEventListener(\"keydown\", handler);\n };\n }, [isOpen, close, next, prev]);\n\n return { isOpen, currentIndex: isOpen ? currentIndex : -1, current, open, close, next, prev };\n}\n"],"mappings":";AAAA,SAAS,WAAW,QAAQ,gBAAgB;AAYrC,SAAS,gBAAgB,MAG9B;AACA,QAAM,YAAY,MAAM,aAAa;AACrC,QAAM,OAAO,MAAM,QAAQ;AAC3B,QAAM,MAAM,OAA2B,IAAI;AAC3C,QAAM,CAAC,WAAW,YAAY,IAAI,SAAS,KAAK;AAEhD,YAAU,MAAM;AACd,QAAI,OAAO,WAAW,YAAa;AACnC,QAAI,OAAQ,WAAkD,yBAAyB,aAAa;AAClG;AAAA,IACF;AACA,UAAM,KAAK,IAAI;AACf,QAAI,CAAC,GAAI;AAET,UAAM,WAAW,IAAI;AAAA,MACnB,CAAC,YAAY;AACX,mBAAW,SAAS,SAAS;AAC3B,cAAI,MAAM,gBAAgB;AACxB,yBAAa,IAAI;AACjB,gBAAI,KAAM,UAAS,UAAU,MAAM,MAAM;AAAA,UAC3C,WAAW,CAAC,MAAM;AAChB,yBAAa,KAAK;AAAA,UACpB;AAAA,QACF;AAAA,MACF;AAAA,MACA,EAAE,UAAU;AAAA,IACd;AAEA,aAAS,QAAQ,EAAE;AACnB,WAAO,MAAM,SAAS,WAAW;AAAA,EACnC,GAAG,CAAC,WAAW,IAAI,CAAC;AAEpB,SAAO,EAAE,KAAK,UAAU;AAC1B;;;AChDA,SAAS,aAAa,aAAAA,YAAW,YAAAC,iBAAgB;AAuB1C,SAAS,mBAAmB,QAAmD;AACpF,QAAM,CAAC,cAAc,eAAe,IAAIA,UAAS,EAAE;AACnD,QAAM,SAAS,gBAAgB,KAAK,eAAe,OAAO;AAC1D,QAAM,UAAU,SAAS,OAAO,YAAY,KAAK,OAAO;AAExD,QAAM,OAAO;AAAA,IACX,CAAC,UAAkB;AACjB,UAAI,OAAO,WAAW,EAAG;AACzB,UAAI,QAAQ,KAAK,SAAS,OAAO,OAAQ;AACzC,sBAAgB,KAAK;AAAA,IACvB;AAAA,IACA,CAAC,OAAO,MAAM;AAAA,EAChB;AAEA,QAAM,QAAQ,YAAY,MAAM;AAC9B,oBAAgB,EAAE;AAAA,EACpB,GAAG,CAAC,CAAC;AAEL,QAAM,OAAO,YAAY,MAAM;AAC7B,QAAI,OAAO,WAAW,EAAG;AACzB,oBAAgB,CAACC,UAAUA,QAAO,IAAIA,SAAQA,QAAO,KAAK,OAAO,MAAO;AAAA,EAC1E,GAAG,CAAC,OAAO,MAAM,CAAC;AAElB,QAAM,OAAO,YAAY,MAAM;AAC7B,QAAI,OAAO,WAAW,EAAG;AACzB,oBAAgB,CAAC,MAAO,IAAI,IAAI,KAAK,IAAI,IAAI,OAAO,UAAU,OAAO,MAAO;AAAA,EAC9E,GAAG,CAAC,OAAO,MAAM,CAAC;AAElB,EAAAF,WAAU,MAAM;AACd,QAAI,CAAC,OAAQ;AACb,QAAI,OAAO,WAAW,YAAa;AACnC,UAAM,UAAU,CAAC,MAAqB;AACpC,UAAI,EAAE,QAAQ,SAAU,OAAM;AAAA,eACrB,EAAE,QAAQ,YAAa,MAAK;AAAA,eAC5B,EAAE,QAAQ,aAAc,MAAK;AAAA,IACxC;AACA,WAAO,iBAAiB,WAAW,OAAO;AAC1C,WAAO,MAAM;AACX,aAAO,oBAAoB,WAAW,OAAO;AAAA,IAC/C;AAAA,EACF,GAAG,CAAC,QAAQ,OAAO,MAAM,IAAI,CAAC;AAE9B,SAAO,EAAE,QAAQ,cAAc,SAAS,eAAe,IAAI,SAAS,MAAM,OAAO,MAAM,KAAK;AAC9F;","names":["useEffect","useState","prev"]}
@@ -0,0 +1,122 @@
1
+ // src/hooks/useImageDropZone.ts
2
+ import { useCallback, useRef, useState } from "react";
3
+ var DEFAULT_ACCEPT = ["image/jpeg", "image/png", "image/webp"];
4
+ var DEFAULT_MAX_SIZE = 10 * 1024 * 1024;
5
+ function useImageDropZone(opts) {
6
+ const {
7
+ onFiles,
8
+ accept = DEFAULT_ACCEPT,
9
+ maxSize = DEFAULT_MAX_SIZE,
10
+ multiple = true,
11
+ validate,
12
+ disabled = false
13
+ } = opts;
14
+ const [isDragging, setIsDragging] = useState(false);
15
+ const [rejectedReasons, setRejectedReasons] = useState([]);
16
+ const dragCounter = useRef(0);
17
+ const processFiles = useCallback(
18
+ (incoming) => {
19
+ if (disabled) return;
20
+ const accepted = [];
21
+ const rejected = [];
22
+ const slice = multiple ? incoming : incoming.slice(0, 1);
23
+ for (const file of slice) {
24
+ if (accept.length > 0 && !accept.includes(file.type)) {
25
+ rejected.push(`${file.name}: \uC9C0\uC6D0\uD558\uC9C0 \uC54A\uB294 \uD615\uC2DD (${file.type || "unknown"})`);
26
+ continue;
27
+ }
28
+ if (file.size > maxSize) {
29
+ rejected.push(
30
+ `${file.name}: \uD30C\uC77C \uD06C\uAE30 \uCD08\uACFC (${(file.size / 1024 / 1024).toFixed(1)}MB > ${(maxSize / 1024 / 1024).toFixed(1)}MB)`
31
+ );
32
+ continue;
33
+ }
34
+ if (validate) {
35
+ const result = validate(file);
36
+ if (!result.ok) {
37
+ rejected.push(`${file.name}: ${result.reason}`);
38
+ continue;
39
+ }
40
+ }
41
+ accepted.push(file);
42
+ }
43
+ setRejectedReasons(rejected);
44
+ if (accepted.length > 0) {
45
+ void onFiles(accepted);
46
+ }
47
+ },
48
+ [accept, maxSize, multiple, validate, disabled, onFiles]
49
+ );
50
+ const onDragEnter = useCallback(
51
+ (e) => {
52
+ e.preventDefault();
53
+ e.stopPropagation();
54
+ if (disabled) return;
55
+ dragCounter.current += 1;
56
+ const types = e.dataTransfer?.types;
57
+ if (!types || (typeof types.includes === "function" ? types.includes("Files") : Array.from(types).includes("Files"))) {
58
+ setIsDragging(true);
59
+ }
60
+ },
61
+ [disabled]
62
+ );
63
+ const onDragOver = useCallback(
64
+ (e) => {
65
+ e.preventDefault();
66
+ e.stopPropagation();
67
+ },
68
+ []
69
+ );
70
+ const onDragLeave = useCallback(
71
+ (e) => {
72
+ e.preventDefault();
73
+ e.stopPropagation();
74
+ if (disabled) return;
75
+ dragCounter.current = Math.max(0, dragCounter.current - 1);
76
+ if (dragCounter.current === 0) {
77
+ setIsDragging(false);
78
+ }
79
+ },
80
+ [disabled]
81
+ );
82
+ const onDrop = useCallback(
83
+ (e) => {
84
+ e.preventDefault();
85
+ e.stopPropagation();
86
+ dragCounter.current = 0;
87
+ setIsDragging(false);
88
+ if (disabled) return;
89
+ const incoming = Array.from(e.dataTransfer?.files ?? []);
90
+ processFiles(incoming);
91
+ },
92
+ [disabled, processFiles]
93
+ );
94
+ const onChange = useCallback(
95
+ (e) => {
96
+ if (disabled) return;
97
+ const incoming = Array.from(e.target.files ?? []);
98
+ processFiles(incoming);
99
+ try {
100
+ e.target.value = "";
101
+ } catch {
102
+ }
103
+ },
104
+ [disabled, processFiles]
105
+ );
106
+ return {
107
+ isDragging,
108
+ dropProps: { onDragEnter, onDragOver, onDragLeave, onDrop },
109
+ inputProps: {
110
+ type: "file",
111
+ accept: accept.join(","),
112
+ multiple,
113
+ onChange
114
+ },
115
+ rejectedReasons
116
+ };
117
+ }
118
+
119
+ export {
120
+ useImageDropZone
121
+ };
122
+ //# sourceMappingURL=chunk-BC3TPJQY.mjs.map