@kyro-cms/admin 0.8.0 → 0.9.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.
Files changed (100) hide show
  1. package/dist/index.cjs +11960 -11006
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +67 -65
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.cts +563 -0
  6. package/dist/index.d.ts +7 -7
  7. package/dist/index.js +12183 -11238
  8. package/dist/index.js.map +1 -1
  9. package/package.json +15 -11
  10. package/src/components/ActionBar.tsx +27 -14
  11. package/src/components/Admin.tsx +1 -1
  12. package/src/components/ApiKeysManager.tsx +5 -5
  13. package/src/components/AutoForm.tsx +585 -369
  14. package/src/components/BrandingHub.tsx +7 -4
  15. package/src/components/CreateView.tsx +2 -0
  16. package/src/components/DetailView.tsx +71 -56
  17. package/src/components/DeveloperCenter.tsx +8 -6
  18. package/src/components/FieldRenderer.tsx +94 -19
  19. package/src/components/ListView.tsx +33 -20
  20. package/src/components/MediaGallery.tsx +219 -194
  21. package/src/components/PluginsManager.tsx +197 -70
  22. package/src/components/RestPlayground.tsx +7 -7
  23. package/src/components/SessionsManager.tsx +1 -1
  24. package/src/components/SettingsPage.tsx +22 -0
  25. package/src/components/Sidebar.astro +13 -41
  26. package/src/components/UserManagement.tsx +153 -15
  27. package/src/components/UserMenu.tsx +30 -4
  28. package/src/components/VersionHistoryPanel.tsx +112 -119
  29. package/src/components/WebhookManager.tsx +6 -4
  30. package/src/components/blocks/ArrayBlock.tsx +6 -23
  31. package/src/components/blocks/BlockEditModal.tsx +82 -309
  32. package/src/components/blocks/CardBlock.tsx +35 -0
  33. package/src/components/blocks/ChildBlocksTree.tsx +57 -31
  34. package/src/components/blocks/GenericBlock.tsx +44 -0
  35. package/src/components/blocks/HeadingSubheadingBlock.tsx +32 -0
  36. package/src/components/blocks/HeroBlock.tsx +5 -14
  37. package/src/components/blocks/RichTextBlock.tsx +5 -5
  38. package/src/components/blocks/index.ts +5 -3
  39. package/src/components/fields/AccordionField.tsx +2 -2
  40. package/src/components/fields/ArrayField.tsx +1 -1
  41. package/src/components/fields/ArrayLayout.tsx +120 -29
  42. package/src/components/fields/BlocksField.tsx +430 -50
  43. package/src/components/fields/CardField.tsx +73 -0
  44. package/src/components/fields/CheckboxField.tsx +7 -3
  45. package/src/components/fields/DateField.tsx +4 -1
  46. package/src/components/fields/GroupLayout.tsx +2 -2
  47. package/src/components/fields/HeadingSubheadingField.tsx +43 -0
  48. package/src/components/fields/ListField.tsx +2 -2
  49. package/src/components/fields/NumberField.tsx +4 -1
  50. package/src/components/fields/RelationshipField.tsx +153 -87
  51. package/src/components/fields/RichTextField.tsx +781 -0
  52. package/src/components/fields/SecretField.tsx +102 -0
  53. package/src/components/fields/SelectField.tsx +19 -6
  54. package/src/components/fields/TabsLayout.tsx +19 -9
  55. package/src/components/fields/TextField.tsx +4 -1
  56. package/src/components/fields/UploadField.tsx +122 -56
  57. package/src/components/fields/extensions/blockComponents.tsx +103 -174
  58. package/src/components/fields/extensions/blocksStore.ts +8 -1
  59. package/src/components/fields/index.ts +4 -2
  60. package/src/components/ui/PageHeader.tsx +5 -5
  61. package/src/components/ui/SlidePanel.tsx +8 -3
  62. package/src/components/ui/icons.tsx +109 -109
  63. package/src/components/users/UserDetail.tsx +79 -16
  64. package/src/hooks/useAutoFormState.ts +125 -62
  65. package/src/integration.ts +148 -46
  66. package/src/kyro-cms.d.ts +7 -2
  67. package/src/layouts/AuthLayout.astro +14 -2
  68. package/src/lib/autoform-store.ts +85 -52
  69. package/src/lib/change-source.ts +9 -0
  70. package/src/lib/config.ts +104 -8
  71. package/src/lib/globals.ts +44 -9
  72. package/src/lib/normalize-upload-fields.ts +41 -0
  73. package/src/lib/paths.ts +2 -2
  74. package/src/lib/resolve-field-value.ts +110 -0
  75. package/src/lib/shim/use-sync-external-store-with-selector.js +30 -0
  76. package/src/lib/shim/use-sync-external-store.js +1 -0
  77. package/src/lib/stores/index.ts +1 -0
  78. package/src/lib/useResourceManager.ts +4 -4
  79. package/src/lib/vite-shim-plugin.ts +100 -0
  80. package/src/pages/[collection]/[id].astro +1 -1
  81. package/src/pages/preview/[collection]/[id].astro +4 -4
  82. package/src/pages/settings/[slug].astro +2 -2
  83. package/src/styles/main.css +60 -54
  84. package/README.md +0 -46
  85. package/dist/EditorClient-Q23UXR37.cjs +0 -468
  86. package/dist/EditorClient-Q23UXR37.cjs.map +0 -1
  87. package/dist/EditorClient-T5PASFNR.js +0 -466
  88. package/dist/EditorClient-T5PASFNR.js.map +0 -1
  89. package/dist/chunk-3BGDYKTD.cjs +0 -348
  90. package/dist/chunk-3BGDYKTD.cjs.map +0 -1
  91. package/dist/chunk-EEFXLQVT.js +0 -3
  92. package/dist/chunk-EEFXLQVT.js.map +0 -1
  93. package/src/components/blocks/ButtonBlock.tsx +0 -64
  94. package/src/components/blocks/ColumnsBlock.tsx +0 -55
  95. package/src/components/blocks/DividerBlock.tsx +0 -43
  96. package/src/components/blocks/LinkBlock.tsx +0 -65
  97. package/src/components/blocks/VStackBlock.tsx +0 -29
  98. package/src/components/fields/EditorClient.tsx +0 -535
  99. package/src/components/fields/PortableTextField.tsx +0 -155
  100. package/src/components/fields/PortableTextRenderer.tsx +0 -68
@@ -0,0 +1,102 @@
1
+ import { useState } from "react";
2
+ import type { SecretField as SecretFieldType } from "@kyro-cms/core/client";
3
+ import { IconCopy, IconCheck, IconRefreshCw } from "../ui/icons";
4
+ import FieldLayout from "./FieldLayout";
5
+
6
+ interface SecretFieldComponentProps {
7
+ field: SecretFieldType;
8
+ value?: string | null;
9
+ onChange?: (value: string) => void;
10
+ error?: string;
11
+ disabled?: boolean;
12
+ }
13
+
14
+ export default function SecretField({
15
+ field,
16
+ value,
17
+ onChange,
18
+ error,
19
+ disabled,
20
+ }: SecretFieldComponentProps) {
21
+ const [copied, setCopied] = useState(false);
22
+ const [regenerating, setRegenerating] = useState(false);
23
+
24
+ const fullValue = value ?? "";
25
+ const displayValue = fullValue.length > 8
26
+ ? fullValue.slice(0, -8) + "*".repeat(8)
27
+ : fullValue;
28
+
29
+ const handleCopy = async () => {
30
+ if (!fullValue) return;
31
+ try {
32
+ await navigator.clipboard.writeText(fullValue);
33
+ setCopied(true);
34
+ setTimeout(() => setCopied(false), 1800);
35
+ } catch {
36
+ const ta = document.createElement("textarea");
37
+ ta.value = fullValue;
38
+ ta.style.position = "fixed";
39
+ ta.style.opacity = "0";
40
+ document.body.appendChild(ta);
41
+ ta.select();
42
+ document.execCommand("copy");
43
+ document.body.removeChild(ta);
44
+ setCopied(true);
45
+ setTimeout(() => setCopied(false), 1800);
46
+ }
47
+ };
48
+
49
+ const handleRegenerate = () => {
50
+ if (regenerating || disabled) return;
51
+ setRegenerating(true);
52
+ const bytes = new Uint8Array(32);
53
+ crypto.getRandomValues(bytes);
54
+ const hex = Array.from(bytes)
55
+ .map((b) => b.toString(16).padStart(2, "0"))
56
+ .join("");
57
+ onChange?.(hex);
58
+ setTimeout(() => setRegenerating(false), 400);
59
+ };
60
+
61
+ return (
62
+ <FieldLayout field={field} error={error}>
63
+ <div className="flex items-center gap-1.5">
64
+ <div className="relative flex-1">
65
+ <input
66
+ id={field.name}
67
+ type="text"
68
+ value={displayValue}
69
+ readOnly
70
+ disabled={disabled}
71
+ className="kyro-form-input font-mono text-xs tracking-wider pr-10 opacity-70 bg-[var(--kyro-bg-secondary)] cursor-not-allowed select-none"
72
+ spellCheck={false}
73
+ />
74
+ </div>
75
+ <button
76
+ type="button"
77
+ onClick={handleCopy}
78
+ disabled={!fullValue || disabled}
79
+ className="p-2 rounded-lg border border-[var(--kyro-border)] bg-[var(--kyro-surface)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] hover:border-[var(--kyro-primary)] hover:bg-[var(--kyro-primary-alpha)] transition-all disabled:opacity-40 disabled:cursor-not-allowed active:scale-95"
80
+ title={copied ? "Copied!" : "Copy full secret"}
81
+ >
82
+ {copied ? (
83
+ <IconCheck className="w-3.5 h-3.5 text-[var(--kyro-success)]" />
84
+ ) : (
85
+ <IconCopy className="w-3.5 h-3.5" />
86
+ )}
87
+ </button>
88
+ <button
89
+ type="button"
90
+ onClick={handleRegenerate}
91
+ disabled={disabled}
92
+ className="p-2 rounded-lg border border-[var(--kyro-border)] bg-[var(--kyro-surface)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] hover:border-[var(--kyro-warning)] hover:bg-[var(--kyro-warning)]/10 transition-all disabled:opacity-40 disabled:cursor-not-allowed active:scale-95"
93
+ title="Regenerate secret"
94
+ >
95
+ <IconRefreshCw
96
+ className={`w-3.5 h-3.5 ${regenerating ? "animate-spin" : ""}`}
97
+ />
98
+ </button>
99
+ </div>
100
+ </FieldLayout>
101
+ );
102
+ }
@@ -1,5 +1,6 @@
1
1
  import type { SelectField as SelectFieldType } from "@kyro-cms/core/client";
2
2
  import FieldLayout from "./FieldLayout";
3
+ import { collections } from "../../lib/config";
3
4
 
4
5
  interface SelectFieldComponentProps {
5
6
  field: SelectFieldType;
@@ -16,7 +17,21 @@ export default function SelectField({
16
17
  error,
17
18
  disabled,
18
19
  }: SelectFieldComponentProps) {
19
- const isReadOnly = field.admin?.readOnly;
20
+ const isReadOnly =
21
+ typeof field.admin?.readOnly === "function"
22
+ ? false
23
+ : Boolean(field.admin?.readOnly);
24
+
25
+ // Resolve dynamic options at runtime if configured
26
+ let options = field.options || [];
27
+ if (field.dynamicOptions === "collections") {
28
+ options = Object.keys(collections)
29
+ .filter((slug) => slug !== "media")
30
+ .map((slug) => ({
31
+ label: collections[slug]?.label || slug,
32
+ value: slug,
33
+ }));
34
+ }
20
35
 
21
36
  return (
22
37
  <FieldLayout
@@ -27,14 +42,12 @@ export default function SelectField({
27
42
  id={field.name}
28
43
  value={
29
44
  field.hasMany
30
- ? Array.isArray(value)
31
- ? value.join(",")
32
- : ""
45
+ ? (Array.isArray(value) ? value : [])
33
46
  : value || ""
34
47
  }
35
48
  onChange={(e) => {
36
49
  if (field.hasMany) {
37
- const selected = e.target.value ? e.target.value.split(",") : [];
50
+ const selected = Array.from(e.target.selectedOptions, (opt) => opt.value);
38
51
  onChange?.(selected);
39
52
  } else {
40
53
  onChange?.(e.target.value || undefined);
@@ -48,7 +61,7 @@ export default function SelectField({
48
61
  }`}
49
62
  >
50
63
  {!field.required && !field.hasMany && <option value="">Select...</option>}
51
- {field.options?.map((option) => (
64
+ {options.map((option) => (
52
65
  <option key={option.value} value={option.value}>
53
66
  {option.label}
54
67
  </option>
@@ -5,7 +5,7 @@ import { SeoPreview } from "../ui/SeoPreview";
5
5
  interface TabsLayoutProps {
6
6
  field: Field;
7
7
  formData: Record<string, unknown>;
8
- onTabDataChange: (tabData: Record<string, unknown>) => void;
8
+ onTabDataChange: (value: unknown) => void;
9
9
  renderField: (
10
10
  field: Field,
11
11
  parentData: Record<string, unknown>,
@@ -24,8 +24,10 @@ export function TabsLayout({
24
24
  const fieldTabs = (field as Field & { tabs?: { label: string; fields: Field[] }[] }).tabs || [];
25
25
  const currentTab = fieldTabs[activeTab] || fieldTabs[0];
26
26
 
27
- // Get nested tab data
28
- const tabData = formData[field.name as string] || {};
27
+ // Tab data is stored nested under field.name when present
28
+ const tabData: Record<string, unknown> = field.name
29
+ ? (formData[field.name] as Record<string, unknown>) || {}
30
+ : formData;
29
31
 
30
32
  return (
31
33
  <div className="space-y-8">
@@ -34,7 +36,7 @@ export function TabsLayout({
34
36
  <button
35
37
  key={index}
36
38
  type="button"
37
- className={`px-6 py-3 text-xs tracking-widest font-bold transition-all border-b-2 -mb-[1px] whitespace-nowrap ${activeTab === index
39
+ className={`px-6 py-3 text-sm tracking-widest font-medium transition-all border-b-2 -mb-[1px] whitespace-nowrap ${activeTab === index
38
40
  ? "border-[var(--kyro-primary)] text-[var(--kyro-primary)]"
39
41
  : "border-transparent text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] opacity-60 hover:opacity-100"
40
42
  }`}
@@ -56,11 +58,19 @@ export function TabsLayout({
56
58
  Live Google Preview
57
59
  </h4>
58
60
  <SeoPreview
59
- title={formData.metaTitle || formData.title || "Untitled"}
60
- description={
61
- formData.metaDescription || "Please enter a description..."
62
- }
63
- slug={formData.slug || "your-slug"}
61
+ title={String(
62
+ (typeof tabData.metaTitle === "object" ? "" : tabData.metaTitle) ||
63
+ (typeof tabData.title === "object" ? "" : tabData.title) ||
64
+ "Untitled"
65
+ )}
66
+ description={String(
67
+ (typeof tabData.metaDescription === "object" ? "" : tabData.metaDescription) ||
68
+ "Please enter a description..."
69
+ )}
70
+ slug={String(
71
+ (typeof formData.slug === "object" ? "" : formData.slug) ||
72
+ "your-slug"
73
+ )}
64
74
  />
65
75
  </div>
66
76
  )}
@@ -18,7 +18,10 @@ export default function TextField({
18
18
  error,
19
19
  disabled,
20
20
  }: TextFieldComponentProps) {
21
- const isReadOnly = field.admin?.readOnly;
21
+ const isReadOnly =
22
+ typeof field.admin?.readOnly === "function"
23
+ ? false
24
+ : Boolean(field.admin?.readOnly);
22
25
  const isTextarea = (field as TextFieldType).variant === "textarea";
23
26
  const isSlug = field.name === "slug";
24
27
 
@@ -1,6 +1,6 @@
1
1
  import React, { useState, useEffect, useRef, useMemo } from "react";
2
2
  import { createPortal } from "react-dom";
3
- import { Image as ImageIcon, Film, FileText, Music, File, X, Loader2 } from "../ui/icons";
3
+ import { Image as ImageIcon, Film, FileText, Music, File, X, Loader2, Check } from "../ui/icons";
4
4
  import { apiGet, withCacheBust, apiPost, apiUpload, resolveApi, resolveMedia } from "../../lib/api";
5
5
  import { toast } from "../../lib/stores";
6
6
 
@@ -88,9 +88,10 @@ export function UploadField({
88
88
  const [showUrlInput, setShowUrlInput] = useState(false);
89
89
  const [urlValue, setUrlValue] = useState("");
90
90
  const [urlError, setUrlError] = useState("");
91
+ const [selectedItems, setSelectedItems] = useState<MediaItem[]>([]);
91
92
 
92
93
  const fieldLabel = field?.label || field?.name || "File";
93
- const maxCount = field.maxCount || 1;
94
+ const maxCount = field.maxCount ?? (field.hasMany ? 999 : 1);
94
95
  const isMultiple = maxCount > 1;
95
96
  const currentValue = Array.isArray(value) ? value : value ? [value] : [];
96
97
  const canAddMore = currentValue.length < maxCount;
@@ -98,21 +99,38 @@ export function UploadField({
98
99
  useEffect(() => {
99
100
  const fetchMissingDetails = async () => {
100
101
  const idsToFetch = currentValue
101
- .filter(item => typeof item === 'string')
102
- .map(id => id as string);
102
+ .filter((item): item is string => typeof item === 'string')
103
+ .map(id => id);
103
104
 
104
- if (idsToFetch.length === 0) return;
105
+ const objectIdsToFetch = currentValue
106
+ .filter((item): item is Record<string, unknown> =>
107
+ typeof item === 'object' && item !== null &&
108
+ typeof item.id === 'string' &&
109
+ !item.url && !item.filename && !item.mimeType
110
+ )
111
+ .map(item => item.id as string);
112
+
113
+ const allIds = [...idsToFetch, ...objectIdsToFetch];
114
+ if (allIds.length === 0) return;
105
115
 
106
116
  try {
107
117
  const fetchedItems = await Promise.all(
108
- idsToFetch.map(id => apiGet<any>(`/api/media/${id}`))
118
+ allIds.map(id => apiGet<any>(`/api/media/${id}`))
109
119
  );
110
120
 
111
121
  const newItems = [...currentValue];
112
122
  fetchedItems.forEach(fetchedItem => {
113
- const index = newItems.findIndex(item => item === (fetchedItem.id || fetchedItem));
114
- if (index !== -1) {
115
- newItems[index] = fetchedItem;
123
+ const id = fetchedItem.id as string;
124
+ const strIndex = newItems.findIndex(item => item === id);
125
+ if (strIndex !== -1) {
126
+ newItems[strIndex] = fetchedItem;
127
+ return;
128
+ }
129
+ const objIndex = newItems.findIndex(
130
+ item => typeof item === 'object' && item !== null && (item as any).id === id
131
+ );
132
+ if (objIndex !== -1) {
133
+ newItems[objIndex] = fetchedItem;
116
134
  }
117
135
  });
118
136
 
@@ -183,6 +201,7 @@ export function UploadField({
183
201
  toast.success(`Asset synchronized: ${newImage.filename}`);
184
202
  } catch (err) {
185
203
  console.error("Upload failed:", err);
204
+ toast.error(`Upload failed: ${err instanceof Error ? err.message : "Unknown error"}`);
186
205
  } finally {
187
206
  setUploading(false);
188
207
  }
@@ -227,18 +246,33 @@ export function UploadField({
227
246
  }
228
247
  };
229
248
 
249
+ const toMediaObj = (item: MediaItem) => ({
250
+ id: item.id,
251
+ filename: item.filename,
252
+ url: item.url,
253
+ mimeType: item.mimeType,
254
+ });
255
+
230
256
  const selectFromLibrary = (item: MediaItem) => {
231
- const newImage = {
232
- id: item.id,
233
- filename: item.filename,
234
- url: item.url,
235
- mimeType: item.mimeType,
236
- };
237
257
  if (isMultiple) {
238
- onChange([...currentValue, newImage]);
258
+ setSelectedItems(prev => {
259
+ const exists = prev.find(i => i.id === item.id);
260
+ if (exists) return prev.filter(i => i.id !== item.id);
261
+ return [...prev, item];
262
+ });
239
263
  } else {
240
- onChange(newImage);
264
+ onChange(toMediaObj(item));
265
+ setShowPicker(false);
266
+ setPickerSearch("");
241
267
  }
268
+ };
269
+
270
+ const handleDone = () => {
271
+ if (selectedItems.length > 0) {
272
+ const newItems = [...currentValue, ...selectedItems.map(toMediaObj)];
273
+ onChange(newItems);
274
+ }
275
+ setSelectedItems([]);
242
276
  setShowPicker(false);
243
277
  setPickerSearch("");
244
278
  };
@@ -324,7 +358,10 @@ export function UploadField({
324
358
  {canAddMore && (
325
359
  <button
326
360
  type="button"
327
- onClick={() => inputRef.current?.click()}
361
+ onClick={() => {
362
+ setSelectedItems([]);
363
+ setShowPicker(true);
364
+ }}
328
365
  disabled={disabled}
329
366
  className="flex items-center justify-center h-12 border-2 border-dashed border-[var(--kyro-border)] rounded-lg text-sm text-[var(--kyro-text-secondary)] hover:border-[var(--kyro-border-active)] cursor-pointer transition-colors"
330
367
  >
@@ -374,7 +411,10 @@ export function UploadField({
374
411
  </button>
375
412
  <button
376
413
  type="button"
377
- onClick={() => setShowPicker(true)}
414
+ onClick={() => {
415
+ setSelectedItems([]);
416
+ setShowPicker(true);
417
+ }}
378
418
  disabled={disabled}
379
419
  className="px-3 py-1.5 text-xs font-semibold rounded border border-[var(--kyro-border)] bg-[var(--kyro-surface)] text-[var(--kyro-text-secondary)] cursor-pointer hover:border-[var(--kyro-border-active)] transition-colors"
380
420
  >
@@ -409,7 +449,7 @@ export function UploadField({
409
449
  type="button"
410
450
  onClick={addByUrl}
411
451
  disabled={disabled || !urlValue.trim()}
412
- className="px-3 py-1.5 text-xs rounded bg-[var(--kyro-primary)] text-white cursor-pointer hover:opacity-90 transition-opacity disabled:opacity-50"
452
+ className="kyro-btn kyro-btn-primary px-3 py-1.5 text-xs rounded cursor-pointer hover:opacity-90 transition-opacity disabled:opacity-50"
413
453
  >
414
454
  Add
415
455
  </button>
@@ -424,6 +464,8 @@ export function UploadField({
424
464
  createPortal(
425
465
  <MediaPickerContent
426
466
  isFullscreen
467
+ isMultiple={isMultiple}
468
+ selectedItems={selectedItems}
427
469
  pickerSearch={pickerSearch}
428
470
  setPickerSearch={setPickerSearch}
429
471
  folders={folders}
@@ -432,6 +474,7 @@ export function UploadField({
432
474
  mediaLoading={mediaLoading}
433
475
  filteredMedia={filteredMedia}
434
476
  selectFromLibrary={selectFromLibrary}
477
+ onDone={handleDone}
435
478
  setIsPickerFullscreen={setIsPickerFullscreen}
436
479
  setShowPicker={setShowPicker}
437
480
  />,
@@ -440,6 +483,8 @@ export function UploadField({
440
483
  ) : (
441
484
  <MediaPickerContent
442
485
  isFullscreen={false}
486
+ isMultiple={isMultiple}
487
+ selectedItems={selectedItems}
443
488
  pickerSearch={pickerSearch}
444
489
  setPickerSearch={setPickerSearch}
445
490
  folders={folders}
@@ -448,6 +493,7 @@ export function UploadField({
448
493
  mediaLoading={mediaLoading}
449
494
  filteredMedia={filteredMedia}
450
495
  selectFromLibrary={selectFromLibrary}
496
+ onDone={handleDone}
451
497
  setIsPickerFullscreen={setIsPickerFullscreen}
452
498
  setShowPicker={setShowPicker}
453
499
  />
@@ -458,6 +504,8 @@ export function UploadField({
458
504
 
459
505
  function MediaPickerContent({
460
506
  isFullscreen,
507
+ isMultiple,
508
+ selectedItems,
461
509
  pickerSearch,
462
510
  setPickerSearch,
463
511
  folders,
@@ -466,10 +514,13 @@ function MediaPickerContent({
466
514
  mediaLoading,
467
515
  filteredMedia,
468
516
  selectFromLibrary,
517
+ onDone,
469
518
  setIsPickerFullscreen,
470
519
  setShowPicker,
471
520
  }: {
472
521
  isFullscreen: boolean;
522
+ isMultiple: boolean;
523
+ selectedItems: MediaItem[];
473
524
  pickerSearch: string;
474
525
  setPickerSearch: (v: string) => void;
475
526
  folders: MediaFolder[];
@@ -478,14 +529,17 @@ function MediaPickerContent({
478
529
  mediaLoading: boolean;
479
530
  filteredMedia: MediaItem[];
480
531
  selectFromLibrary: (item: MediaItem) => void;
532
+ onDone: () => void;
481
533
  setIsPickerFullscreen: (v: boolean) => void;
482
534
  setShowPicker: (v: boolean) => void;
483
535
  }) {
536
+ const isItemSelected = (id: string) => selectedItems.some(i => i.id === id);
537
+
484
538
  return (
485
539
  <div
486
540
  className={`${isFullscreen
487
541
  ? "fixed inset-0 z-[9999]"
488
- : "absolute z-[9999] w-[360px] max-h-[400px] mt-1 rounded-lg shadow-lg"
542
+ : "relative z-[9999] w-[360px] max-h-[400px] mt-1 rounded-lg shadow-lg"
489
543
  } overflow-hidden bg-[var(--kyro-surface)] border border-[var(--kyro-border)] flex flex-col`}
490
544
  >
491
545
  <div className="p-2 border-b border-[var(--kyro-border)] flex flex-col gap-2">
@@ -501,8 +555,8 @@ function MediaPickerContent({
501
555
  <button
502
556
  type="button"
503
557
  onClick={() => setSelectedFolder("")}
504
- className={`px-2 py-1 text-xs rounded transition-colors ${selectedFolder === ""
505
- ? "bg-[var(--kyro-primary)] text-white"
558
+ className={`kyro-btn-primary px-2 py-1 text-xs rounded transition-colors ${selectedFolder === ""
559
+ ? ""
506
560
  : "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-border)]"
507
561
  }`}
508
562
  >
@@ -513,8 +567,8 @@ function MediaPickerContent({
513
567
  key={folder.path}
514
568
  type="button"
515
569
  onClick={() => setSelectedFolder(folder.path)}
516
- className={`px-2 py-1 text-xs rounded transition-colors ${selectedFolder === folder.path
517
- ? "bg-[var(--kyro-primary)] text-white"
570
+ className={`kyro-btn-primary px-2 py-1 text-xs rounded transition-colors ${selectedFolder === folder.path
571
+ ? ""
518
572
  : "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-border)]"
519
573
  }`}
520
574
  >
@@ -542,40 +596,43 @@ function MediaPickerContent({
542
596
  : "grid-cols-3"
543
597
  }`}
544
598
  >
545
- {filteredMedia.map((item) => (
546
- <button
547
- key={item.id}
548
- type="button"
549
- onClick={() => selectFromLibrary(item)}
550
- className="border border-[var(--kyro-border)] rounded-md overflow-hidden cursor-pointer p-0 bg-[var(--kyro-surface)] hover:border-[var(--kyro-primary)] transition-all relative group"
551
- >
552
- <div
553
- className={`w-full flex items-center justify-center bg-[var(--kyro-surface-accent)] ${isFullscreen ? "h-[120px]" : "h-[80px]"
599
+ {filteredMedia.map((item) => {
600
+ const selected = isItemSelected(item.id);
601
+ return (
602
+ <button
603
+ key={item.id}
604
+ type="button"
605
+ onClick={() => selectFromLibrary(item)}
606
+ className={`border rounded-md overflow-hidden cursor-pointer p-0 bg-[var(--kyro-surface)] transition-all relative group ${selected
607
+ ? "border-[var(--kyro-primary)] ring-2 ring-[var(--kyro-primary)]"
608
+ : "border-[var(--kyro-border)] hover:border-[var(--kyro-primary)]"
554
609
  }`}
555
610
  >
556
- {getFileType(item.mimeType, item.filename) === "image" ? (
557
- <img
558
- src={resolveMedia(item.thumbnailUrl || item.url)}
559
- alt={item.filename}
560
- className="w-full h-full object-cover"
561
- />
562
- ) : (
563
- <FileIcon
564
- type={getFileType(item.mimeType, item.filename)}
565
- className={isFullscreen ? "w-10 h-10" : "w-8 h-8"}
566
- />
611
+ <div
612
+ className={`w-full flex items-center justify-center bg-[var(--kyro-surface-accent)] ${isFullscreen ? "h-[120px]" : "h-[80px]"
613
+ }`}
614
+ >
615
+ {getFileType(item.mimeType, item.filename) === "image" ? (
616
+ <img
617
+ src={resolveMedia(item.thumbnailUrl || item.url)}
618
+ alt={item.filename}
619
+ className="w-full h-full object-cover"
620
+ />
621
+ ) : (
622
+ <FileIcon
623
+ type={getFileType(item.mimeType, item.filename)}
624
+ className={isFullscreen ? "w-10 h-10" : "w-8 h-8"}
625
+ />
626
+ )}
627
+ </div>
628
+ {isMultiple && selected && (
629
+ <div className="absolute top-1 right-1 w-5 h-5 rounded-full bg-[var(--kyro-primary)] flex items-center justify-center">
630
+ <Check className="w-3 h-3 text-white" />
631
+ </div>
567
632
  )}
568
- </div>
569
- <div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-0 transition-opacity flex flex-col items-center justify-center p-2">
570
- <span className="text-white text-[10px] font-medium text-center line-clamp-2 mb-1">
571
- {item.filename}
572
- </span>
573
- <span className="text-white/70 text-[9px] font-bold tracking-tighter">
574
- {getFileType(item.mimeType, item.filename)}
575
- </span>
576
- </div>
577
- </button>
578
- ))}
633
+ </button>
634
+ );
635
+ })}
579
636
  </div>
580
637
  )}
581
638
  </div>
@@ -584,6 +641,15 @@ function MediaPickerContent({
584
641
  {filteredMedia.length} items
585
642
  </span>
586
643
  <div className="flex gap-2 items-center">
644
+ {isMultiple && (
645
+ <button
646
+ type="button"
647
+ onClick={onDone}
648
+ className="kyro-btn kyro-btn-primary px-3 py-1 text-xs font-semibold rounded cursor-pointer hover:opacity-90 transition-opacity"
649
+ >
650
+ Done{selectedItems.length > 0 ? ` (${selectedItems.length})` : ""}
651
+ </button>
652
+ )}
587
653
  <button
588
654
  type="button"
589
655
  onClick={() => setIsPickerFullscreen(!isFullscreen)}