@kidecms/core 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.
- package/README.md +28 -0
- package/admin/components/AdminCard.astro +25 -0
- package/admin/components/AiGenerateButton.tsx +102 -0
- package/admin/components/AssetsGrid.tsx +711 -0
- package/admin/components/BlockEditor.tsx +996 -0
- package/admin/components/CheckboxField.tsx +31 -0
- package/admin/components/DocumentActions.tsx +317 -0
- package/admin/components/DocumentLock.tsx +54 -0
- package/admin/components/DocumentsDataTable.tsx +804 -0
- package/admin/components/FieldControl.astro +397 -0
- package/admin/components/FocalPointSelector.tsx +100 -0
- package/admin/components/ImageBrowseDialog.tsx +176 -0
- package/admin/components/ImagePicker.tsx +149 -0
- package/admin/components/InternalLinkPicker.tsx +80 -0
- package/admin/components/LiveHeading.tsx +17 -0
- package/admin/components/MobileSidebar.tsx +29 -0
- package/admin/components/RelationField.tsx +204 -0
- package/admin/components/RichTextEditor.tsx +685 -0
- package/admin/components/SelectField.tsx +65 -0
- package/admin/components/SidebarUserMenu.tsx +99 -0
- package/admin/components/SlugField.tsx +77 -0
- package/admin/components/TaxonomySelect.tsx +52 -0
- package/admin/components/Toast.astro +40 -0
- package/admin/components/TreeItemsEditor.tsx +790 -0
- package/admin/components/TreeSelect.tsx +166 -0
- package/admin/components/UnsavedGuard.tsx +181 -0
- package/admin/components/tree-utils.ts +86 -0
- package/admin/components/ui/alert-dialog.tsx +92 -0
- package/admin/components/ui/badge.tsx +83 -0
- package/admin/components/ui/button.tsx +53 -0
- package/admin/components/ui/card.tsx +70 -0
- package/admin/components/ui/checkbox.tsx +28 -0
- package/admin/components/ui/collapsible.tsx +26 -0
- package/admin/components/ui/command.tsx +88 -0
- package/admin/components/ui/dialog.tsx +92 -0
- package/admin/components/ui/dropdown-menu.tsx +259 -0
- package/admin/components/ui/input.tsx +20 -0
- package/admin/components/ui/label.tsx +20 -0
- package/admin/components/ui/popover.tsx +42 -0
- package/admin/components/ui/select.tsx +165 -0
- package/admin/components/ui/separator.tsx +21 -0
- package/admin/components/ui/sheet.tsx +104 -0
- package/admin/components/ui/skeleton.tsx +7 -0
- package/admin/components/ui/table.tsx +74 -0
- package/admin/components/ui/textarea.tsx +18 -0
- package/admin/components/ui/tooltip.tsx +52 -0
- package/admin/layouts/AdminLayout.astro +340 -0
- package/admin/lib/utils.ts +19 -0
- package/dist/admin.js +92 -0
- package/dist/ai.js +67 -0
- package/dist/api.js +827 -0
- package/dist/assets.js +163 -0
- package/dist/auth.js +132 -0
- package/dist/blocks.js +110 -0
- package/dist/content.js +29 -0
- package/dist/create-admin.js +23 -0
- package/dist/define.js +36 -0
- package/dist/generator.js +370 -0
- package/dist/image.js +69 -0
- package/dist/index.js +16 -0
- package/dist/integration.js +256 -0
- package/dist/locks.js +37 -0
- package/dist/richtext.js +1 -0
- package/dist/runtime.js +26 -0
- package/dist/schema.js +13 -0
- package/dist/seed.js +84 -0
- package/dist/values.js +102 -0
- package/middleware/auth.ts +100 -0
- package/package.json +102 -0
- package/routes/api/cms/[collection]/[...path].ts +366 -0
- package/routes/api/cms/ai/alt-text.ts +25 -0
- package/routes/api/cms/ai/seo.ts +25 -0
- package/routes/api/cms/ai/translate.ts +31 -0
- package/routes/api/cms/assets/[id].ts +82 -0
- package/routes/api/cms/assets/folders.ts +81 -0
- package/routes/api/cms/assets/index.ts +23 -0
- package/routes/api/cms/assets/upload.ts +112 -0
- package/routes/api/cms/auth/invite.ts +166 -0
- package/routes/api/cms/auth/login.ts +124 -0
- package/routes/api/cms/auth/logout.ts +33 -0
- package/routes/api/cms/auth/setup.ts +77 -0
- package/routes/api/cms/cron/publish.ts +33 -0
- package/routes/api/cms/img/[...path].ts +24 -0
- package/routes/api/cms/locks/[...path].ts +37 -0
- package/routes/api/cms/preview/render.ts +36 -0
- package/routes/api/cms/references/[collection]/[id].ts +60 -0
- package/routes/pages/admin/[...path].astro +1104 -0
- package/routes/pages/admin/assets/[id].astro +183 -0
- package/routes/pages/admin/assets/index.astro +58 -0
- package/routes/pages/admin/invite.astro +116 -0
- package/routes/pages/admin/login.astro +57 -0
- package/routes/pages/admin/setup.astro +91 -0
- package/virtual.d.ts +61 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { FieldConfig } from "@kidecms/core";
|
|
3
|
+
import { serializeFieldValue, humanize } from "@kidecms/core";
|
|
4
|
+
import { customFields } from "virtual:kide/custom-fields";
|
|
5
|
+
import ImagePicker from "./ImagePicker";
|
|
6
|
+
import TreeItemsEditor from "./TreeItemsEditor";
|
|
7
|
+
import RichTextEditor from "./RichTextEditor";
|
|
8
|
+
import SelectField from "./SelectField";
|
|
9
|
+
import RelationField from "./RelationField";
|
|
10
|
+
import BlockEditor from "./BlockEditor";
|
|
11
|
+
import SlugField from "./SlugField";
|
|
12
|
+
import CheckboxField from "./CheckboxField";
|
|
13
|
+
import TaxonomySelect from "./TaxonomySelect";
|
|
14
|
+
import { Input } from "./ui/input";
|
|
15
|
+
import { Label } from "./ui/label";
|
|
16
|
+
import { Textarea } from "./ui/textarea";
|
|
17
|
+
import { cn } from "../lib/utils";
|
|
18
|
+
|
|
19
|
+
type RelationOption = {
|
|
20
|
+
value: string;
|
|
21
|
+
label: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type RelationMeta = {
|
|
25
|
+
collectionSlug: string;
|
|
26
|
+
collectionLabel: string;
|
|
27
|
+
hasMany: boolean;
|
|
28
|
+
labelField?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type LinkOptionGroup = {
|
|
32
|
+
collection: string;
|
|
33
|
+
label: string;
|
|
34
|
+
items: Array<{ id: string; label: string; href: string }>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const {
|
|
38
|
+
name,
|
|
39
|
+
field,
|
|
40
|
+
value,
|
|
41
|
+
relationOptions = [],
|
|
42
|
+
relationMeta,
|
|
43
|
+
readOnly = false,
|
|
44
|
+
menuLinkOptions = [],
|
|
45
|
+
blockRelationOptions = {},
|
|
46
|
+
} = Astro.props as {
|
|
47
|
+
name: string;
|
|
48
|
+
field: FieldConfig;
|
|
49
|
+
value?: unknown;
|
|
50
|
+
relationOptions?: RelationOption[];
|
|
51
|
+
relationMeta?: RelationMeta;
|
|
52
|
+
readOnly?: boolean;
|
|
53
|
+
menuLinkOptions?: LinkOptionGroup[];
|
|
54
|
+
blockRelationOptions?: Record<string, RelationOption[]>;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const label = field.label ?? humanize(name);
|
|
58
|
+
const placeholder = field.admin?.placeholder ?? "";
|
|
59
|
+
const serializedValue = serializeFieldValue(field, value);
|
|
60
|
+
const rows =
|
|
61
|
+
field.admin?.rows ?? (field.type === "richText" ? 10 : field.type === "json" || field.type === "blocks" ? 12 : 5);
|
|
62
|
+
const inputMode = field.type === "number" ? "decimal" : undefined;
|
|
63
|
+
const controlClass = cn("w-full shadow-none", readOnly && "cursor-not-allowed bg-muted text-muted-foreground");
|
|
64
|
+
const isTreeEditor =
|
|
65
|
+
field.type === "json" && (field.admin?.component === "menu-items" || field.admin?.component === "taxonomy-terms");
|
|
66
|
+
|
|
67
|
+
// Custom field component: if admin.component is set and not a built-in variant, look for user-provided component
|
|
68
|
+
const builtInComponents = new Set(["radio", "taxonomy-select", "menu-items", "taxonomy-terms"]);
|
|
69
|
+
const CustomComponent =
|
|
70
|
+
field.admin?.component && !builtInComponents.has(field.admin.component)
|
|
71
|
+
? customFields[field.admin.component] ?? null
|
|
72
|
+
: null;
|
|
73
|
+
|
|
74
|
+
// Build serializable block types metadata for BlockEditor
|
|
75
|
+
const blockTypesMeta =
|
|
76
|
+
field.type === "blocks" && field.types
|
|
77
|
+
? Object.fromEntries(
|
|
78
|
+
Object.entries(field.types).map(([typeName, typeFields]) => [
|
|
79
|
+
typeName,
|
|
80
|
+
Object.fromEntries(
|
|
81
|
+
Object.entries(typeFields).map(([fieldName, subField]) => [
|
|
82
|
+
fieldName,
|
|
83
|
+
{
|
|
84
|
+
type: subField.type,
|
|
85
|
+
label: subField.label,
|
|
86
|
+
required: subField.required,
|
|
87
|
+
options: "options" in subField ? subField.options : undefined,
|
|
88
|
+
from: "from" in subField ? subField.from : undefined,
|
|
89
|
+
admin: subField.admin,
|
|
90
|
+
defaultValue: subField.defaultValue,
|
|
91
|
+
of: "of" in subField && subField.of ? { type: subField.of.type } : undefined,
|
|
92
|
+
collection: "collection" in subField ? subField.collection : undefined,
|
|
93
|
+
hasMany: "hasMany" in subField ? subField.hasMany : undefined,
|
|
94
|
+
},
|
|
95
|
+
]),
|
|
96
|
+
),
|
|
97
|
+
]),
|
|
98
|
+
)
|
|
99
|
+
: {};
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
<div
|
|
103
|
+
class="grid gap-2"
|
|
104
|
+
data-field-name={name}
|
|
105
|
+
{...field.condition
|
|
106
|
+
? {
|
|
107
|
+
"data-condition-field": field.condition.field,
|
|
108
|
+
"data-condition-value": JSON.stringify(field.condition.value),
|
|
109
|
+
}
|
|
110
|
+
: {}}
|
|
111
|
+
>
|
|
112
|
+
{
|
|
113
|
+
!isTreeEditor && (
|
|
114
|
+
<Label htmlFor={name}>
|
|
115
|
+
{label}
|
|
116
|
+
{field.required ? " *" : ""}
|
|
117
|
+
</Label>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
{field.description && <p class="text-muted-foreground -mt-1 text-xs leading-5">{field.description}</p>}
|
|
121
|
+
{field.admin?.help && <p class="text-muted-foreground -mt-1 -mb-0.5 text-xs leading-5">{field.admin.help}</p>}
|
|
122
|
+
|
|
123
|
+
{
|
|
124
|
+
CustomComponent && (
|
|
125
|
+
<CustomComponent
|
|
126
|
+
client:load
|
|
127
|
+
name={name}
|
|
128
|
+
field={field}
|
|
129
|
+
value={serializedValue}
|
|
130
|
+
readOnly={readOnly}
|
|
131
|
+
/>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
{!CustomComponent && (
|
|
136
|
+
<Fragment>
|
|
137
|
+
|
|
138
|
+
{
|
|
139
|
+
field.type === "image" && !readOnly && (
|
|
140
|
+
<ImagePicker client:load name={name} value={serializedValue} placeholder={placeholder} />
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
{
|
|
145
|
+
field.type === "image" && readOnly && (
|
|
146
|
+
<Input
|
|
147
|
+
className={controlClass}
|
|
148
|
+
type="text"
|
|
149
|
+
id={name}
|
|
150
|
+
name={name}
|
|
151
|
+
defaultValue={serializedValue}
|
|
152
|
+
placeholder={placeholder}
|
|
153
|
+
readOnly={readOnly}
|
|
154
|
+
/>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
{
|
|
159
|
+
field.type === "text" && field.admin?.component === "taxonomy-select" && !readOnly && (
|
|
160
|
+
<TaxonomySelect client:load name={name} value={serializedValue} taxonomySlug={field.admin?.placeholder ?? ""} />
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
{
|
|
165
|
+
(field.type === "text" || field.type === "email" || field.type === "date") &&
|
|
166
|
+
field.admin?.component !== "taxonomy-select" &&
|
|
167
|
+
(field.type === "text" && field.admin?.rows ? (
|
|
168
|
+
<Textarea
|
|
169
|
+
className={controlClass}
|
|
170
|
+
id={name}
|
|
171
|
+
name={name}
|
|
172
|
+
rows={field.admin.rows}
|
|
173
|
+
defaultValue={serializedValue}
|
|
174
|
+
placeholder={placeholder}
|
|
175
|
+
readOnly={readOnly}
|
|
176
|
+
required={field.required}
|
|
177
|
+
/>
|
|
178
|
+
) : (
|
|
179
|
+
<Input
|
|
180
|
+
className={controlClass}
|
|
181
|
+
type={field.type === "email" ? "email" : field.type === "date" ? "date" : "text"}
|
|
182
|
+
id={name}
|
|
183
|
+
name={name}
|
|
184
|
+
defaultValue={serializedValue}
|
|
185
|
+
placeholder={placeholder}
|
|
186
|
+
readOnly={readOnly}
|
|
187
|
+
required={field.required}
|
|
188
|
+
/>
|
|
189
|
+
))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
{
|
|
193
|
+
field.type === "slug" && (
|
|
194
|
+
<SlugField
|
|
195
|
+
client:load
|
|
196
|
+
name={name}
|
|
197
|
+
value={serializedValue}
|
|
198
|
+
from={field.from}
|
|
199
|
+
readOnly={readOnly}
|
|
200
|
+
required={field.required}
|
|
201
|
+
placeholder={placeholder || "auto-generated-slug"}
|
|
202
|
+
className={controlClass}
|
|
203
|
+
/>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
{
|
|
208
|
+
field.type === "number" && (
|
|
209
|
+
<Input
|
|
210
|
+
className={controlClass}
|
|
211
|
+
type="number"
|
|
212
|
+
id={name}
|
|
213
|
+
name={name}
|
|
214
|
+
defaultValue={serializedValue}
|
|
215
|
+
inputMode={inputMode}
|
|
216
|
+
readOnly={readOnly}
|
|
217
|
+
required={field.required}
|
|
218
|
+
/>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
{field.type === "boolean" && <CheckboxField client:load name={name} checked={Boolean(value)} disabled={readOnly} />}
|
|
223
|
+
|
|
224
|
+
{
|
|
225
|
+
field.type === "select" && field.admin?.component !== "radio" && (
|
|
226
|
+
<SelectField
|
|
227
|
+
client:load
|
|
228
|
+
name={name}
|
|
229
|
+
value={String(value ?? "")}
|
|
230
|
+
placeholder="Select an option"
|
|
231
|
+
disabled={readOnly}
|
|
232
|
+
items={field.options.map((option) => ({ value: option, label: option }))}
|
|
233
|
+
/>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
{
|
|
238
|
+
field.type === "select" && field.admin?.component === "radio" && (
|
|
239
|
+
<div class="flex flex-wrap gap-x-5 gap-y-2 has-disabled:opacity-50">
|
|
240
|
+
{field.options.map((option) => (
|
|
241
|
+
<label class="hover:text-foreground flex items-center gap-2 text-sm transition-colors has-disabled:cursor-not-allowed has-disabled:hover:text-current">
|
|
242
|
+
<input
|
|
243
|
+
class="border-input hover:border-primary/60 checked:border-primary checked:hover:border-primary focus-visible:ring-ring/50 disabled:hover:border-input size-4 shrink-0 appearance-none rounded-full border transition-all checked:border-[5px] focus-visible:ring-3 focus-visible:outline-none disabled:cursor-not-allowed"
|
|
244
|
+
type="radio"
|
|
245
|
+
name={name}
|
|
246
|
+
value={option}
|
|
247
|
+
checked={String(value ?? "") === option}
|
|
248
|
+
disabled={readOnly}
|
|
249
|
+
/>
|
|
250
|
+
<span class="select-none">{option}</span>
|
|
251
|
+
</label>
|
|
252
|
+
))}
|
|
253
|
+
</div>
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
{
|
|
258
|
+
field.type === "relation" && !readOnly && relationMeta && (
|
|
259
|
+
<RelationField
|
|
260
|
+
client:load
|
|
261
|
+
name={name}
|
|
262
|
+
value={Array.isArray(value) ? JSON.stringify(value) : String(value ?? "")}
|
|
263
|
+
hasMany={relationMeta.hasMany}
|
|
264
|
+
options={relationOptions}
|
|
265
|
+
collectionSlug={relationMeta.collectionSlug}
|
|
266
|
+
collectionLabel={relationMeta.collectionLabel}
|
|
267
|
+
labelField={relationMeta.labelField}
|
|
268
|
+
/>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
{
|
|
273
|
+
field.type === "relation" && (readOnly || !relationMeta) && (
|
|
274
|
+
<SelectField
|
|
275
|
+
client:load
|
|
276
|
+
name={name}
|
|
277
|
+
value={String(value ?? "")}
|
|
278
|
+
placeholder="Select a document"
|
|
279
|
+
disabled={readOnly}
|
|
280
|
+
items={relationOptions}
|
|
281
|
+
/>
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
{
|
|
286
|
+
field.type === "richText" && !readOnly && (
|
|
287
|
+
<RichTextEditor client:load name={name} initialValue={value ? JSON.stringify(value) : ""} rows={rows} />
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
{
|
|
292
|
+
field.type === "richText" && readOnly && (
|
|
293
|
+
<Textarea
|
|
294
|
+
className={controlClass}
|
|
295
|
+
id={name}
|
|
296
|
+
name={name}
|
|
297
|
+
rows={rows}
|
|
298
|
+
readOnly={readOnly}
|
|
299
|
+
defaultValue={serializedValue}
|
|
300
|
+
/>
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
{
|
|
305
|
+
field.type === "json" && field.admin?.component === "menu-items" && !readOnly && (
|
|
306
|
+
<TreeItemsEditor
|
|
307
|
+
client:load
|
|
308
|
+
name={name}
|
|
309
|
+
value={serializedValue}
|
|
310
|
+
variant="menu"
|
|
311
|
+
label={label}
|
|
312
|
+
linkOptions={menuLinkOptions}
|
|
313
|
+
/>
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
{
|
|
318
|
+
field.type === "json" && field.admin?.component === "taxonomy-terms" && !readOnly && (
|
|
319
|
+
<TreeItemsEditor client:load name={name} value={serializedValue} variant="taxonomy" label={label} />
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
{
|
|
324
|
+
field.type === "json" &&
|
|
325
|
+
(field.admin?.component === "menu-items" || field.admin?.component === "taxonomy-terms") &&
|
|
326
|
+
readOnly && (
|
|
327
|
+
<Textarea
|
|
328
|
+
className={controlClass}
|
|
329
|
+
id={name}
|
|
330
|
+
name={name}
|
|
331
|
+
rows={rows}
|
|
332
|
+
readOnly={readOnly}
|
|
333
|
+
defaultValue={serializedValue}
|
|
334
|
+
/>
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
{
|
|
339
|
+
field.type === "blocks" && !readOnly && (
|
|
340
|
+
<BlockEditor
|
|
341
|
+
client:load
|
|
342
|
+
name={name}
|
|
343
|
+
value={serializedValue}
|
|
344
|
+
types={blockTypesMeta}
|
|
345
|
+
blockRelationOptions={blockRelationOptions}
|
|
346
|
+
/>
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
{
|
|
351
|
+
field.type === "blocks" && readOnly && (
|
|
352
|
+
<Textarea
|
|
353
|
+
className={controlClass}
|
|
354
|
+
id={name}
|
|
355
|
+
name={name}
|
|
356
|
+
rows={rows}
|
|
357
|
+
readOnly={readOnly}
|
|
358
|
+
defaultValue={serializedValue}
|
|
359
|
+
/>
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
{
|
|
364
|
+
field.type === "array" && "of" in field && field.of?.type === "text" && (
|
|
365
|
+
<Input
|
|
366
|
+
className={controlClass}
|
|
367
|
+
id={name}
|
|
368
|
+
name={name}
|
|
369
|
+
placeholder={placeholder || "item1, item2, item3"}
|
|
370
|
+
readOnly={readOnly}
|
|
371
|
+
required={field.required}
|
|
372
|
+
defaultValue={serializedValue}
|
|
373
|
+
/>
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
{
|
|
378
|
+
((field.type === "array" && !("of" in field && field.of?.type === "text")) ||
|
|
379
|
+
(field.type === "json" &&
|
|
380
|
+
field.admin?.component !== "menu-items" &&
|
|
381
|
+
field.admin?.component !== "taxonomy-terms")) && (
|
|
382
|
+
<Textarea
|
|
383
|
+
className={controlClass}
|
|
384
|
+
id={name}
|
|
385
|
+
name={name}
|
|
386
|
+
rows={rows}
|
|
387
|
+
placeholder={placeholder}
|
|
388
|
+
readOnly={readOnly}
|
|
389
|
+
required={field.required}
|
|
390
|
+
defaultValue={serializedValue}
|
|
391
|
+
/>
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
</Fragment>
|
|
396
|
+
)}
|
|
397
|
+
</div>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useEffect, useRef } from "react";
|
|
4
|
+
import { Crosshair, X } from "lucide-react";
|
|
5
|
+
import { Button } from "./ui/button";
|
|
6
|
+
import { Input } from "./ui/input";
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
src: string;
|
|
10
|
+
alt: string;
|
|
11
|
+
focalX: number | null;
|
|
12
|
+
focalY: number | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export default function FocalPointSelector({ src, alt, focalX: initialX, focalY: initialY }: Props) {
|
|
16
|
+
const [focalX, setFocalX] = useState<number | null>(initialX);
|
|
17
|
+
const [focalY, setFocalY] = useState<number | null>(initialY);
|
|
18
|
+
const imageRef = useRef<HTMLImageElement>(null);
|
|
19
|
+
const hiddenXRef = useRef<HTMLInputElement>(null);
|
|
20
|
+
|
|
21
|
+
// Notify the form of value changes so UnsavedGuard detects them
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
hiddenXRef.current?.dispatchEvent(new Event("change", { bubbles: true }));
|
|
24
|
+
}, [focalX, focalY]);
|
|
25
|
+
|
|
26
|
+
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
27
|
+
const img = imageRef.current;
|
|
28
|
+
if (!img) return;
|
|
29
|
+
const rect = img.getBoundingClientRect();
|
|
30
|
+
const x = Math.round(((e.clientX - rect.left) / rect.width) * 100);
|
|
31
|
+
const y = Math.round(((e.clientY - rect.top) / rect.height) * 100);
|
|
32
|
+
setFocalX(Math.max(0, Math.min(100, x)));
|
|
33
|
+
setFocalY(Math.max(0, Math.min(100, y)));
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
const handleClear = useCallback(() => {
|
|
37
|
+
setFocalX(null);
|
|
38
|
+
setFocalY(null);
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
const hasFocal = focalX !== null && focalY !== null;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="space-y-2">
|
|
45
|
+
<div className="flex h-6 items-center justify-between gap-2">
|
|
46
|
+
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
|
47
|
+
<Crosshair className="size-3" />
|
|
48
|
+
{hasFocal ? <span>Focal point</span> : <span>Click image to set focal point</span>}
|
|
49
|
+
</div>
|
|
50
|
+
{hasFocal && (
|
|
51
|
+
<div className="flex items-center gap-1.5">
|
|
52
|
+
<div className="flex items-center gap-1">
|
|
53
|
+
<span className="text-muted-foreground text-xs">X</span>
|
|
54
|
+
<Input
|
|
55
|
+
type="number"
|
|
56
|
+
min={0}
|
|
57
|
+
max={100}
|
|
58
|
+
value={focalX ?? ""}
|
|
59
|
+
onChange={(e) =>
|
|
60
|
+
setFocalX(e.target.value === "" ? null : Math.max(0, Math.min(100, Number(e.target.value))))
|
|
61
|
+
}
|
|
62
|
+
className="h-6 w-14 px-1.5 text-center text-xs"
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
<div className="flex items-center gap-1">
|
|
66
|
+
<span className="text-muted-foreground text-xs">Y</span>
|
|
67
|
+
<Input
|
|
68
|
+
type="number"
|
|
69
|
+
min={0}
|
|
70
|
+
max={100}
|
|
71
|
+
value={focalY ?? ""}
|
|
72
|
+
onChange={(e) =>
|
|
73
|
+
setFocalY(e.target.value === "" ? null : Math.max(0, Math.min(100, Number(e.target.value))))
|
|
74
|
+
}
|
|
75
|
+
className="h-6 w-14 px-1.5 text-center text-xs"
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
<Button type="button" variant="ghost" size="icon-xs" title="Clear focal point" onClick={handleClear}>
|
|
79
|
+
<X className="size-3" />
|
|
80
|
+
</Button>
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
<div className="bg-muted/30 relative cursor-crosshair overflow-hidden rounded-md" onClick={handleClick}>
|
|
85
|
+
<img ref={imageRef} src={src} alt={alt} className="w-full object-contain" draggable={false} />
|
|
86
|
+
{hasFocal && (
|
|
87
|
+
<div
|
|
88
|
+
className="pointer-events-none absolute size-8 -translate-x-1/2 -translate-y-1/2"
|
|
89
|
+
style={{ left: `${focalX}%`, top: `${focalY}%` }}
|
|
90
|
+
>
|
|
91
|
+
<div className="absolute inset-0 rounded-full border-2 border-white shadow-[0_0_0_1.5px_rgba(0,0,0,0.4),inset_0_0_0_1.5px_rgba(0,0,0,0.4)]" />
|
|
92
|
+
<div className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow-[0_0_0_1.5px_rgba(0,0,0,0.4)]" />
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
<input ref={hiddenXRef} type="hidden" name="focalX" value={focalX ?? ""} />
|
|
97
|
+
<input type="hidden" name="focalY" value={focalY ?? ""} />
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect } from "react";
|
|
2
|
+
import { ChevronRight, Folder, Loader2, X } from "lucide-react";
|
|
3
|
+
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
|
|
4
|
+
import { thumbnail } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
type AssetRecord = {
|
|
7
|
+
_id: string;
|
|
8
|
+
filename: string;
|
|
9
|
+
mimeType: string;
|
|
10
|
+
url: string;
|
|
11
|
+
_createdAt: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type FolderRecord = {
|
|
15
|
+
_id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type BrowseState = {
|
|
20
|
+
folderId: string | null;
|
|
21
|
+
folders: FolderRecord[];
|
|
22
|
+
assets: AssetRecord[];
|
|
23
|
+
breadcrumbs: Array<{ id: string | null; name: string }>;
|
|
24
|
+
loading: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type Props = {
|
|
28
|
+
open: boolean;
|
|
29
|
+
onOpenChange: (open: boolean) => void;
|
|
30
|
+
onSelect: (asset: AssetRecord) => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export default function ImageBrowseDialog({ open, onOpenChange, onSelect }: Props) {
|
|
34
|
+
const [browse, setBrowse] = useState<BrowseState>({
|
|
35
|
+
folderId: null,
|
|
36
|
+
folders: [],
|
|
37
|
+
assets: [],
|
|
38
|
+
breadcrumbs: [{ id: null, name: "All assets" }],
|
|
39
|
+
loading: false,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const loadFolder = useCallback(async (folderId: string | null, breadcrumbs: BrowseState["breadcrumbs"]) => {
|
|
43
|
+
setBrowse((prev) => ({ ...prev, loading: true, folderId, breadcrumbs }));
|
|
44
|
+
try {
|
|
45
|
+
const folderParam = folderId ? `&folder=${folderId}` : "&folder=";
|
|
46
|
+
const [assetsRes, foldersRes] = await Promise.all([
|
|
47
|
+
fetch(`/api/cms/assets?limit=50${folderParam}`),
|
|
48
|
+
fetch(`/api/cms/assets/folders?parent=${folderId ?? ""}`),
|
|
49
|
+
]);
|
|
50
|
+
const assetsData = await assetsRes.json();
|
|
51
|
+
const foldersData = await foldersRes.json();
|
|
52
|
+
setBrowse((prev) => ({
|
|
53
|
+
...prev,
|
|
54
|
+
folders: foldersData ?? [],
|
|
55
|
+
assets: (assetsData.items ?? []).filter((a: AssetRecord) => a.mimeType.startsWith("image/")),
|
|
56
|
+
loading: false,
|
|
57
|
+
}));
|
|
58
|
+
} catch {
|
|
59
|
+
setBrowse((prev) => ({ ...prev, folders: [], assets: [], loading: false }));
|
|
60
|
+
}
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
const navigateToFolder = useCallback(
|
|
64
|
+
(folder: FolderRecord) => {
|
|
65
|
+
loadFolder(folder._id, [...browse.breadcrumbs, { id: folder._id, name: folder.name }]);
|
|
66
|
+
},
|
|
67
|
+
[browse.breadcrumbs, loadFolder],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const navigateToBreadcrumb = useCallback(
|
|
71
|
+
(index: number) => {
|
|
72
|
+
const crumb = browse.breadcrumbs[index];
|
|
73
|
+
loadFolder(crumb.id, browse.breadcrumbs.slice(0, index + 1));
|
|
74
|
+
},
|
|
75
|
+
[browse.breadcrumbs, loadFolder],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (open) {
|
|
80
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
81
|
+
loadFolder(null, [{ id: null, name: "All assets" }]);
|
|
82
|
+
}
|
|
83
|
+
}, [open, loadFolder]);
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
87
|
+
<DialogContent className="max-w-3xl">
|
|
88
|
+
<DialogHeader>
|
|
89
|
+
<div className="flex items-center justify-between">
|
|
90
|
+
<DialogTitle>Media Library</DialogTitle>
|
|
91
|
+
<DialogClose>
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
title="Close"
|
|
95
|
+
className="text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors"
|
|
96
|
+
>
|
|
97
|
+
<X className="size-5" />
|
|
98
|
+
</button>
|
|
99
|
+
</DialogClose>
|
|
100
|
+
</div>
|
|
101
|
+
</DialogHeader>
|
|
102
|
+
|
|
103
|
+
{browse.breadcrumbs.length > 1 && (
|
|
104
|
+
<nav className="text-muted-foreground flex items-center gap-1 text-sm">
|
|
105
|
+
{browse.breadcrumbs.map((crumb, i) => (
|
|
106
|
+
<span key={crumb.id ?? "root"} className="contents">
|
|
107
|
+
{i > 0 && <ChevronRight className="size-3.5 shrink-0" />}
|
|
108
|
+
{i < browse.breadcrumbs.length - 1 ? (
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
onClick={() => navigateToBreadcrumb(i)}
|
|
112
|
+
className="hover:text-foreground truncate transition-colors"
|
|
113
|
+
>
|
|
114
|
+
{crumb.name}
|
|
115
|
+
</button>
|
|
116
|
+
) : (
|
|
117
|
+
<span className="text-foreground truncate font-medium">{crumb.name}</span>
|
|
118
|
+
)}
|
|
119
|
+
</span>
|
|
120
|
+
))}
|
|
121
|
+
</nav>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
<div className="max-h-[60vh] overflow-y-auto">
|
|
125
|
+
{browse.loading ? (
|
|
126
|
+
<div className="flex h-48 items-center justify-center">
|
|
127
|
+
<Loader2 className="text-muted-foreground size-6 animate-spin" />
|
|
128
|
+
</div>
|
|
129
|
+
) : (
|
|
130
|
+
<div className="space-y-4">
|
|
131
|
+
{browse.folders.length > 0 && (
|
|
132
|
+
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
|
|
133
|
+
{browse.folders.map((folder) => (
|
|
134
|
+
<button
|
|
135
|
+
key={folder._id}
|
|
136
|
+
type="button"
|
|
137
|
+
onClick={() => navigateToFolder(folder)}
|
|
138
|
+
className="hover:bg-accent flex items-center gap-2 rounded-lg border px-3 py-2.5 text-left text-sm transition-colors"
|
|
139
|
+
>
|
|
140
|
+
<Folder className="text-muted-foreground size-4 shrink-0" />
|
|
141
|
+
<span className="truncate">{folder.name}</span>
|
|
142
|
+
</button>
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{browse.assets.length > 0 ? (
|
|
148
|
+
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4">
|
|
149
|
+
{browse.assets.map((asset) => (
|
|
150
|
+
<button
|
|
151
|
+
key={asset._id}
|
|
152
|
+
type="button"
|
|
153
|
+
onClick={() => {
|
|
154
|
+
onSelect(asset);
|
|
155
|
+
onOpenChange(false);
|
|
156
|
+
}}
|
|
157
|
+
className="hover:border-foreground relative aspect-square overflow-hidden rounded-lg border transition-colors"
|
|
158
|
+
>
|
|
159
|
+
<img src={thumbnail(asset.url)} alt={asset.filename} className="size-full object-cover" />
|
|
160
|
+
</button>
|
|
161
|
+
))}
|
|
162
|
+
</div>
|
|
163
|
+
) : (
|
|
164
|
+
browse.folders.length === 0 && (
|
|
165
|
+
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
|
166
|
+
{browse.folderId ? "This folder is empty." : "No images uploaded yet."}
|
|
167
|
+
</div>
|
|
168
|
+
)
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
</DialogContent>
|
|
174
|
+
</Dialog>
|
|
175
|
+
);
|
|
176
|
+
}
|