@kyro-cms/admin 0.10.5 → 0.10.7
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 +38 -15119
- package/dist/index.css +1 -1698
- package/dist/index.js +38 -15033
- package/package.json +1 -1
- package/src/components/ApiKeysManager.tsx +33 -126
- package/src/components/AutoForm.tsx +130 -1188
- package/src/components/PluginsManager.tsx +13 -141
- package/src/components/SettingsPage.tsx +0 -1
- package/src/components/UserMenu.tsx +17 -1
- package/src/components/autoform/AutoFormApiView.tsx +96 -0
- package/src/components/autoform/AutoFormEditView.tsx +91 -0
- package/src/components/autoform/AutoFormHeader.tsx +488 -0
- package/src/components/autoform/AutoFormVersionView.tsx +283 -0
- package/src/hooks/useAutoFormState.ts +3 -1
- package/src/lib/autoform-store.ts +8 -0
- package/dist/index.cjs.map +0 -1
- package/dist/index.css.map +0 -1
- package/dist/index.js.map +0 -1
|
@@ -33,14 +33,8 @@ interface ToggleError {
|
|
|
33
33
|
export function PluginsManager() {
|
|
34
34
|
const [plugins, setPlugins] = useState<Plugin[]>([]);
|
|
35
35
|
const [loading, setLoading] = useState(true);
|
|
36
|
-
const [toggleLoading, setToggleLoading] = useState<string | null>(null);
|
|
37
36
|
const [searchQuery, setSearchQuery] = useState("");
|
|
38
37
|
const [showConfigModal, setShowConfigModal] = useState<string | null>(null);
|
|
39
|
-
const [confirmDisable, setConfirmDisable] = useState<{
|
|
40
|
-
id: string;
|
|
41
|
-
name: string;
|
|
42
|
-
activeProvider: string;
|
|
43
|
-
} | null>(null);
|
|
44
38
|
const [error, setError] = useState<string | null>(null);
|
|
45
39
|
|
|
46
40
|
const fetchPlugins = async () => {
|
|
@@ -62,79 +56,6 @@ export function PluginsManager() {
|
|
|
62
56
|
fetchPlugins();
|
|
63
57
|
}, []);
|
|
64
58
|
|
|
65
|
-
const togglePlugin = async (id: string) => {
|
|
66
|
-
setError(null);
|
|
67
|
-
setToggleLoading(id);
|
|
68
|
-
|
|
69
|
-
try {
|
|
70
|
-
const res = await fetch(`/api/plugins/${encodeURIComponent(id)}/toggle`, {
|
|
71
|
-
method: "PUT",
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
if (res.status === 409) {
|
|
75
|
-
const errData: ToggleError = await res.json();
|
|
76
|
-
const plugin = plugins.find((p) => p.id === id);
|
|
77
|
-
if (errData.requiresAction && plugin) {
|
|
78
|
-
setConfirmDisable({
|
|
79
|
-
id,
|
|
80
|
-
name: plugin.name,
|
|
81
|
-
activeProvider: errData.activeProvider || "unknown",
|
|
82
|
-
});
|
|
83
|
-
} else {
|
|
84
|
-
setError(errData.error);
|
|
85
|
-
}
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (!res.ok) {
|
|
90
|
-
const errData = await res.json().catch(() => ({ error: "Toggle failed" }));
|
|
91
|
-
setError(errData.error);
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const result = await res.json();
|
|
96
|
-
setPlugins((prev) =>
|
|
97
|
-
prev.map((p) =>
|
|
98
|
-
p.id === id ? { ...p, enabled: result.enabled } : p,
|
|
99
|
-
),
|
|
100
|
-
);
|
|
101
|
-
} catch (e: any) {
|
|
102
|
-
setError(e.message || "Network error");
|
|
103
|
-
} finally {
|
|
104
|
-
setToggleLoading(null);
|
|
105
|
-
}
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
const handleForceDisable = async () => {
|
|
109
|
-
if (!confirmDisable) return;
|
|
110
|
-
setError(null);
|
|
111
|
-
|
|
112
|
-
try {
|
|
113
|
-
const res = await fetch(
|
|
114
|
-
`/api/plugins/${encodeURIComponent(confirmDisable.id)}/toggle?force=1`,
|
|
115
|
-
{ method: "PUT" },
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
if (res.ok) {
|
|
119
|
-
const result = await res.json();
|
|
120
|
-
setPlugins((prev) =>
|
|
121
|
-
prev.map((p) =>
|
|
122
|
-
p.id === confirmDisable.id
|
|
123
|
-
? { ...p, enabled: result.enabled }
|
|
124
|
-
: p,
|
|
125
|
-
),
|
|
126
|
-
);
|
|
127
|
-
} else {
|
|
128
|
-
const errData = await res.json().catch(() => ({ error: "Toggle failed" }));
|
|
129
|
-
setError(errData.error);
|
|
130
|
-
}
|
|
131
|
-
} catch (e: any) {
|
|
132
|
-
setError(e.message || "Network error");
|
|
133
|
-
} finally {
|
|
134
|
-
setConfirmDisable(null);
|
|
135
|
-
}
|
|
136
|
-
};
|
|
137
|
-
|
|
138
59
|
const filteredPlugins = plugins.filter(
|
|
139
60
|
(p) =>
|
|
140
61
|
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
@@ -191,6 +112,19 @@ export function PluginsManager() {
|
|
|
191
112
|
)}
|
|
192
113
|
|
|
193
114
|
<div className="flex flex-col gap-8 surface-tile p-8">
|
|
115
|
+
{/* Info Banner */}
|
|
116
|
+
<div className="p-4 rounded-2xl bg-blue-500/10 border border-blue-500/20 flex items-start gap-3">
|
|
117
|
+
<ShieldCheck className="w-4 h-4 text-blue-500 shrink-0 mt-0.5" />
|
|
118
|
+
<div>
|
|
119
|
+
<h4 className="text-sm font-bold text-blue-500 mb-1">Developer-First Approach</h4>
|
|
120
|
+
<p className="text-xs text-blue-500/80 leading-relaxed">
|
|
121
|
+
Plugins inject core structural components like schemas, collections, and APIs.
|
|
122
|
+
To ensure version-controlled integrity, plugins are entirely managed via code in your <code className="bg-blue-500/20 px-1.5 py-0.5 rounded-md text-[10px] font-mono">kyro.config.ts</code> file.
|
|
123
|
+
This page serves as a read-only directory of the currently installed ecosystem.
|
|
124
|
+
</p>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
194
128
|
{/* Stats Summary */}
|
|
195
129
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
196
130
|
<div className="p-4 rounded-2xl bg-[var(--kyro-bg-secondary)]/50 border border-[var(--kyro-border)] flex items-center gap-3 group hover:border-[var(--kyro-primary)]/30 transition-all">
|
|
@@ -294,23 +228,6 @@ export function PluginsManager() {
|
|
|
294
228
|
>
|
|
295
229
|
<Settings className="w-3.5 h-3.5" />
|
|
296
230
|
</button>
|
|
297
|
-
<button
|
|
298
|
-
type="button"
|
|
299
|
-
onClick={() => togglePlugin(plugin.id)}
|
|
300
|
-
disabled={toggleLoading === plugin.id}
|
|
301
|
-
className={`p-2 border rounded-lg transition-all shadow-sm ${plugin.enabled
|
|
302
|
-
? "bg-red-500/5 border-red-500/10 text-red-500/40 hover:text-red-500 hover:border-red-500/30"
|
|
303
|
-
: "bg-green-500/5 border-green-500/10 text-green-500/40 hover:text-green-500 hover:border-green-500/30"
|
|
304
|
-
} disabled:opacity-30 disabled:cursor-not-allowed`}
|
|
305
|
-
>
|
|
306
|
-
{toggleLoading === plugin.id ? (
|
|
307
|
-
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
|
308
|
-
) : plugin.enabled ? (
|
|
309
|
-
<X className="w-3.5 h-3.5" />
|
|
310
|
-
) : (
|
|
311
|
-
<Plus className="w-3.5 h-3.5" />
|
|
312
|
-
)}
|
|
313
|
-
</button>
|
|
314
231
|
</div>
|
|
315
232
|
</div>
|
|
316
233
|
</div>
|
|
@@ -326,51 +243,6 @@ export function PluginsManager() {
|
|
|
326
243
|
</div>
|
|
327
244
|
</div>
|
|
328
245
|
|
|
329
|
-
{/* Confirmation Modal for Disabling Active Storage Plugin */}
|
|
330
|
-
<Modal
|
|
331
|
-
open={!!confirmDisable}
|
|
332
|
-
onClose={() => setConfirmDisable(null)}
|
|
333
|
-
title="Disable Storage Plugin?"
|
|
334
|
-
size="md"
|
|
335
|
-
>
|
|
336
|
-
<ModalContent>
|
|
337
|
-
<div className="p-6 text-center space-y-4">
|
|
338
|
-
<div className="w-16 h-16 mx-auto bg-amber-500/10 rounded-2xl flex items-center justify-center border border-amber-500/20">
|
|
339
|
-
<AlertTriangle className="w-8 h-8 text-amber-500" />
|
|
340
|
-
</div>
|
|
341
|
-
<div>
|
|
342
|
-
<h4 className="text-lg font-bold mb-2">Storage Plugin In Use</h4>
|
|
343
|
-
<p className="text-sm text-[var(--kyro-text-secondary)] opacity-70 leading-relaxed">
|
|
344
|
-
"{confirmDisable?.name}" is currently the active storage provider.
|
|
345
|
-
Disabling it will switch storage to <strong>Local</strong>.
|
|
346
|
-
</p>
|
|
347
|
-
<p className="text-xs text-[var(--kyro-text-secondary)] opacity-50 mt-2">
|
|
348
|
-
Existing media URLs will remain accessible, but new uploads will
|
|
349
|
-
use Local storage. You can re-enable the plugin at any time.
|
|
350
|
-
</p>
|
|
351
|
-
</div>
|
|
352
|
-
</div>
|
|
353
|
-
</ModalContent>
|
|
354
|
-
<ModalActions>
|
|
355
|
-
<div className="flex gap-3 w-full">
|
|
356
|
-
<button
|
|
357
|
-
type="button"
|
|
358
|
-
onClick={() => setConfirmDisable(null)}
|
|
359
|
-
className="kyro-btn kyro-btn-primary flex-1 py-3 rounded-xl font-bold text-sm"
|
|
360
|
-
>
|
|
361
|
-
Cancel
|
|
362
|
-
</button>
|
|
363
|
-
<button
|
|
364
|
-
type="button"
|
|
365
|
-
onClick={handleForceDisable}
|
|
366
|
-
className="kyro-btn flex-1 py-3 rounded-xl font-bold text-sm bg-amber-500 hover:bg-amber-600 text-white transition-all"
|
|
367
|
-
>
|
|
368
|
-
Switch to Local & Disable
|
|
369
|
-
</button>
|
|
370
|
-
</div>
|
|
371
|
-
</ModalActions>
|
|
372
|
-
</Modal>
|
|
373
|
-
|
|
374
246
|
{/* Config Modal */}
|
|
375
247
|
<Modal
|
|
376
248
|
open={!!showConfigModal}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useState, useEffect } from "react";
|
|
2
2
|
import { Dropdown, DropdownItem, DropdownSeparator } from "./ui/Dropdown";
|
|
3
|
-
import { User, Shield, Key, Webhook, Clock, FileText, ExternalLink, HelpCircle, LogOut } from "./ui/icons";
|
|
3
|
+
import { User, Shield, Key, Webhook, Clock, FileText, ExternalLink, HelpCircle, LogOut, Terminal, Zap } from "./ui/icons";
|
|
4
4
|
import { useAuthStore } from "../lib/stores";
|
|
5
5
|
import { apiGet } from "../lib/api";
|
|
6
6
|
|
|
@@ -18,6 +18,8 @@ export function UserMenu({ adminPath }: UserMenuProps) {
|
|
|
18
18
|
apiGet<any>(`/api/media/${avatar}`)
|
|
19
19
|
.then((media) => setAvatarUrl(media?.thumbnailUrl || media?.url || null))
|
|
20
20
|
.catch(() => setAvatarUrl(null));
|
|
21
|
+
} else if (typeof avatar === "string") {
|
|
22
|
+
setAvatarUrl(avatar);
|
|
21
23
|
} else {
|
|
22
24
|
setAvatarUrl(null);
|
|
23
25
|
}
|
|
@@ -100,6 +102,20 @@ export function UserMenu({ adminPath }: UserMenuProps) {
|
|
|
100
102
|
Audit Logs
|
|
101
103
|
</DropdownItem>
|
|
102
104
|
|
|
105
|
+
<DropdownItem
|
|
106
|
+
icon={<Terminal className="w-4 h-4" />}
|
|
107
|
+
onClick={() => (window.location.href = `${adminPath}/rest-playground`)}
|
|
108
|
+
>
|
|
109
|
+
API Explorer
|
|
110
|
+
</DropdownItem>
|
|
111
|
+
|
|
112
|
+
<DropdownItem
|
|
113
|
+
icon={<Zap className="w-4 h-4" />}
|
|
114
|
+
onClick={() => (window.location.href = `${adminPath}/graphql`)}
|
|
115
|
+
>
|
|
116
|
+
GraphQL Playground
|
|
117
|
+
</DropdownItem>
|
|
118
|
+
|
|
103
119
|
<DropdownSeparator />
|
|
104
120
|
|
|
105
121
|
<div className="px-4 py-2 mb-1">
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useAutoFormStore } from "../../lib/autoform-store";
|
|
3
|
+
|
|
4
|
+
interface AutoFormApiViewProps {
|
|
5
|
+
collectionSlug?: string;
|
|
6
|
+
globalSlug?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function AutoFormApiView({
|
|
10
|
+
collectionSlug,
|
|
11
|
+
globalSlug,
|
|
12
|
+
}: AutoFormApiViewProps) {
|
|
13
|
+
const { formData } = useAutoFormStore();
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div className="w-full space-y-8 animate-in fade-in slide-in-from-bottom-4">
|
|
17
|
+
<div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-8">
|
|
18
|
+
<div className="surface-tile p-8 min-w-0">
|
|
19
|
+
<h2 className="text-xl font-bold mb-6">Response Payload</h2>
|
|
20
|
+
<div className="bg-[#0f172a] p-6 rounded-2xl border border-white/5 overflow-x-auto max-h-[800px]">
|
|
21
|
+
<pre className="text-blue-300 text-xs font-mono whitespace-pre-wrap break-all">
|
|
22
|
+
{JSON.stringify(formData, null, 2)}
|
|
23
|
+
</pre>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div className="space-y-6">
|
|
28
|
+
<div className="surface-tile p-8 space-y-6">
|
|
29
|
+
<h2 className="text-xl font-bold mb-6">API Info</h2>
|
|
30
|
+
|
|
31
|
+
<div className="space-y-6">
|
|
32
|
+
<div>
|
|
33
|
+
<label className="text-[10px] font-bold tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-2">
|
|
34
|
+
Reference Path
|
|
35
|
+
</label>
|
|
36
|
+
<div className="bg-[var(--kyro-bg-secondary)] px-4 py-3 rounded-xl border border-[var(--kyro-border)] text-[11px] font-mono break-all selection:bg-[var(--kyro-primary)]/20 text-[var(--kyro-text-primary)]">
|
|
37
|
+
{globalSlug
|
|
38
|
+
? `kyro.globals('${globalSlug}').get()`
|
|
39
|
+
: formData.id
|
|
40
|
+
? `kyro.collection('${collectionSlug}').get('${formData.id}')`
|
|
41
|
+
: "Not saved yet"}
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
{formData.id && (
|
|
46
|
+
<div>
|
|
47
|
+
<label className="text-[10px] font-bold tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-2">
|
|
48
|
+
Document ID
|
|
49
|
+
</label>
|
|
50
|
+
<div className="flex items-center gap-2">
|
|
51
|
+
<code className="text-xs font-mono bg-[var(--kyro-bg-secondary)] px-3 py-1.5 rounded-lg border border-[var(--kyro-border)] text-[var(--kyro-text-primary)]">
|
|
52
|
+
{String(formData.id)}
|
|
53
|
+
</code>
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
onClick={() => {
|
|
57
|
+
navigator.clipboard.writeText(String(formData.id));
|
|
58
|
+
}}
|
|
59
|
+
className="p-1.5 hover:bg-[var(--kyro-bg-secondary)] rounded-lg transition-colors text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
|
|
60
|
+
title="Copy ID"
|
|
61
|
+
>
|
|
62
|
+
<svg
|
|
63
|
+
width="14"
|
|
64
|
+
height="14"
|
|
65
|
+
viewBox="0 0 24 24"
|
|
66
|
+
fill="none"
|
|
67
|
+
stroke="currentColor"
|
|
68
|
+
strokeWidth="2"
|
|
69
|
+
strokeLinecap="round"
|
|
70
|
+
strokeLinejoin="round"
|
|
71
|
+
>
|
|
72
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
73
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
74
|
+
</svg>
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
{globalSlug && (
|
|
81
|
+
<div>
|
|
82
|
+
<label className="text-[10px] font-bold tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-2">
|
|
83
|
+
Global Slug
|
|
84
|
+
</label>
|
|
85
|
+
<code className="text-xs font-mono bg-[var(--kyro-bg-secondary)] px-3 py-1.5 rounded-lg border border-[var(--kyro-border)] text-[var(--kyro-text-primary)]">
|
|
86
|
+
{globalSlug}
|
|
87
|
+
</code>
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { CollectionConfig, GlobalConfig, Field } from "@kyro-cms/core/client";
|
|
3
|
+
import { useAutoFormStore } from "../../lib/autoform-store";
|
|
4
|
+
|
|
5
|
+
interface AutoFormEditViewProps {
|
|
6
|
+
config: CollectionConfig | GlobalConfig;
|
|
7
|
+
layout: "split" | "single";
|
|
8
|
+
collectionSlug?: string;
|
|
9
|
+
renderField: (field: Field) => React.ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function AutoFormEditView({
|
|
13
|
+
config,
|
|
14
|
+
layout,
|
|
15
|
+
collectionSlug,
|
|
16
|
+
renderField,
|
|
17
|
+
}: AutoFormEditViewProps) {
|
|
18
|
+
const { showPreview, sidebarCollapsed, formData } = useAutoFormStore();
|
|
19
|
+
|
|
20
|
+
if (layout === "single") {
|
|
21
|
+
return (
|
|
22
|
+
<div className="w-full space-y-6 md:space-y-8">
|
|
23
|
+
<div className="surface-tile p-4 md:p-8 space-y-6 md:space-y-8">
|
|
24
|
+
{config.fields.map((f: Field) => renderField(f))}
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const showRightColumn = !sidebarCollapsed && !showPreview;
|
|
31
|
+
const hasSidebarFields =
|
|
32
|
+
config.fields.some((f: Field) => f.admin?.position === "sidebar") &&
|
|
33
|
+
!showPreview;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
className={`w-full mx-auto grid gap-4 md:gap-8 pb-32 transition-all duration-700 ${showPreview
|
|
38
|
+
? "grid-cols-1 lg:grid-cols-2"
|
|
39
|
+
: sidebarCollapsed || !hasSidebarFields
|
|
40
|
+
? "grid-cols-1"
|
|
41
|
+
: "grid-cols-1 lg:grid-cols-[1fr_380px]"
|
|
42
|
+
}`}
|
|
43
|
+
>
|
|
44
|
+
<div className="space-y-6 md:space-y-8 animate-in fade-in slide-in-from-left-4 duration-500">
|
|
45
|
+
{config.tabs ? (
|
|
46
|
+
renderField({ type: "tabs", tabs: config.tabs } as Field)
|
|
47
|
+
) : (
|
|
48
|
+
<div className="surface-tile p-4 md:p-8 space-y-6 md:space-y-8">
|
|
49
|
+
{config.fields
|
|
50
|
+
.filter(
|
|
51
|
+
(f: Field) => !f.admin?.position || f.admin.position === "main",
|
|
52
|
+
)
|
|
53
|
+
.map((f: Field) => renderField(f))}
|
|
54
|
+
</div>
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
{showPreview ? (
|
|
59
|
+
<div className="sticky top-36 h-[calc(100vh-280px)] animate-in fade-in slide-in-from-right-10 duration-700">
|
|
60
|
+
<div className="w-full h-full rounded-3xl border border-[var(--kyro-border)] bg-[var(--kyro-bg-secondary)] shadow-2xl overflow-hidden relative group">
|
|
61
|
+
<div className="absolute top-4 left-4 z-10 flex items-center gap-2">
|
|
62
|
+
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
|
63
|
+
<span className="text-[10px] font-bold tracking-widest text-white/60">
|
|
64
|
+
Live Preview Mode
|
|
65
|
+
</span>
|
|
66
|
+
</div>
|
|
67
|
+
<iframe
|
|
68
|
+
src={`/${collectionSlug}/${formData.slug || formData.id}?preview=true`}
|
|
69
|
+
className="w-full h-full border-none"
|
|
70
|
+
title="Live Preview"
|
|
71
|
+
/>
|
|
72
|
+
<div className="absolute inset-0 bg-transparent pointer-events-none border-[12px] border-[var(--kyro-surface)] rounded-3xl" />
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
) : sidebarCollapsed ? null : (
|
|
76
|
+
<div className="space-y-4 md:space-y-6 animate-in fade-in slide-in-from-right-4 duration-500">
|
|
77
|
+
{config.fields.some((f: Field) => f.admin?.position === "sidebar") && (
|
|
78
|
+
<div className="surface-tile p-4 md:p-6 space-y-4 md:space-y-6">
|
|
79
|
+
<h3 className="text-[10px] font-bold tracking-[0.2em] opacity-40">
|
|
80
|
+
Settings
|
|
81
|
+
</h3>
|
|
82
|
+
{config.fields
|
|
83
|
+
.filter((f: Field) => f.admin?.position === "sidebar")
|
|
84
|
+
.map((f: Field) => renderField(f))}
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|