@jimmy_harika/websitekit 0.5.0 → 0.5.1

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
@@ -7,7 +7,29 @@ verticals land here and upgrade all consumers at once.
7
7
 
8
8
  This is the **single home** for the builder: the engine, a design-system-agnostic
9
9
  block library, and per-industry catalogs all live in this repo and ship as one
10
- private package.
10
+ public package (`@jimmy_harika/websitekit`).
11
+
12
+ ## Consumers (keep them in sync)
13
+
14
+ | App | Repo | Job |
15
+ |---|---|---|
16
+ | **pixbox** studio Website tool | `convex-pixbox` (`apps/pixbox`) | Multi-page photographer portfolio |
17
+ | **guestcard** event websites | `convex-pixbox` (`apps/invite`) | Per-event invite / event site |
18
+ | Future (authors, vendors, …) | other apps | Same engine, own catalog + persistence |
19
+
20
+ **Release path (every shared fix/feature):**
21
+
22
+ 1. Land change in **this repo** (`src/`) — not in a consumer’s `node_modules` fork.
23
+ 2. `bun run typecheck` → `npm version patch|minor|major` → `npm publish`.
24
+ 3. In **each** consumer monorepo, bump `"@jimmy_harika/websitekit": "^x.y.z"` on **all** apps that depend on it (pixbox + invite together), then `bun install`.
25
+
26
+ **Local dual-dev** (iterate without publishing every minute): from the consumer monorepo root,
27
+
28
+ ```bash
29
+ bun add @jimmy_harika/websitekit@link:../websitekit --filter <app>
30
+ ```
31
+
32
+ Restore the published `^x.y.z` before merge. App-only glue (Convex adapters, plan gates, SEO Manager UI, image upload) stays in the consumer — never pull app UI or Convex into this package.
11
33
 
12
34
  ## Why custom (not Puck / GrapesJS)
13
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jimmy_harika/websitekit",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Snow Labs website builder engine. Block-agnostic, curated-section. One shared builder across every Snow Labs app — engine + design-system-agnostic blocks + per-industry catalogs (author, photographer, …). Each app plugs in a catalog + theme + persistence adapter.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -292,11 +292,12 @@ function ImageField({
292
292
  if (blobRef.current) URL.revokeObjectURL(blobRef.current);
293
293
  };
294
294
  }, []);
295
- async function pick(e: ChangeEvent<HTMLInputElement>) {
296
- const file = e.target.files?.[0];
297
- e.target.value = "";
298
- if (!file) return;
295
+ const [err, setErr] = useState<string | null>(null);
296
+ const [dragOver, setDragOver] = useState(false);
297
+
298
+ async function uploadFile(file: File) {
299
299
  setBusy(true);
300
+ setErr(null);
300
301
  try {
301
302
  const usedFallback = !caps.uploadImage;
302
303
  const url = caps.uploadImage
@@ -307,18 +308,57 @@ function ImageField({
307
308
  blobRef.current = url;
308
309
  }
309
310
  onChange(url);
311
+ } catch (e) {
312
+ setErr(e instanceof Error ? e.message : "Upload failed");
310
313
  } finally {
311
314
  setBusy(false);
312
315
  }
313
316
  }
317
+
318
+ async function pick(e: ChangeEvent<HTMLInputElement>) {
319
+ const file = e.target.files?.[0];
320
+ e.target.value = "";
321
+ if (!file) return;
322
+ await uploadFile(file);
323
+ }
314
324
  return (
315
325
  <div className="space-y-2">
316
326
  {value ? (
317
327
  // eslint-disable-next-line @next/next/no-img-element
318
- <img src={value} alt={altValue ?? ""} className="h-24 w-full rounded-md object-cover" />
328
+ <img
329
+ src={value}
330
+ alt={altValue ?? ""}
331
+ className={`h-24 w-full rounded-md object-cover ${dragOver ? "ring-2 ring-sky-400" : ""}`}
332
+ onDragOver={(e) => {
333
+ e.preventDefault();
334
+ setDragOver(true);
335
+ }}
336
+ onDragLeave={() => setDragOver(false)}
337
+ onDrop={(e) => {
338
+ e.preventDefault();
339
+ setDragOver(false);
340
+ const f = e.dataTransfer.files?.[0];
341
+ if (f) void uploadFile(f);
342
+ }}
343
+ />
319
344
  ) : (
320
- <div className="flex h-24 items-center justify-center rounded-md border border-dashed text-xs text-muted-foreground/70">
321
- No image
345
+ <div
346
+ className={`flex h-24 items-center justify-center rounded-md border border-dashed text-xs text-muted-foreground/70 ${
347
+ dragOver ? "border-sky-400 bg-sky-400/10" : ""
348
+ }`}
349
+ onDragOver={(e) => {
350
+ e.preventDefault();
351
+ setDragOver(true);
352
+ }}
353
+ onDragLeave={() => setDragOver(false)}
354
+ onDrop={(e) => {
355
+ e.preventDefault();
356
+ setDragOver(false);
357
+ const f = e.dataTransfer.files?.[0];
358
+ if (f) void uploadFile(f);
359
+ }}
360
+ >
361
+ {busy ? "Uploading…" : "No image — click or drop"}
322
362
  </div>
323
363
  )}
324
364
  <div className="flex gap-2">
@@ -337,7 +377,8 @@ function ImageField({
337
377
  </button>
338
378
  )}
339
379
  </div>
340
- <input ref={ref} type="file" accept="image/*" hidden onChange={pick} />
380
+ {err && <p className="text-[10px] text-red-600 dark:text-red-400">{err}</p>}
381
+ <input ref={ref} type="file" accept="image/*,.heic,.heif,image/heic,image/heif" hidden onChange={pick} />
341
382
  <input
342
383
  value={value}
343
384
  onChange={(e) => onChange(e.target.value)}
@@ -376,12 +417,16 @@ function useImageUploads() {
376
417
  try {
377
418
  const urls: string[] = [];
378
419
  for (const file of files) {
379
- if (caps.uploadImage) {
380
- urls.push(await caps.uploadImage(file));
381
- } else {
382
- const u = URL.createObjectURL(file);
383
- blobsRef.current.push(u);
384
- urls.push(u);
420
+ try {
421
+ if (caps.uploadImage) {
422
+ urls.push(await caps.uploadImage(file));
423
+ } else {
424
+ const u = URL.createObjectURL(file);
425
+ blobsRef.current.push(u);
426
+ urls.push(u);
427
+ }
428
+ } catch {
429
+ // Skip failed files; caller can show partial results
385
430
  }
386
431
  }
387
432
  return urls;
@@ -415,14 +460,20 @@ function ImageListField({
415
460
  const [urlDraft, setUrlDraft] = useState("");
416
461
  const room = max ? Math.max(0, max - value.length) : Infinity;
417
462
 
418
- async function pick(e: ChangeEvent<HTMLInputElement>) {
419
- const files = Array.from(e.target.files ?? []);
420
- e.target.value = "";
463
+ const [dragOver, setDragOver] = useState(false);
464
+
465
+ async function addFiles(files: File[]) {
421
466
  if (!files.length) return;
422
467
  const urls = await upload(max ? files.slice(0, room) : files);
423
468
  if (urls.length) onChange([...value, ...urls]);
424
469
  }
425
470
 
471
+ async function pick(e: ChangeEvent<HTMLInputElement>) {
472
+ const files = Array.from(e.target.files ?? []);
473
+ e.target.value = "";
474
+ await addFiles(files);
475
+ }
476
+
426
477
  return (
427
478
  <div className="space-y-2">
428
479
  {value.length > 0 ? (
@@ -462,18 +513,51 @@ function ImageListField({
462
513
  ))}
463
514
  </div>
464
515
  ) : (
465
- <div className="flex h-16 items-center justify-center rounded-md border border-dashed text-xs text-muted-foreground/70">
466
- No images yet
516
+ <div
517
+ className={`flex h-16 items-center justify-center rounded-md border border-dashed text-xs text-muted-foreground/70 ${
518
+ dragOver ? "border-sky-400 bg-sky-400/10" : ""
519
+ }`}
520
+ onDragOver={(e) => {
521
+ e.preventDefault();
522
+ setDragOver(true);
523
+ }}
524
+ onDragLeave={() => setDragOver(false)}
525
+ onDrop={(e) => {
526
+ e.preventDefault();
527
+ setDragOver(false);
528
+ void addFiles(Array.from(e.dataTransfer.files ?? []));
529
+ }}
530
+ >
531
+ No images yet — drop here
467
532
  </div>
468
533
  )}
469
534
  <button
470
535
  onClick={() => ref.current?.click()}
471
536
  disabled={busy || room === 0}
472
- className="w-full rounded-md border px-2 py-1.5 text-xs hover:bg-muted disabled:opacity-50"
537
+ className={`w-full rounded-md border px-2 py-1.5 text-xs hover:bg-muted disabled:opacity-50 ${
538
+ dragOver ? "border-sky-400" : ""
539
+ }`}
540
+ onDragOver={(e) => {
541
+ e.preventDefault();
542
+ setDragOver(true);
543
+ }}
544
+ onDragLeave={() => setDragOver(false)}
545
+ onDrop={(e) => {
546
+ e.preventDefault();
547
+ setDragOver(false);
548
+ void addFiles(Array.from(e.dataTransfer.files ?? []));
549
+ }}
473
550
  >
474
- {busy ? "Uploading…" : room === 0 ? `Limit of ${max} reached` : "Add images"}
551
+ {busy ? "Uploading…" : room === 0 ? `Limit of ${max} reached` : "Add images (click or drop)"}
475
552
  </button>
476
- <input ref={ref} type="file" accept="image/*" multiple hidden onChange={pick} />
553
+ <input
554
+ ref={ref}
555
+ type="file"
556
+ accept="image/*,.heic,.heif,image/heic,image/heif"
557
+ multiple
558
+ hidden
559
+ onChange={pick}
560
+ />
477
561
  <div className="flex gap-1.5">
478
562
  <input
479
563
  value={urlDraft}
@@ -86,12 +86,17 @@ function EditableText({
86
86
  );
87
87
  }
88
88
 
89
+ const IMAGE_ACCEPT =
90
+ "image/*,.heic,.heif,image/heic,image/heif";
91
+
89
92
  function EditableImage({ field, src, alt, className, style, width, height, priority }: ImageEditProps) {
90
93
  const sectionId = useContext(EditSectionContext);
91
94
  const updateProps = useBuilder((s) => s.updateProps);
92
95
  const caps = useContext(EditorCapsContext);
93
96
  const inputRef = useRef<HTMLInputElement>(null);
94
97
  const [busy, setBusy] = useState(false);
98
+ const [err, setErr] = useState<string | null>(null);
99
+ const [dragOver, setDragOver] = useState(false);
95
100
  // Track object URLs we minted so we can revoke them (avoid leaks on replace/unmount).
96
101
  const blobRef = useRef<string | null>(null);
97
102
  useEffect(() => {
@@ -100,11 +105,10 @@ function EditableImage({ field, src, alt, className, style, width, height, prior
100
105
  };
101
106
  }, []);
102
107
 
103
- async function onPick(e: ChangeEvent<HTMLInputElement>) {
104
- const file = e.target.files?.[0];
105
- e.target.value = "";
106
- if (!file || !sectionId) return;
108
+ async function uploadFile(file: File) {
109
+ if (!sectionId) return;
107
110
  setBusy(true);
111
+ setErr(null);
108
112
  try {
109
113
  const usedFallback = !caps.uploadImage;
110
114
  const url = caps.uploadImage
@@ -115,16 +119,49 @@ function EditableImage({ field, src, alt, className, style, width, height, prior
115
119
  blobRef.current = url;
116
120
  }
117
121
  updateProps(sectionId, { [field]: url });
122
+ } catch (e) {
123
+ setErr(e instanceof Error ? e.message : "Upload failed");
118
124
  } finally {
119
125
  setBusy(false);
120
126
  }
121
127
  }
122
128
 
129
+ async function onPick(e: ChangeEvent<HTMLInputElement>) {
130
+ const file = e.target.files?.[0];
131
+ e.target.value = "";
132
+ if (!file) return;
133
+ await uploadFile(file);
134
+ }
135
+
123
136
  const open = (e: ReactMouseEvent) => {
124
137
  e.stopPropagation();
125
138
  inputRef.current?.click();
126
139
  };
127
140
 
141
+ const onDragOver = (e: React.DragEvent) => {
142
+ e.preventDefault();
143
+ e.stopPropagation();
144
+ setDragOver(true);
145
+ };
146
+ const onDragLeave = (e: React.DragEvent) => {
147
+ e.preventDefault();
148
+ e.stopPropagation();
149
+ setDragOver(false);
150
+ };
151
+ const onDrop = (e: React.DragEvent) => {
152
+ e.preventDefault();
153
+ e.stopPropagation();
154
+ setDragOver(false);
155
+ const file = e.dataTransfer.files?.[0];
156
+ if (file) void uploadFile(file);
157
+ };
158
+
159
+ const dropProps = {
160
+ onDragOver,
161
+ onDragLeave,
162
+ onDrop,
163
+ };
164
+
128
165
  return (
129
166
  <>
130
167
  {src ? (
@@ -136,24 +173,36 @@ function EditableImage({ field, src, alt, className, style, width, height, prior
136
173
  height={height}
137
174
  loading={priority ? "eager" : "lazy"}
138
175
  fetchPriority={priority ? "high" : "auto"}
139
- className={cn("cursor-pointer outline-2 outline-offset-2 outline-transparent hover:outline-sky-400", className)}
176
+ className={cn(
177
+ "cursor-pointer outline-2 outline-offset-2 outline-transparent hover:outline-sky-400",
178
+ dragOver && "outline-sky-400",
179
+ className,
180
+ )}
140
181
  style={style}
141
- title={busy ? "Uploading…" : "Click to replace"}
182
+ title={busy ? "Uploading…" : err ?? "Click or drop to replace"}
142
183
  onClick={open}
184
+ {...dropProps}
143
185
  />
144
186
  ) : (
145
187
  <div
146
188
  className={cn(
147
189
  "flex cursor-pointer items-center justify-center bg-black/[0.04] text-[11px] uppercase tracking-wider opacity-50 outline-2 outline-dashed outline-black/15 hover:outline-sky-400",
190
+ dragOver && "outline-sky-400 opacity-80",
148
191
  className,
149
192
  )}
150
193
  style={style}
151
194
  onClick={open}
195
+ {...dropProps}
152
196
  >
153
- {busy ? "Uploading…" : "Add image"}
197
+ {busy ? "Uploading…" : err ? "Upload failed — try again" : "Add image (click or drop)"}
154
198
  </div>
155
199
  )}
156
- <input ref={inputRef} type="file" accept="image/*" hidden onChange={onPick} />
200
+ {err && (
201
+ <p className="mt-1 text-[10px] text-red-600 dark:text-red-400" title={err}>
202
+ {err}
203
+ </p>
204
+ )}
205
+ <input ref={inputRef} type="file" accept={IMAGE_ACCEPT} hidden onChange={onPick} />
157
206
  </>
158
207
  );
159
208
  }