@kyro-cms/admin 0.9.0 → 0.9.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/dist/index.cjs +11715 -11292
- 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 +564 -0
- package/dist/index.d.ts +11 -10
- package/dist/index.js +11326 -10912
- package/dist/index.js.map +1 -1
- package/package.json +16 -12
- package/src/components/ActionBar.tsx +25 -161
- package/src/components/Admin.tsx +2 -4
- package/src/components/ApiKeysManager.tsx +5 -5
- package/src/components/AuditLogsPage.tsx +2 -13
- package/src/components/AutoForm.tsx +572 -461
- package/src/components/BrandingHub.tsx +7 -4
- package/src/components/CreateView.tsx +2 -0
- package/src/components/DetailView.tsx +52 -65
- package/src/components/DeveloperCenter.tsx +8 -6
- package/src/components/FieldRenderer.tsx +94 -19
- package/src/components/ListView.tsx +57 -216
- package/src/components/MediaGallery.tsx +334 -367
- package/src/components/PluginsManager.tsx +197 -70
- package/src/components/RestPlayground.tsx +59 -52
- 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 +433 -55
- 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/RelationshipBlockField.tsx +2 -3
- package/src/components/fields/RelationshipField.tsx +155 -90
- 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/fix_imports.cjs +23 -0
- package/src/components/fix_imports2.cjs +19 -0
- package/src/components/replace_svgs.cjs +63 -0
- package/src/components/ui/Dropdown.tsx +7 -2
- package/src/components/ui/Modal.tsx +24 -27
- package/src/components/ui/PageHeader.tsx +5 -5
- package/src/components/ui/PromptModal.tsx +2 -10
- package/src/components/ui/SlidePanel.tsx +10 -13
- package/src/components/ui/SplitButton.tsx +107 -0
- package/src/components/ui/Toaster.tsx +0 -1
- package/src/components/ui/icons.tsx +110 -109
- package/src/components/users/UserDetail.tsx +79 -16
- package/src/components/users/UsersList.tsx +8 -85
- package/src/hooks/useAutoFormState.ts +187 -196
- package/src/hooks/useQueue.ts +60 -0
- package/src/integration.ts +148 -46
- package/src/kyro-cms.d.ts +7 -2
- package/src/layouts/AdminLayout.astro +22 -2
- package/src/layouts/AuthLayout.astro +67 -7
- package/src/lib/autoform-store.ts +90 -53
- package/src/lib/change-source.ts +9 -0
- package/src/lib/config.ts +104 -8
- package/src/lib/globals.ts +48 -11
- 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/auth/register.astro +5 -2
- 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,73 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
interface CardFieldProps {
|
|
4
|
+
title?: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
icon?: string;
|
|
7
|
+
link?: string;
|
|
8
|
+
linkText?: string;
|
|
9
|
+
onChange: (field: string, value: string) => void;
|
|
10
|
+
compact?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const CardField: React.FC<CardFieldProps> = ({
|
|
14
|
+
title = "",
|
|
15
|
+
description = "",
|
|
16
|
+
icon = "",
|
|
17
|
+
link = "",
|
|
18
|
+
linkText = "",
|
|
19
|
+
onChange,
|
|
20
|
+
compact = false,
|
|
21
|
+
}) => {
|
|
22
|
+
const inputClass = compact
|
|
23
|
+
? "w-full px-2.5 py-1.5 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
|
|
24
|
+
: "w-full px-3 py-2.5 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent";
|
|
25
|
+
|
|
26
|
+
const textareaClass = compact
|
|
27
|
+
? "w-full px-2.5 py-1.5 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] min-h-[50px] resize-none focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
|
|
28
|
+
: "w-full px-3 py-2.5 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] min-h-[80px] resize-none focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent";
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className={compact ? "space-y-2" : "space-y-3"}>
|
|
32
|
+
<input
|
|
33
|
+
type="text"
|
|
34
|
+
value={title}
|
|
35
|
+
onChange={(e) => onChange("title", e.target.value)}
|
|
36
|
+
className={`${inputClass} font-bold text-base`}
|
|
37
|
+
placeholder="Card title..."
|
|
38
|
+
/>
|
|
39
|
+
<textarea
|
|
40
|
+
value={description}
|
|
41
|
+
onChange={(e) => onChange("description", e.target.value)}
|
|
42
|
+
className={textareaClass}
|
|
43
|
+
placeholder="Card description..."
|
|
44
|
+
/>
|
|
45
|
+
<input
|
|
46
|
+
type="text"
|
|
47
|
+
value={icon}
|
|
48
|
+
onChange={(e) => onChange("icon", e.target.value)}
|
|
49
|
+
className={inputClass}
|
|
50
|
+
placeholder="Icon (emoji or name)..."
|
|
51
|
+
/>
|
|
52
|
+
<div className="flex items-center gap-2">
|
|
53
|
+
<input
|
|
54
|
+
type="text"
|
|
55
|
+
value={linkText}
|
|
56
|
+
onChange={(e) => onChange("linkText", e.target.value)}
|
|
57
|
+
className="flex-1 px-2.5 py-1.5 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
|
|
58
|
+
placeholder="Link text..."
|
|
59
|
+
/>
|
|
60
|
+
<span className="text-[var(--kyro-text-muted)] text-xs">→</span>
|
|
61
|
+
<input
|
|
62
|
+
type="url"
|
|
63
|
+
value={link}
|
|
64
|
+
onChange={(e) => onChange("link", e.target.value)}
|
|
65
|
+
className="flex-1 px-2.5 py-1.5 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent font-mono text-xs"
|
|
66
|
+
placeholder="https://..."
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export default CardField;
|
|
@@ -11,12 +11,16 @@ interface CheckboxFieldComponentProps {
|
|
|
11
11
|
|
|
12
12
|
export default function CheckboxField({
|
|
13
13
|
field,
|
|
14
|
-
value
|
|
14
|
+
value,
|
|
15
15
|
onChange,
|
|
16
16
|
error,
|
|
17
17
|
disabled,
|
|
18
18
|
}: CheckboxFieldComponentProps) {
|
|
19
|
-
const isReadOnly =
|
|
19
|
+
const isReadOnly =
|
|
20
|
+
typeof field.admin?.readOnly === "function"
|
|
21
|
+
? false
|
|
22
|
+
: Boolean(field.admin?.readOnly);
|
|
23
|
+
const checked = value ?? false;
|
|
20
24
|
|
|
21
25
|
return (
|
|
22
26
|
<FieldLayout
|
|
@@ -27,7 +31,7 @@ export default function CheckboxField({
|
|
|
27
31
|
<label className="flex items-center gap-2.5 cursor-pointer group py-0.5">
|
|
28
32
|
<input
|
|
29
33
|
type="checkbox"
|
|
30
|
-
checked={
|
|
34
|
+
checked={checked}
|
|
31
35
|
onChange={(e) => onChange?.(e.target.checked)}
|
|
32
36
|
disabled={disabled || isReadOnly}
|
|
33
37
|
className={`w-4 h-4 rounded border-[var(--kyro-border)] text-[var(--kyro-primary)] focus:ring-[var(--kyro-primary)] transition-all ${
|
|
@@ -16,7 +16,10 @@ export default function DateField({
|
|
|
16
16
|
error,
|
|
17
17
|
disabled,
|
|
18
18
|
}: DateFieldComponentProps) {
|
|
19
|
-
const isReadOnly =
|
|
19
|
+
const isReadOnly =
|
|
20
|
+
typeof field.admin?.readOnly === "function"
|
|
21
|
+
? false
|
|
22
|
+
: Boolean(field.admin?.readOnly);
|
|
20
23
|
|
|
21
24
|
return (
|
|
22
25
|
<FieldLayout
|
|
@@ -22,10 +22,10 @@ export function GroupLayout({
|
|
|
22
22
|
|
|
23
23
|
return (
|
|
24
24
|
<div className="kyro-form-group border border-[var(--kyro-border)] rounded-[var(--kyro-radius-lg)] p-6 bg-[var(--kyro-surface-accent)]/30">
|
|
25
|
-
<h3 className="text-sm font-bold
|
|
25
|
+
<h3 className="text-sm font-bold tracking-widest text-[var(--kyro-text-primary)] mb-6 border-b border-[var(--kyro-border)] pb-2 inline-block">
|
|
26
26
|
{field.label || field.name}
|
|
27
27
|
</h3>
|
|
28
|
-
<div className="space-y-6">
|
|
28
|
+
<div className={field.admin?.inline ? "flex items-start gap-4" : "space-y-6"}>
|
|
29
29
|
{(field as Field & { fields?: Field[] }).fields.map((f: Field) =>
|
|
30
30
|
renderField(f, groupData, onChange),
|
|
31
31
|
)}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
interface HeadingSubheadingFieldProps {
|
|
4
|
+
heading?: string;
|
|
5
|
+
subheading?: string;
|
|
6
|
+
onChange: (field: string, value: string) => void;
|
|
7
|
+
compact?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const HeadingSubheadingField: React.FC<HeadingSubheadingFieldProps> = ({
|
|
11
|
+
heading = "",
|
|
12
|
+
subheading = "",
|
|
13
|
+
onChange,
|
|
14
|
+
compact = false,
|
|
15
|
+
}) => {
|
|
16
|
+
const inputClass = compact
|
|
17
|
+
? "w-full px-2.5 py-1.5 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
|
|
18
|
+
: "w-full px-3 py-2.5 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent";
|
|
19
|
+
|
|
20
|
+
const textareaClass = compact
|
|
21
|
+
? "w-full px-2.5 py-1.5 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] min-h-[50px] resize-none focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
|
|
22
|
+
: "w-full px-3 py-2.5 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] min-h-[80px] resize-none focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent";
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className={compact ? "space-y-2" : "space-y-3"}>
|
|
26
|
+
<input
|
|
27
|
+
type="text"
|
|
28
|
+
value={heading}
|
|
29
|
+
onChange={(e) => onChange("heading", e.target.value)}
|
|
30
|
+
className={`${inputClass} font-bold text-base`}
|
|
31
|
+
placeholder="Heading..."
|
|
32
|
+
/>
|
|
33
|
+
<textarea
|
|
34
|
+
value={subheading}
|
|
35
|
+
onChange={(e) => onChange("subheading", e.target.value)}
|
|
36
|
+
className={textareaClass}
|
|
37
|
+
placeholder="Subheading..."
|
|
38
|
+
/>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export default HeadingSubheadingField;
|
|
@@ -32,13 +32,13 @@ export const ListField: React.FC<ListFieldProps> = ({
|
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
const inputClass = compact
|
|
35
|
-
? "w-full px-2.5 py-1.5 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
|
|
35
|
+
? "w-full px-2.5 py-1.5 border border-[var(--kyro-border)] rounded-md bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
|
|
36
36
|
: "w-full px-3 py-2.5 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent";
|
|
37
37
|
|
|
38
38
|
return (
|
|
39
39
|
<div className={compact ? "space-y-1.5" : "space-y-2"}>
|
|
40
40
|
{items.length === 0 ? (
|
|
41
|
-
<div className="text-center py-4 text-[var(--kyro-text-muted)] text-sm border border-dashed border-[var(--kyro-border)] rounded-
|
|
41
|
+
<div className="text-center py-4 text-[var(--kyro-text-muted)] text-sm border border-dashed border-[var(--kyro-border)] rounded-md">
|
|
42
42
|
No items. Type below to add.
|
|
43
43
|
</div>
|
|
44
44
|
) : (
|
|
@@ -16,7 +16,10 @@ export default function NumberField({
|
|
|
16
16
|
error,
|
|
17
17
|
disabled,
|
|
18
18
|
}: NumberFieldComponentProps) {
|
|
19
|
-
const isReadOnly =
|
|
19
|
+
const isReadOnly =
|
|
20
|
+
typeof field.admin?.readOnly === "function"
|
|
21
|
+
? false
|
|
22
|
+
: Boolean(field.admin?.readOnly);
|
|
20
23
|
|
|
21
24
|
return (
|
|
22
25
|
<FieldLayout
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useState, useEffect } from "react";
|
|
2
2
|
import { Search, Loader2, X } from "../ui/icons";
|
|
3
3
|
import { apiGet, buildSearchQuery } from "../../lib/api";
|
|
4
|
+
import { EmptyState } from "../ui/EmptyState";
|
|
4
5
|
|
|
5
6
|
interface RelationshipBlockFieldProps {
|
|
6
7
|
relationTo?: string;
|
|
@@ -171,9 +172,7 @@ export const RelationshipBlockField: React.FC<RelationshipBlockFieldProps> = ({
|
|
|
171
172
|
Loading...
|
|
172
173
|
</div>
|
|
173
174
|
) : options.length === 0 ? (
|
|
174
|
-
<
|
|
175
|
-
No results found
|
|
176
|
-
</div>
|
|
175
|
+
<EmptyState title="No results found" />
|
|
177
176
|
) : (
|
|
178
177
|
<div className="py-1">
|
|
179
178
|
{options.map((opt) => (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { useEffect, useState, useRef } from "react";
|
|
1
|
+
import { useEffect, useState, useRef, useCallback } from "react";
|
|
2
2
|
import { Search, X, ChevronDown, Loader2 } from "../ui/icons";
|
|
3
3
|
import { apiGet, buildSearchQuery } from "../../lib/api";
|
|
4
|
+
import { EmptyState } from "../ui/EmptyState";
|
|
4
5
|
|
|
5
6
|
interface RelationshipFieldProps {
|
|
6
7
|
field: {
|
|
@@ -15,12 +16,33 @@ interface RelationshipFieldProps {
|
|
|
15
16
|
placeholder?: string;
|
|
16
17
|
};
|
|
17
18
|
};
|
|
18
|
-
value?:
|
|
19
|
-
onChange?: (value:
|
|
19
|
+
value?: unknown;
|
|
20
|
+
onChange?: (value: unknown) => void;
|
|
20
21
|
error?: string;
|
|
21
22
|
disabled?: boolean;
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
interface ResolvedDoc {
|
|
26
|
+
id: string;
|
|
27
|
+
relationTo?: string;
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getLabel(opt: Record<string, unknown>): string {
|
|
32
|
+
const mainTabs = opt?.mainTabs as Record<string, unknown> | undefined;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
(opt?.title as string) ||
|
|
36
|
+
(mainTabs?.title as string) ||
|
|
37
|
+
(opt?.name as string) ||
|
|
38
|
+
(opt?.label as string) ||
|
|
39
|
+
(opt?.email as string) ||
|
|
40
|
+
(opt?.filename as string) ||
|
|
41
|
+
(opt?.slug as string) ||
|
|
42
|
+
"Untitled"
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
24
46
|
export function RelationshipField({
|
|
25
47
|
field,
|
|
26
48
|
value,
|
|
@@ -30,28 +52,85 @@ export function RelationshipField({
|
|
|
30
52
|
}: RelationshipFieldProps) {
|
|
31
53
|
const [isOpen, setIsOpen] = useState(false);
|
|
32
54
|
const [search, setSearch] = useState("");
|
|
33
|
-
const [options, setOptions] = useState<
|
|
55
|
+
const [options, setOptions] = useState<ResolvedDoc[]>([]);
|
|
34
56
|
const [loading, setLoading] = useState(false);
|
|
57
|
+
const [selectedDocs, setSelectedDocs] = useState<ResolvedDoc[]>([]);
|
|
58
|
+
const fetchedIdsRef = useRef<Set<string>>(new Set());
|
|
35
59
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
60
|
+
const onChangeRef = useRef<(value: unknown) => void>(() => {});
|
|
61
|
+
onChangeRef.current = onChange || (() => {});
|
|
36
62
|
|
|
37
63
|
const isMultiple = field.hasMany;
|
|
38
64
|
const relationTo = Array.isArray(field.relationTo)
|
|
39
65
|
? field.relationTo
|
|
40
66
|
: [field.relationTo];
|
|
41
|
-
const
|
|
67
|
+
const isPolymorphic = relationTo.length > 1;
|
|
68
|
+
const [activeRelation, setActiveRelation] = useState(relationTo[0]);
|
|
69
|
+
|
|
70
|
+
const extractIds = useCallback((): string[] => {
|
|
71
|
+
if (!value) return [];
|
|
72
|
+
const items = isMultiple
|
|
73
|
+
? Array.isArray(value) ? value : []
|
|
74
|
+
: value ? [value] : [];
|
|
75
|
+
return items.map((item) => {
|
|
76
|
+
if (typeof item === "object" && item !== null) {
|
|
77
|
+
return (item as { value?: string }).value || (item as { id?: string }).id || "";
|
|
78
|
+
}
|
|
79
|
+
return String(item);
|
|
80
|
+
}).filter(Boolean);
|
|
81
|
+
}, [value, isMultiple]);
|
|
82
|
+
|
|
83
|
+
const fetchSelectedDocs = useCallback((ids: string[]) => {
|
|
84
|
+
if (ids.length === 0) return;
|
|
85
|
+
ids.forEach((id) => {
|
|
86
|
+
if (fetchedIdsRef.current.has(id)) return;
|
|
87
|
+
fetchedIdsRef.current.add(id);
|
|
88
|
+
const rel = isPolymorphic
|
|
89
|
+
? (() => {
|
|
90
|
+
if (!value) return activeRelation;
|
|
91
|
+
const items = isMultiple
|
|
92
|
+
? Array.isArray(value) ? value : []
|
|
93
|
+
: [value];
|
|
94
|
+
const match = items.find((item) => {
|
|
95
|
+
if (typeof item === "object" && item !== null) {
|
|
96
|
+
return (item as { value?: string }).value === id || (item as { id?: string }).id === id;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
});
|
|
100
|
+
return match && typeof match === "object" ? (match as { relationTo?: string }).relationTo || activeRelation : activeRelation;
|
|
101
|
+
})()
|
|
102
|
+
: activeRelation;
|
|
103
|
+
apiGet<Record<string, unknown>>(`/api/${rel}/${id}`)
|
|
104
|
+
.then((response) => {
|
|
105
|
+
const doc = (response as any).data || response;
|
|
106
|
+
if (!doc || typeof doc !== "object") return;
|
|
42
107
|
|
|
43
|
-
|
|
108
|
+
setSelectedDocs((prev) => {
|
|
109
|
+
if (prev.some((d) => d.id === id)) return prev;
|
|
110
|
+
return [...prev, { ...doc, id: String((doc as any).id), relationTo: rel }];
|
|
111
|
+
});
|
|
112
|
+
})
|
|
113
|
+
.catch(() => {});
|
|
114
|
+
});
|
|
115
|
+
}, [isPolymorphic, value, activeRelation, isMultiple]);
|
|
116
|
+
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
const ids = extractIds();
|
|
119
|
+
fetchSelectedDocs(ids);
|
|
120
|
+
}, [extractIds, fetchSelectedDocs]);
|
|
121
|
+
|
|
122
|
+
const fetchOptions = useCallback((query: string = "") => {
|
|
44
123
|
setLoading(true);
|
|
45
124
|
const searchFields = ["title", "name", "label", "email"];
|
|
46
|
-
const url = `/api/${
|
|
125
|
+
const url = `/api/${activeRelation}?${buildSearchQuery(query, searchFields)}`;
|
|
47
126
|
|
|
48
|
-
apiGet(url)
|
|
127
|
+
apiGet<{ docs?: Record<string, unknown>[] }>(url)
|
|
49
128
|
.then((data) => {
|
|
50
129
|
setOptions((prev) => {
|
|
51
130
|
const existingIds = new Set(prev.map((o) => o.id));
|
|
52
|
-
const newDocs = (data.docs || []).filter(
|
|
53
|
-
|
|
54
|
-
|
|
131
|
+
const newDocs: ResolvedDoc[] = (data.docs || []).filter(
|
|
132
|
+
(d) => !existingIds.has(d.id as string),
|
|
133
|
+
).map((d) => ({ ...d, id: d.id as string }));
|
|
55
134
|
return [...prev, ...newDocs];
|
|
56
135
|
});
|
|
57
136
|
setLoading(false);
|
|
@@ -59,39 +138,14 @@ const newDocs = (data.docs || []).filter(
|
|
|
59
138
|
.catch(() => {
|
|
60
139
|
setLoading(false);
|
|
61
140
|
});
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
const fetchSelectedItems = () => {
|
|
65
|
-
const items: (string | Record<string, unknown>)[] = isMultiple
|
|
66
|
-
? Array.isArray(value)
|
|
67
|
-
? value
|
|
68
|
-
: []
|
|
69
|
-
: value
|
|
70
|
-
? [value]
|
|
71
|
-
: [];
|
|
72
|
-
items.forEach((itemId) => {
|
|
73
|
-
const id = typeof itemId === "object" ? itemId?.id : itemId;
|
|
74
|
-
if (id && !options.some((o) => o.id === id)) {
|
|
75
|
-
apiGet(`/api/${targetCollection}/${id}`)
|
|
76
|
-
.then((doc) => {
|
|
77
|
-
setOptions((prev) => [...prev, doc]);
|
|
78
|
-
})
|
|
79
|
-
.catch(() => {});
|
|
80
|
-
}
|
|
81
|
-
});
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
useEffect(() => {
|
|
85
|
-
if (value) {
|
|
86
|
-
fetchSelectedItems();
|
|
87
|
-
}
|
|
88
|
-
}, [value, targetCollection]);
|
|
141
|
+
}, [activeRelation]);
|
|
89
142
|
|
|
90
143
|
useEffect(() => {
|
|
91
144
|
if (isOpen) {
|
|
145
|
+
setOptions([]);
|
|
92
146
|
fetchOptions(search);
|
|
93
147
|
}
|
|
94
|
-
}, [isOpen,
|
|
148
|
+
}, [isOpen, activeRelation]);
|
|
95
149
|
|
|
96
150
|
useEffect(() => {
|
|
97
151
|
const handleClickOutside = (event: MouseEvent) => {
|
|
@@ -106,24 +160,14 @@ const fetchSelectedItems = () => {
|
|
|
106
160
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
107
161
|
}, []);
|
|
108
162
|
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
opt?.email ||
|
|
115
|
-
opt?.filename ||
|
|
116
|
-
opt?.slug ||
|
|
117
|
-
String(opt?.id) ||
|
|
118
|
-
"Untitled"
|
|
119
|
-
);
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
const getValueId = (val: string | Record<string, unknown>) => {
|
|
123
|
-
return val?.id || val;
|
|
163
|
+
const getValueId = (val: unknown): string => {
|
|
164
|
+
if (typeof val === "object" && val !== null) {
|
|
165
|
+
return (val as { value?: string }).value || (val as { id?: string }).id || "";
|
|
166
|
+
}
|
|
167
|
+
return String(val);
|
|
124
168
|
};
|
|
125
169
|
|
|
126
|
-
const isSelected = (opt: Record<string, unknown>) => {
|
|
170
|
+
const isSelected = (opt: Record<string, unknown>): boolean => {
|
|
127
171
|
const optId = opt.id;
|
|
128
172
|
if (!value) return false;
|
|
129
173
|
if (isMultiple && Array.isArray(value)) {
|
|
@@ -135,60 +179,56 @@ const fetchSelectedItems = () => {
|
|
|
135
179
|
const handleSelect = (opt: Record<string, unknown>) => {
|
|
136
180
|
const optId = opt.id;
|
|
137
181
|
if (isMultiple) {
|
|
138
|
-
const current = Array.isArray(value) ? value : [];
|
|
182
|
+
const current: unknown[] = Array.isArray(value) ? value : [];
|
|
139
183
|
if (isSelected(opt)) {
|
|
140
|
-
|
|
184
|
+
onChangeRef.current?.(current.filter((v) => getValueId(v) !== optId));
|
|
141
185
|
} else {
|
|
142
|
-
|
|
186
|
+
const newItem = isPolymorphic
|
|
187
|
+
? { relationTo: activeRelation, value: optId }
|
|
188
|
+
: optId;
|
|
189
|
+
onChangeRef.current?.([...current, newItem]);
|
|
143
190
|
}
|
|
144
191
|
} else {
|
|
145
192
|
if (isSelected(opt)) {
|
|
146
|
-
|
|
193
|
+
onChangeRef.current?.(null);
|
|
147
194
|
} else {
|
|
148
|
-
|
|
195
|
+
const newItem = isPolymorphic
|
|
196
|
+
? { relationTo: activeRelation, value: optId }
|
|
197
|
+
: optId;
|
|
198
|
+
onChangeRef.current?.(newItem);
|
|
149
199
|
setIsOpen(false);
|
|
150
200
|
setSearch("");
|
|
151
201
|
}
|
|
152
202
|
}
|
|
153
203
|
};
|
|
154
204
|
|
|
155
|
-
const handleClear = () => {
|
|
156
|
-
onChange?.(isMultiple ? [] : null);
|
|
157
|
-
};
|
|
158
|
-
|
|
159
205
|
const renderSelectedItems = () => {
|
|
160
206
|
if (!value) return null;
|
|
161
207
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
} else {
|
|
166
|
-
items = value ? [value] : [];
|
|
167
|
-
}
|
|
208
|
+
const items: unknown[] = isMultiple
|
|
209
|
+
? Array.isArray(value) ? value : []
|
|
210
|
+
: value ? [value] : [];
|
|
168
211
|
|
|
169
212
|
return (
|
|
170
213
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
|
171
|
-
{items.map((item
|
|
172
|
-
const rawId =
|
|
173
|
-
const
|
|
174
|
-
const label =
|
|
214
|
+
{items.map((item) => {
|
|
215
|
+
const rawId = getValueId(item);
|
|
216
|
+
const doc = selectedDocs.find((d) => d.id === rawId);
|
|
217
|
+
const label = doc ? getLabel(doc) : rawId.slice(0, 12);
|
|
218
|
+
const rel = isPolymorphic && doc ? doc.relationTo : null;
|
|
175
219
|
return (
|
|
176
220
|
<span
|
|
177
|
-
key={
|
|
221
|
+
key={rawId}
|
|
178
222
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-md bg-[var(--kyro-sidebar-active)]/10 text-[var(--kyro-sidebar-active)]"
|
|
179
223
|
>
|
|
224
|
+
{rel && <span className="opacity-60 mr-0.5">{rel}:</span>}
|
|
180
225
|
{label}
|
|
181
226
|
{!disabled && (
|
|
182
227
|
<button
|
|
183
228
|
type="button"
|
|
184
229
|
onClick={() => {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
(items as (string | Record<string, unknown>)[]).filter((_: unknown, i: number) => i !== idx),
|
|
188
|
-
);
|
|
189
|
-
} else {
|
|
190
|
-
onChange?.(null);
|
|
191
|
-
}
|
|
230
|
+
const filtered = items.filter((value) => getValueId(value) !== rawId);
|
|
231
|
+
onChangeRef.current?.(isMultiple ? filtered : (filtered[0] ?? null));
|
|
192
232
|
}}
|
|
193
233
|
className="hover:opacity-70"
|
|
194
234
|
>
|
|
@@ -213,6 +253,28 @@ onChange?.(
|
|
|
213
253
|
</label>
|
|
214
254
|
)}
|
|
215
255
|
<div ref={containerRef} className="relative">
|
|
256
|
+
{isPolymorphic && (
|
|
257
|
+
<div className="flex gap-1 mb-1.5">
|
|
258
|
+
{relationTo.map((rel) => (
|
|
259
|
+
<button
|
|
260
|
+
key={rel}
|
|
261
|
+
type="button"
|
|
262
|
+
onClick={() => {
|
|
263
|
+
setActiveRelation(rel);
|
|
264
|
+
setOptions([]);
|
|
265
|
+
setSearch("");
|
|
266
|
+
}}
|
|
267
|
+
className={`px-2 py-0.5 text-[10px] font-bold rounded transition-colors ${
|
|
268
|
+
activeRelation === rel
|
|
269
|
+
? "kyro-btn-primary"
|
|
270
|
+
: "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-border)]"
|
|
271
|
+
}`}
|
|
272
|
+
>
|
|
273
|
+
{rel}
|
|
274
|
+
</button>
|
|
275
|
+
))}
|
|
276
|
+
</div>
|
|
277
|
+
)}
|
|
216
278
|
<div className="relative">
|
|
217
279
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-muted)]" />
|
|
218
280
|
<input
|
|
@@ -225,10 +287,15 @@ onChange?.(
|
|
|
225
287
|
}}
|
|
226
288
|
onFocus={() => setIsOpen(true)}
|
|
227
289
|
placeholder={
|
|
228
|
-
field.admin?.placeholder || `Search ${
|
|
290
|
+
field.admin?.placeholder || `Search ${activeRelation}...`
|
|
291
|
+
}
|
|
292
|
+
disabled={
|
|
293
|
+
disabled ||
|
|
294
|
+
(typeof field.admin?.readOnly === "function"
|
|
295
|
+
? false
|
|
296
|
+
: Boolean(field.admin?.readOnly))
|
|
229
297
|
}
|
|
230
|
-
|
|
231
|
-
className="w-full pl-9 pr-10 py-2 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent disabled:opacity-50"
|
|
298
|
+
className="w-full pl-9 pr-10 py-2 border border-[var(--kyro-border)] rounded-md bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent disabled:opacity-50"
|
|
232
299
|
/>
|
|
233
300
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
|
234
301
|
{loading ? (
|
|
@@ -242,15 +309,13 @@ onChange?.(
|
|
|
242
309
|
</div>
|
|
243
310
|
|
|
244
311
|
{isOpen && (
|
|
245
|
-
<div className="
|
|
312
|
+
<div className="relative z-20 w-full mt-1 border border-[var(--kyro-border)] rounded-lg shadow-lg bg-[var(--kyro-surface)] max-h-64 overflow-auto">
|
|
246
313
|
{loading ? (
|
|
247
314
|
<div className="p-4 text-center text-sm text-[var(--kyro-text-muted)]">
|
|
248
315
|
Loading...
|
|
249
316
|
</div>
|
|
250
317
|
) : options.length === 0 ? (
|
|
251
|
-
<
|
|
252
|
-
No results found
|
|
253
|
-
</div>
|
|
318
|
+
<EmptyState title="No results found" />
|
|
254
319
|
) : (
|
|
255
320
|
<div className="py-1">
|
|
256
321
|
{options.map((opt) => (
|
|
@@ -272,7 +337,7 @@ onChange?.(
|
|
|
272
337
|
</span>
|
|
273
338
|
)}
|
|
274
339
|
</div>
|
|
275
|
-
{opt.slug && (
|
|
340
|
+
{"slug" in opt && typeof opt.slug === "string" && (
|
|
276
341
|
<div className="text-xs text-[var(--kyro-text-muted)]">
|
|
277
342
|
{opt.slug}
|
|
278
343
|
</div>
|