@jimmy_harika/websitekit 0.5.0 → 0.5.2
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 +23 -1
- package/package.json +1 -1
- package/src/editor/Inspector.tsx +106 -22
- package/src/editor/edit-primitives.tsx +58 -8
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.5.2",
|
|
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,
|
package/src/editor/Inspector.tsx
CHANGED
|
@@ -292,11 +292,12 @@ function ImageField({
|
|
|
292
292
|
if (blobRef.current) URL.revokeObjectURL(blobRef.current);
|
|
293
293
|
};
|
|
294
294
|
}, []);
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
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
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
|
466
|
-
|
|
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=
|
|
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
|
|
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}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
useRef,
|
|
15
15
|
useState,
|
|
16
16
|
type ChangeEvent,
|
|
17
|
+
type DragEvent as ReactDragEvent,
|
|
17
18
|
type ElementType,
|
|
18
19
|
type FocusEvent as ReactFocusEvent,
|
|
19
20
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
@@ -86,12 +87,17 @@ function EditableText({
|
|
|
86
87
|
);
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
const IMAGE_ACCEPT =
|
|
91
|
+
"image/*,.heic,.heif,image/heic,image/heif";
|
|
92
|
+
|
|
89
93
|
function EditableImage({ field, src, alt, className, style, width, height, priority }: ImageEditProps) {
|
|
90
94
|
const sectionId = useContext(EditSectionContext);
|
|
91
95
|
const updateProps = useBuilder((s) => s.updateProps);
|
|
92
96
|
const caps = useContext(EditorCapsContext);
|
|
93
97
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
94
98
|
const [busy, setBusy] = useState(false);
|
|
99
|
+
const [err, setErr] = useState<string | null>(null);
|
|
100
|
+
const [dragOver, setDragOver] = useState(false);
|
|
95
101
|
// Track object URLs we minted so we can revoke them (avoid leaks on replace/unmount).
|
|
96
102
|
const blobRef = useRef<string | null>(null);
|
|
97
103
|
useEffect(() => {
|
|
@@ -100,11 +106,10 @@ function EditableImage({ field, src, alt, className, style, width, height, prior
|
|
|
100
106
|
};
|
|
101
107
|
}, []);
|
|
102
108
|
|
|
103
|
-
async function
|
|
104
|
-
|
|
105
|
-
e.target.value = "";
|
|
106
|
-
if (!file || !sectionId) return;
|
|
109
|
+
async function uploadFile(file: File) {
|
|
110
|
+
if (!sectionId) return;
|
|
107
111
|
setBusy(true);
|
|
112
|
+
setErr(null);
|
|
108
113
|
try {
|
|
109
114
|
const usedFallback = !caps.uploadImage;
|
|
110
115
|
const url = caps.uploadImage
|
|
@@ -115,16 +120,49 @@ function EditableImage({ field, src, alt, className, style, width, height, prior
|
|
|
115
120
|
blobRef.current = url;
|
|
116
121
|
}
|
|
117
122
|
updateProps(sectionId, { [field]: url });
|
|
123
|
+
} catch (e) {
|
|
124
|
+
setErr(e instanceof Error ? e.message : "Upload failed");
|
|
118
125
|
} finally {
|
|
119
126
|
setBusy(false);
|
|
120
127
|
}
|
|
121
128
|
}
|
|
122
129
|
|
|
130
|
+
async function onPick(e: ChangeEvent<HTMLInputElement>) {
|
|
131
|
+
const file = e.target.files?.[0];
|
|
132
|
+
e.target.value = "";
|
|
133
|
+
if (!file) return;
|
|
134
|
+
await uploadFile(file);
|
|
135
|
+
}
|
|
136
|
+
|
|
123
137
|
const open = (e: ReactMouseEvent) => {
|
|
124
138
|
e.stopPropagation();
|
|
125
139
|
inputRef.current?.click();
|
|
126
140
|
};
|
|
127
141
|
|
|
142
|
+
const onDragOver = (e: ReactDragEvent) => {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
e.stopPropagation();
|
|
145
|
+
setDragOver(true);
|
|
146
|
+
};
|
|
147
|
+
const onDragLeave = (e: ReactDragEvent) => {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
e.stopPropagation();
|
|
150
|
+
setDragOver(false);
|
|
151
|
+
};
|
|
152
|
+
const onDrop = (e: ReactDragEvent) => {
|
|
153
|
+
e.preventDefault();
|
|
154
|
+
e.stopPropagation();
|
|
155
|
+
setDragOver(false);
|
|
156
|
+
const file = e.dataTransfer.files?.[0];
|
|
157
|
+
if (file) void uploadFile(file);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const dropProps = {
|
|
161
|
+
onDragOver,
|
|
162
|
+
onDragLeave,
|
|
163
|
+
onDrop,
|
|
164
|
+
};
|
|
165
|
+
|
|
128
166
|
return (
|
|
129
167
|
<>
|
|
130
168
|
{src ? (
|
|
@@ -136,24 +174,36 @@ function EditableImage({ field, src, alt, className, style, width, height, prior
|
|
|
136
174
|
height={height}
|
|
137
175
|
loading={priority ? "eager" : "lazy"}
|
|
138
176
|
fetchPriority={priority ? "high" : "auto"}
|
|
139
|
-
className={cn(
|
|
177
|
+
className={cn(
|
|
178
|
+
"cursor-pointer outline-2 outline-offset-2 outline-transparent hover:outline-sky-400",
|
|
179
|
+
dragOver && "outline-sky-400",
|
|
180
|
+
className,
|
|
181
|
+
)}
|
|
140
182
|
style={style}
|
|
141
|
-
title={busy ? "Uploading…" : "Click to replace"}
|
|
183
|
+
title={busy ? "Uploading…" : err ?? "Click or drop to replace"}
|
|
142
184
|
onClick={open}
|
|
185
|
+
{...dropProps}
|
|
143
186
|
/>
|
|
144
187
|
) : (
|
|
145
188
|
<div
|
|
146
189
|
className={cn(
|
|
147
190
|
"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",
|
|
191
|
+
dragOver && "outline-sky-400 opacity-80",
|
|
148
192
|
className,
|
|
149
193
|
)}
|
|
150
194
|
style={style}
|
|
151
195
|
onClick={open}
|
|
196
|
+
{...dropProps}
|
|
152
197
|
>
|
|
153
|
-
{busy ? "Uploading…" : "Add image"}
|
|
198
|
+
{busy ? "Uploading…" : err ? "Upload failed — try again" : "Add image (click or drop)"}
|
|
154
199
|
</div>
|
|
155
200
|
)}
|
|
156
|
-
|
|
201
|
+
{err && (
|
|
202
|
+
<p className="mt-1 text-[10px] text-red-600 dark:text-red-400" title={err}>
|
|
203
|
+
{err}
|
|
204
|
+
</p>
|
|
205
|
+
)}
|
|
206
|
+
<input ref={inputRef} type="file" accept={IMAGE_ACCEPT} hidden onChange={onPick} />
|
|
157
207
|
</>
|
|
158
208
|
);
|
|
159
209
|
}
|