@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.
- package/dist/index.cjs +11960 -11006
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +67 -65
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +563 -0
- package/dist/index.d.ts +7 -7
- package/dist/index.js +12183 -11238
- package/dist/index.js.map +1 -1
- package/package.json +15 -11
- package/src/components/ActionBar.tsx +27 -14
- package/src/components/Admin.tsx +1 -1
- package/src/components/ApiKeysManager.tsx +5 -5
- package/src/components/AutoForm.tsx +585 -369
- package/src/components/BrandingHub.tsx +7 -4
- package/src/components/CreateView.tsx +2 -0
- package/src/components/DetailView.tsx +71 -56
- package/src/components/DeveloperCenter.tsx +8 -6
- package/src/components/FieldRenderer.tsx +94 -19
- package/src/components/ListView.tsx +33 -20
- package/src/components/MediaGallery.tsx +219 -194
- package/src/components/PluginsManager.tsx +197 -70
- package/src/components/RestPlayground.tsx +7 -7
- package/src/components/SessionsManager.tsx +1 -1
- package/src/components/SettingsPage.tsx +22 -0
- package/src/components/Sidebar.astro +13 -41
- package/src/components/UserManagement.tsx +153 -15
- package/src/components/UserMenu.tsx +30 -4
- package/src/components/VersionHistoryPanel.tsx +112 -119
- package/src/components/WebhookManager.tsx +6 -4
- package/src/components/blocks/ArrayBlock.tsx +6 -23
- package/src/components/blocks/BlockEditModal.tsx +82 -309
- package/src/components/blocks/CardBlock.tsx +35 -0
- package/src/components/blocks/ChildBlocksTree.tsx +57 -31
- package/src/components/blocks/GenericBlock.tsx +44 -0
- package/src/components/blocks/HeadingSubheadingBlock.tsx +32 -0
- package/src/components/blocks/HeroBlock.tsx +5 -14
- package/src/components/blocks/RichTextBlock.tsx +5 -5
- package/src/components/blocks/index.ts +5 -3
- package/src/components/fields/AccordionField.tsx +2 -2
- package/src/components/fields/ArrayField.tsx +1 -1
- package/src/components/fields/ArrayLayout.tsx +120 -29
- package/src/components/fields/BlocksField.tsx +430 -50
- package/src/components/fields/CardField.tsx +73 -0
- package/src/components/fields/CheckboxField.tsx +7 -3
- package/src/components/fields/DateField.tsx +4 -1
- package/src/components/fields/GroupLayout.tsx +2 -2
- package/src/components/fields/HeadingSubheadingField.tsx +43 -0
- package/src/components/fields/ListField.tsx +2 -2
- package/src/components/fields/NumberField.tsx +4 -1
- package/src/components/fields/RelationshipField.tsx +153 -87
- package/src/components/fields/RichTextField.tsx +781 -0
- package/src/components/fields/SecretField.tsx +102 -0
- package/src/components/fields/SelectField.tsx +19 -6
- package/src/components/fields/TabsLayout.tsx +19 -9
- package/src/components/fields/TextField.tsx +4 -1
- package/src/components/fields/UploadField.tsx +122 -56
- package/src/components/fields/extensions/blockComponents.tsx +103 -174
- package/src/components/fields/extensions/blocksStore.ts +8 -1
- package/src/components/fields/index.ts +4 -2
- package/src/components/ui/PageHeader.tsx +5 -5
- package/src/components/ui/SlidePanel.tsx +8 -3
- package/src/components/ui/icons.tsx +109 -109
- package/src/components/users/UserDetail.tsx +79 -16
- package/src/hooks/useAutoFormState.ts +125 -62
- package/src/integration.ts +148 -46
- package/src/kyro-cms.d.ts +7 -2
- package/src/layouts/AuthLayout.astro +14 -2
- package/src/lib/autoform-store.ts +85 -52
- package/src/lib/change-source.ts +9 -0
- package/src/lib/config.ts +104 -8
- package/src/lib/globals.ts +44 -9
- package/src/lib/normalize-upload-fields.ts +41 -0
- package/src/lib/paths.ts +2 -2
- package/src/lib/resolve-field-value.ts +110 -0
- package/src/lib/shim/use-sync-external-store-with-selector.js +30 -0
- package/src/lib/shim/use-sync-external-store.js +1 -0
- package/src/lib/stores/index.ts +1 -0
- package/src/lib/useResourceManager.ts +4 -4
- package/src/lib/vite-shim-plugin.ts +100 -0
- package/src/pages/[collection]/[id].astro +1 -1
- package/src/pages/preview/[collection]/[id].astro +4 -4
- package/src/pages/settings/[slug].astro +2 -2
- package/src/styles/main.css +60 -54
- package/README.md +0 -46
- package/dist/EditorClient-Q23UXR37.cjs +0 -468
- package/dist/EditorClient-Q23UXR37.cjs.map +0 -1
- package/dist/EditorClient-T5PASFNR.js +0 -466
- package/dist/EditorClient-T5PASFNR.js.map +0 -1
- package/dist/chunk-3BGDYKTD.cjs +0 -348
- package/dist/chunk-3BGDYKTD.cjs.map +0 -1
- package/dist/chunk-EEFXLQVT.js +0 -3
- package/dist/chunk-EEFXLQVT.js.map +0 -1
- package/src/components/blocks/ButtonBlock.tsx +0 -64
- package/src/components/blocks/ColumnsBlock.tsx +0 -55
- package/src/components/blocks/DividerBlock.tsx +0 -43
- package/src/components/blocks/LinkBlock.tsx +0 -65
- package/src/components/blocks/VStackBlock.tsx +0 -29
- package/src/components/fields/EditorClient.tsx +0 -535
- package/src/components/fields/PortableTextField.tsx +0 -155
- 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 =
|
|
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.
|
|
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
|
-
{
|
|
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: (
|
|
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
|
-
//
|
|
28
|
-
const tabData =
|
|
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-
|
|
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={
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 =
|
|
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
|
|
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
|
|
102
|
+
.filter((item): item is string => typeof item === 'string')
|
|
103
|
+
.map(id => id);
|
|
103
104
|
|
|
104
|
-
|
|
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
|
-
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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(
|
|
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={() =>
|
|
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={() =>
|
|
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
|
|
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
|
-
: "
|
|
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
|
-
? "
|
|
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
|
-
? "
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
</
|
|
569
|
-
|
|
570
|
-
|
|
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)}
|