@kyro-cms/admin 0.12.19 → 0.12.21
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 +24 -24
- package/dist/index.d.cts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +19 -19
- package/package.json +6 -6
- package/src/components/ApiKeysManager.tsx +177 -227
- package/src/components/AuditLogsPage.tsx +7 -7
- package/src/components/AuthorDashboard.tsx +235 -0
- package/src/components/AutoForm.tsx +15 -4
- package/src/components/CustomerDashboard.tsx +213 -0
- package/src/components/Dashboard.tsx +1 -1
- package/src/components/DashboardMetrics.tsx +74 -9
- package/src/components/DashboardQuickSections.tsx +237 -0
- package/src/components/ListView.tsx +3 -3
- package/src/components/MediaGallery.tsx +2 -2
- package/src/components/PluginsManager.tsx +1 -1
- package/src/components/SessionsManager.tsx +15 -15
- package/src/components/Sidebar.astro +564 -116
- package/src/components/UserManagement.tsx +43 -9
- package/src/components/UserMenu.tsx +123 -61
- package/src/components/autoform/AutoFormEditView.tsx +4 -4
- package/src/components/autoform/AutoFormHeader.tsx +2 -2
- package/src/components/fields/ArrayLayout.tsx +8 -8
- package/src/components/fields/CodeField.tsx +0 -1
- package/src/components/fields/TabsLayout.tsx +5 -2
- package/src/components/ui/CommandPalette.tsx +1 -1
- package/src/components/ui/Dropdown.tsx +71 -16
- package/src/components/ui/PageHeader.tsx +1 -1
- package/src/components/ui/Pagination.tsx +1 -1
- package/src/components/ui/SeoPreview.tsx +25 -21
- package/src/components/ui/Toast.tsx +3 -2
- package/src/components/ui/Toaster.tsx +1 -1
- package/src/components/users/UserDetail.tsx +74 -29
- package/src/components/users/UserForm.tsx +1 -1
- package/src/components/users/UsersList.tsx +2 -2
- package/src/integration.ts +4 -0
- package/src/layouts/AdminLayout.astro +26 -34
- package/src/layouts/AuthLayout.astro +18 -9
- package/src/lib/auth-server.ts +99 -0
- package/src/pages/403.astro +54 -35
- package/src/pages/[collection]/[id].astro +8 -0
- package/src/pages/[collection]/index.astro +7 -0
- package/src/pages/audit/index.astro +4 -1
- package/src/pages/auth/check-email.astro +146 -0
- package/src/pages/auth/forgot-password.astro +95 -0
- package/src/pages/auth/login.astro +29 -12
- package/src/pages/auth/register.astro +38 -12
- package/src/pages/auth/reset-password.astro +125 -0
- package/src/pages/auth/verify-email.astro +87 -0
- package/src/pages/index.astro +220 -121
- package/src/pages/keys.astro +3 -1
- package/src/pages/media.astro +3 -1
- package/src/pages/preview/[collection]/[id].astro +23 -73
- package/src/pages/roles/index.astro +134 -101
- package/src/pages/settings/[slug].astro +43 -3
- package/src/pages/users/[id].astro +5 -1
- package/src/pages/users/index.astro +4 -0
- package/src/pages/users/new.astro +4 -0
- package/src/pages/webhooks.astro +3 -1
- package/src/routes.ts +16 -0
|
@@ -39,9 +39,30 @@ export function UserManagement() {
|
|
|
39
39
|
const [createError, setCreateError] = useState("");
|
|
40
40
|
const [creating, setCreating] = useState(false);
|
|
41
41
|
const { confirm, alert } = useUIStore();
|
|
42
|
+
const [currentUserRole, setCurrentUserRole] = useState<string>(() => {
|
|
43
|
+
return (typeof window !== "undefined" && (window as any).__kyroAuth?.user?.role) || "";
|
|
44
|
+
});
|
|
42
45
|
|
|
43
46
|
useEffect(() => {
|
|
44
47
|
loadUsers();
|
|
48
|
+
|
|
49
|
+
if ((window as any).__kyroAuth?.user?.role) {
|
|
50
|
+
setCurrentUserRole((window as any).__kyroAuth.user.role);
|
|
51
|
+
} else {
|
|
52
|
+
apiGet<any>("/api/auth/me")
|
|
53
|
+
.then((res) => {
|
|
54
|
+
if (res?.user?.role) setCurrentUserRole(res.user.role);
|
|
55
|
+
})
|
|
56
|
+
.catch(() => {});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const handler = (e: any) => {
|
|
60
|
+
if (e.detail?.user?.role) {
|
|
61
|
+
setCurrentUserRole(e.detail.user.role);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
window.addEventListener("kyro:auth-ready", handler);
|
|
65
|
+
return () => window.removeEventListener("kyro:auth-ready", handler);
|
|
45
66
|
}, []);
|
|
46
67
|
|
|
47
68
|
const loadUsers = async () => {
|
|
@@ -300,15 +321,28 @@ export function UserManagement() {
|
|
|
300
321
|
</div>
|
|
301
322
|
<div>
|
|
302
323
|
<label className="block text-xs font-bold mb-1.5 text-[var(--kyro-text-secondary)] uppercase tracking-wider">Role</label>
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
324
|
+
{(() => {
|
|
325
|
+
const isSuperAdmin = currentUserRole === "super_admin";
|
|
326
|
+
return (
|
|
327
|
+
<div>
|
|
328
|
+
<select
|
|
329
|
+
value={createForm.role}
|
|
330
|
+
disabled={!isSuperAdmin}
|
|
331
|
+
onChange={(e) => setCreateForm({ ...createForm, role: e.target.value })}
|
|
332
|
+
className="w-full px-4 py-3 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-xl text-[var(--kyro-text-primary)] focus:outline-none focus:border-[var(--kyro-primary)] disabled:opacity-60 disabled:cursor-not-allowed"
|
|
333
|
+
>
|
|
334
|
+
{roleOptions.map((r) => (
|
|
335
|
+
<option key={r} value={r}>{r}</option>
|
|
336
|
+
))}
|
|
337
|
+
</select>
|
|
338
|
+
{!isSuperAdmin && (
|
|
339
|
+
<p className="text-[11px] text-[var(--kyro-text-secondary)] mt-1 font-medium">
|
|
340
|
+
Only Super Admin can assign administrative roles.
|
|
341
|
+
</p>
|
|
342
|
+
)}
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
})()}
|
|
312
346
|
</div>
|
|
313
347
|
{createError && (
|
|
314
348
|
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-2 text-red-500 text-xs font-bold">
|
|
@@ -3,6 +3,7 @@ import { Dropdown, DropdownItem, DropdownSeparator } from "./ui/Dropdown";
|
|
|
3
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
|
+
import { resolveMedia } from "../lib/paths";
|
|
6
7
|
import { useTranslation } from "react-i18next";
|
|
7
8
|
import { navigate } from '../lib/navigate';
|
|
8
9
|
|
|
@@ -17,17 +18,47 @@ export function UserMenu({ adminPath }: UserMenuProps) {
|
|
|
17
18
|
const [apiAccess, setApiAccess] = useState<{ graphqlEnabled?: boolean } | null>(null);
|
|
18
19
|
|
|
19
20
|
useEffect(() => {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
.then((media) => setAvatarUrl(media?.thumbnailUrl || media?.url || null))
|
|
24
|
-
.catch(() => setAvatarUrl(null));
|
|
25
|
-
} else if (typeof avatar === "string") {
|
|
26
|
-
setAvatarUrl(avatar);
|
|
27
|
-
} else {
|
|
21
|
+
const rawAvatar = (currentUser as any)?.avatar || (currentUser as any)?.photo || (currentUser as any)?.picture || (currentUser as any)?.image;
|
|
22
|
+
|
|
23
|
+
if (!rawAvatar) {
|
|
28
24
|
setAvatarUrl(null);
|
|
25
|
+
return;
|
|
29
26
|
}
|
|
30
|
-
|
|
27
|
+
|
|
28
|
+
if (typeof rawAvatar === "object" && rawAvatar !== null) {
|
|
29
|
+
const url = rawAvatar.thumbnailUrl || rawAvatar.url || rawAvatar.filename;
|
|
30
|
+
if (url) {
|
|
31
|
+
setAvatarUrl(resolveMedia(url));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (typeof rawAvatar === "string") {
|
|
37
|
+
if (rawAvatar.startsWith("http") || rawAvatar.startsWith("data:") || rawAvatar.startsWith("blob:") || rawAvatar.startsWith("/")) {
|
|
38
|
+
setAvatarUrl(resolveMedia(rawAvatar));
|
|
39
|
+
} else if (/^[0-9a-f-]+$/i.test(rawAvatar)) {
|
|
40
|
+
apiGet<any>(`/api/media/${rawAvatar}`)
|
|
41
|
+
.then((media) => {
|
|
42
|
+
const fetchedUrl = media?.thumbnailUrl || media?.url || media?.filename;
|
|
43
|
+
setAvatarUrl(fetchedUrl ? resolveMedia(fetchedUrl) : null);
|
|
44
|
+
})
|
|
45
|
+
.catch(() => setAvatarUrl(null));
|
|
46
|
+
} else {
|
|
47
|
+
setAvatarUrl(resolveMedia(rawAvatar));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}, [currentUser]);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
apiGet<any>("/api/auth/me")
|
|
54
|
+
.then((res: any) => {
|
|
55
|
+
const u = res?.user || (res?.id ? res : null);
|
|
56
|
+
if (u) {
|
|
57
|
+
useAuthStore.getState().setUser(u);
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
.catch(() => {});
|
|
61
|
+
}, []);
|
|
31
62
|
|
|
32
63
|
useEffect(() => {
|
|
33
64
|
apiGet<any>("/api/globals/access-settings")
|
|
@@ -38,16 +69,29 @@ export function UserMenu({ adminPath }: UserMenuProps) {
|
|
|
38
69
|
.catch(() => setApiAccess({}));
|
|
39
70
|
}, []);
|
|
40
71
|
|
|
72
|
+
const userRole = currentUser?.role || "";
|
|
73
|
+
const isSuperAdmin = userRole === "super_admin";
|
|
74
|
+
const isAdmin = userRole === "admin" || isSuperAdmin;
|
|
75
|
+
const authPermissions = typeof window !== "undefined" ? (window as any).__kyroAuth?.permissions : null;
|
|
76
|
+
const canReadAudit = isAdmin || authPermissions?.collections?.audit_logs?.read === true;
|
|
77
|
+
|
|
78
|
+
const showDeveloperSection = isAdmin || canReadAudit;
|
|
79
|
+
|
|
41
80
|
return (
|
|
42
81
|
<Dropdown
|
|
43
82
|
align="right"
|
|
44
83
|
trigger={
|
|
45
84
|
<div
|
|
46
|
-
className="flex justify-center
|
|
47
|
-
title={t("userMenu.account", { defaultValue: "Account" })}
|
|
85
|
+
className="flex items-center justify-center w-7 h-7 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface)] rounded-xl transition-all shadow-sm active:scale-95 overflow-hidden"
|
|
86
|
+
title={currentUser?.email || t("userMenu.account", { defaultValue: "Account" })}
|
|
48
87
|
>
|
|
49
88
|
{avatarUrl ? (
|
|
50
|
-
<img
|
|
89
|
+
<img
|
|
90
|
+
src={avatarUrl}
|
|
91
|
+
alt=""
|
|
92
|
+
className="w-full h-full rounded-xl object-cover"
|
|
93
|
+
onError={() => setAvatarUrl(null)}
|
|
94
|
+
/>
|
|
51
95
|
) : (
|
|
52
96
|
<User className="w-4 h-4" strokeWidth={2.5} />
|
|
53
97
|
)}
|
|
@@ -74,61 +118,79 @@ export function UserMenu({ adminPath }: UserMenuProps) {
|
|
|
74
118
|
{t("userMenu.profileSettings", { defaultValue: "Profile Settings" })}
|
|
75
119
|
</DropdownItem>
|
|
76
120
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
121
|
+
{isSuperAdmin && (
|
|
122
|
+
<DropdownItem
|
|
123
|
+
icon={<Shield className="w-4 h-4" />}
|
|
124
|
+
onClick={() => navigate(`${adminPath}/roles`)}
|
|
125
|
+
>
|
|
126
|
+
{t("userMenu.permissions", { defaultValue: "Permissions" })}
|
|
127
|
+
</DropdownItem>
|
|
128
|
+
)}
|
|
83
129
|
|
|
84
|
-
|
|
130
|
+
{showDeveloperSection && (
|
|
131
|
+
<>
|
|
132
|
+
<DropdownSeparator />
|
|
133
|
+
|
|
134
|
+
<div className="px-4 py-2 mb-1">
|
|
135
|
+
<p className="text-[10px] font-medium tracking-[0.2em] text-[var(--kyro-text-secondary)] opacity-40">
|
|
136
|
+
{t("userMenu.developer", { defaultValue: "Developer" })}
|
|
137
|
+
</p>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
{isAdmin && (
|
|
141
|
+
<DropdownItem
|
|
142
|
+
icon={<Key className="w-4 h-4" />}
|
|
143
|
+
onClick={() => navigate(`${adminPath}/keys`)}
|
|
144
|
+
>
|
|
145
|
+
{t("userMenu.apiKeys", { defaultValue: "API Keys" })}
|
|
146
|
+
</DropdownItem>
|
|
147
|
+
)}
|
|
85
148
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
149
|
+
{isAdmin && (
|
|
150
|
+
<DropdownItem
|
|
151
|
+
icon={<Webhook className="w-4 h-4" />}
|
|
152
|
+
onClick={() => navigate(`${adminPath}/webhooks`)}
|
|
153
|
+
>
|
|
154
|
+
{t("userMenu.webHooks", { defaultValue: "Web Hooks" })}
|
|
155
|
+
</DropdownItem>
|
|
156
|
+
)}
|
|
91
157
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
158
|
+
{isAdmin && (
|
|
159
|
+
<DropdownItem
|
|
160
|
+
icon={<Clock className="w-4 h-4" />}
|
|
161
|
+
onClick={() => navigate(`${adminPath}/sessions`)}
|
|
162
|
+
>
|
|
163
|
+
{t("userMenu.sessions", { defaultValue: "Sessions" })}
|
|
164
|
+
</DropdownItem>
|
|
165
|
+
)}
|
|
98
166
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
onClick={() => (navigate(`${adminPath}/sessions`))}
|
|
108
|
-
>
|
|
109
|
-
{t("userMenu.sessions", { defaultValue: "Sessions" })}
|
|
110
|
-
</DropdownItem>
|
|
111
|
-
<DropdownItem
|
|
112
|
-
icon={<FileText className="w-4 h-4" />}
|
|
113
|
-
onClick={() => (navigate(`${adminPath}/audit`))}
|
|
114
|
-
>
|
|
115
|
-
{t("userMenu.auditLogs", { defaultValue: "Audit Logs" })}
|
|
116
|
-
</DropdownItem>
|
|
167
|
+
{canReadAudit && (
|
|
168
|
+
<DropdownItem
|
|
169
|
+
icon={<FileText className="w-4 h-4" />}
|
|
170
|
+
onClick={() => navigate(`${adminPath}/audit`)}
|
|
171
|
+
>
|
|
172
|
+
{t("userMenu.auditLogs", { defaultValue: "Audit Logs" })}
|
|
173
|
+
</DropdownItem>
|
|
174
|
+
)}
|
|
117
175
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
176
|
+
{isAdmin && (
|
|
177
|
+
<DropdownItem
|
|
178
|
+
icon={<Terminal className="w-4 h-4" />}
|
|
179
|
+
onClick={() => navigate(`${adminPath}/rest-playground`)}
|
|
180
|
+
>
|
|
181
|
+
{t("userMenu.apiExplorer", { defaultValue: "API Explorer" })}
|
|
182
|
+
</DropdownItem>
|
|
183
|
+
)}
|
|
124
184
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
185
|
+
{isAdmin && (apiAccess === null || apiAccess?.graphqlEnabled) && (
|
|
186
|
+
<DropdownItem
|
|
187
|
+
icon={<Zap className="w-4 h-4" />}
|
|
188
|
+
onClick={() => navigate(`${adminPath}/graphql`)}
|
|
189
|
+
>
|
|
190
|
+
{t("userMenu.graphqlPlayground", { defaultValue: "GraphQL Playground" })}
|
|
191
|
+
</DropdownItem>
|
|
192
|
+
)}
|
|
193
|
+
</>
|
|
132
194
|
)}
|
|
133
195
|
|
|
134
196
|
<DropdownSeparator />
|
|
@@ -16,13 +16,13 @@ export function AutoFormEditView({
|
|
|
16
16
|
collectionSlug,
|
|
17
17
|
renderField,
|
|
18
18
|
}: AutoFormEditViewProps) {
|
|
19
|
-
|
|
19
|
+
const { t } = useTranslation();
|
|
20
20
|
const { showPreview, sidebarCollapsed, formData, previewUrl } = useAutoFormStore();
|
|
21
21
|
|
|
22
22
|
if (layout === "single") {
|
|
23
23
|
return (
|
|
24
24
|
<div className="w-full space-y-6 md:space-y-8">
|
|
25
|
-
<div className="surface-tile p-4 md:p-8 space-y-6 md:space-y-8">
|
|
25
|
+
<div className="surface-tile p-4 md:p-8 space-y-6 md:space-y-8 rounded-lg">
|
|
26
26
|
{config.fields.map((f: Field) => renderField(f))}
|
|
27
27
|
</div>
|
|
28
28
|
</div>
|
|
@@ -47,7 +47,7 @@ export function AutoFormEditView({
|
|
|
47
47
|
{config.tabs ? (
|
|
48
48
|
renderField({ type: "tabs", tabs: config.tabs } as Field)
|
|
49
49
|
) : (
|
|
50
|
-
<div className="surface-tile p-4 md:p-8 space-y-6 md:space-y-8">
|
|
50
|
+
<div className="surface-tile p-4 md:p-8 space-y-6 md:space-y-8 rounded-lg">
|
|
51
51
|
{config.fields
|
|
52
52
|
.filter(
|
|
53
53
|
(f: Field) => !f.admin?.position || f.admin.position === "main",
|
|
@@ -77,7 +77,7 @@ export function AutoFormEditView({
|
|
|
77
77
|
) : sidebarCollapsed ? null : (
|
|
78
78
|
<div className="space-y-4 md:space-y-6 animate-in fade-in slide-in-from-right-4 duration-500">
|
|
79
79
|
{config.fields.some((f: Field) => f.admin?.position === "sidebar") && (
|
|
80
|
-
<div className="surface-tile p-4 md:p-6 space-y-4 md:space-y-6">
|
|
80
|
+
<div className="surface-tile p-4 md:p-6 space-y-4 md:space-y-6 rounded-lg">
|
|
81
81
|
<h3 className="text-[10px] font-bold tracking-[0.2em] opacity-40">
|
|
82
82
|
Settings
|
|
83
83
|
</h3>
|
|
@@ -42,7 +42,7 @@ export function AutoFormHeader({
|
|
|
42
42
|
handleSchedulePublish,
|
|
43
43
|
handleConflictOverride,
|
|
44
44
|
}: AutoFormHeaderProps) {
|
|
45
|
-
|
|
45
|
+
const { t } = useTranslation();
|
|
46
46
|
const [now, setNow] = useState(Date.now());
|
|
47
47
|
const [showSchedulePicker, setShowSchedulePicker] = useState(false);
|
|
48
48
|
const scheduleRef = useRef<HTMLDivElement>(null);
|
|
@@ -402,7 +402,7 @@ export function AutoFormHeader({
|
|
|
402
402
|
</header>
|
|
403
403
|
|
|
404
404
|
{/* DESKTOP HEADER */}
|
|
405
|
-
<header className="hidden md:flex surface-tile px-8 py-6 items-center justify-between sticky top-0 border-b border-[var(--kyro-border)] mb-8 bg-[var(--kyro-surface)] z-50 backdrop-blur-md">
|
|
405
|
+
<header className="hidden md:flex surface-tile px-8 py-6 items-center justify-between sticky top-0 border-b border-[var(--kyro-border)] mb-8 bg-[var(--kyro-surface)] z-50 backdrop-blur-md rounded-lg">
|
|
406
406
|
<div className="flex flex-col gap-2 min-w-0">
|
|
407
407
|
<div className="flex items-center gap-3 flex-wrap min-w-0">
|
|
408
408
|
<a
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
import { CSS } from "@dnd-kit/utilities";
|
|
20
20
|
import { useTranslation } from "react-i18next";
|
|
21
21
|
|
|
22
|
-
const SIMPLE_TYPES = new Set(["text", "number", "checkbox", "select", "radio", "color", "email", "password"]);
|
|
22
|
+
const SIMPLE_TYPES = new Set(["text", "number", "checkbox", "select", "radio", "color", "email", "password", "url", "id"]);
|
|
23
23
|
|
|
24
24
|
function extractLabelFromObj(obj: unknown): string | null {
|
|
25
25
|
if (!obj || typeof obj !== "object") return null;
|
|
@@ -120,7 +120,7 @@ function SortableArrayItem({
|
|
|
120
120
|
<div
|
|
121
121
|
ref={setNodeRef}
|
|
122
122
|
style={style}
|
|
123
|
-
className="flex items-start gap-
|
|
123
|
+
className="flex items-start gap-3 p-3.5 mb-2 border border-[var(--kyro-border)] rounded-[var(--kyro-radius-md)] bg-[var(--kyro-surface-accent)]/15 hover:border-[var(--kyro-border-accent)] transition-all group/item shadow-xs"
|
|
124
124
|
>
|
|
125
125
|
<div
|
|
126
126
|
{...attributes}
|
|
@@ -132,9 +132,9 @@ function SortableArrayItem({
|
|
|
132
132
|
<span className="text-[10px] font-bold text-[var(--kyro-text-muted)] pt-2.5 min-w-[18px] text-center">
|
|
133
133
|
{index + 1}
|
|
134
134
|
</span>
|
|
135
|
-
<div className={`flex-1 min-w-0 ${fields.length >= 3 ? "flex flex-col gap-
|
|
135
|
+
<div className={`flex-1 min-w-0 ${fields.length >= 3 ? "flex flex-col gap-2" : "grid grid-cols-1 sm:grid-cols-2 gap-3"}`}>
|
|
136
136
|
{fields.map((f: Field) => (
|
|
137
|
-
<div key={f.name} className="
|
|
137
|
+
<div key={f.name} className="min-w-0">
|
|
138
138
|
{renderField(f, item, onChangeItem)}
|
|
139
139
|
</div>
|
|
140
140
|
))}
|
|
@@ -143,10 +143,10 @@ function SortableArrayItem({
|
|
|
143
143
|
type="button"
|
|
144
144
|
disabled={disabled}
|
|
145
145
|
onClick={onRemove}
|
|
146
|
-
className="text-[var(--kyro-text-muted)] hover:text-[var(--kyro-error)] transition-colors disabled:opacity-30 p-
|
|
146
|
+
className="text-[var(--kyro-text-muted)] hover:text-[var(--kyro-error)] transition-colors disabled:opacity-30 p-1 mt-1.5 flex-shrink-0 rounded hover:bg-[var(--kyro-surface-accent)]"
|
|
147
147
|
title={t("tooltips.remove", { defaultValue: "Remove" })}
|
|
148
148
|
>
|
|
149
|
-
<svg width="
|
|
149
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
150
150
|
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
|
151
151
|
</svg>
|
|
152
152
|
</button>
|
|
@@ -437,7 +437,7 @@ export function ArrayLayout({
|
|
|
437
437
|
onDragEnd={handleDragEnd}
|
|
438
438
|
>
|
|
439
439
|
{compact ? (
|
|
440
|
-
<div className="kyro-form-array kyro-form-array--compact border border-[var(--kyro-border)] bg-[var(--kyro-surface-accent)]/
|
|
440
|
+
<div className="kyro-form-array kyro-form-array--compact border border-[var(--kyro-border)] bg-[var(--kyro-surface-accent)]/10 rounded-lg p-3">
|
|
441
441
|
<SortableContext
|
|
442
442
|
items={itemIds}
|
|
443
443
|
strategy={verticalListSortingStrategy}
|
|
@@ -466,7 +466,7 @@ export function ArrayLayout({
|
|
|
466
466
|
</SortableContext>
|
|
467
467
|
<button
|
|
468
468
|
type="button"
|
|
469
|
-
className="w-full py-2 border
|
|
469
|
+
className="w-full py-2.5 border border-dashed border-[var(--kyro-border)] rounded-md text-xs font-bold text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-primary)] hover:border-[var(--kyro-primary)] bg-[var(--kyro-surface)]/50 transition-all disabled:opacity-50 mt-1"
|
|
470
470
|
disabled={disabled}
|
|
471
471
|
onClick={() => onChange([...items, { id: Math.random().toString(36).substr(2, 9) }])}
|
|
472
472
|
>
|
|
@@ -5,6 +5,7 @@ import { SeoPreview } from "../ui/SeoPreview";
|
|
|
5
5
|
interface TabsLayoutProps {
|
|
6
6
|
field: Field;
|
|
7
7
|
formData: Record<string, unknown>;
|
|
8
|
+
collectionSlug?: string;
|
|
8
9
|
onTabDataChange: (value: unknown) => void;
|
|
9
10
|
renderField: (
|
|
10
11
|
field: Field,
|
|
@@ -16,6 +17,7 @@ interface TabsLayoutProps {
|
|
|
16
17
|
export function TabsLayout({
|
|
17
18
|
field,
|
|
18
19
|
formData,
|
|
20
|
+
collectionSlug,
|
|
19
21
|
onTabDataChange,
|
|
20
22
|
renderField,
|
|
21
23
|
}: TabsLayoutProps) {
|
|
@@ -39,7 +41,7 @@ export function TabsLayout({
|
|
|
39
41
|
<button
|
|
40
42
|
key={index}
|
|
41
43
|
type="button"
|
|
42
|
-
className={`px-6 py-3 text-sm
|
|
44
|
+
className={`px-6 py-3 text-sm tracking-widest font-medium transition-all border-b-2 -mb-[1px] whitespace-nowrap ${
|
|
43
45
|
activeTab === index
|
|
44
46
|
? "border-[var(--kyro-primary)] text-[var(--kyro-primary)]"
|
|
45
47
|
: "border-transparent text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] opacity-60 hover:opacity-100"
|
|
@@ -63,7 +65,7 @@ export function TabsLayout({
|
|
|
63
65
|
|
|
64
66
|
{currentTab?.label === "SEO Settings" && (
|
|
65
67
|
<div className="mt-12 pt-8 border-t border-[var(--kyro-border)]">
|
|
66
|
-
<h4 className="text-[10px] font-bold text-[var(--kyro-text-secondary)]
|
|
68
|
+
<h4 className="text-[10px] font-bold text-[var(--kyro-text-secondary)] tracking-[0.2em] mb-6 opacity-50">
|
|
67
69
|
Live Google Preview
|
|
68
70
|
</h4>
|
|
69
71
|
<SeoPreview
|
|
@@ -80,6 +82,7 @@ export function TabsLayout({
|
|
|
80
82
|
(typeof formData.slug === "object" ? "" : formData.slug) ||
|
|
81
83
|
"your-slug"
|
|
82
84
|
)}
|
|
85
|
+
collectionSlug={collectionSlug}
|
|
83
86
|
/>
|
|
84
87
|
</div>
|
|
85
88
|
)}
|
|
@@ -140,7 +140,7 @@ export function CommandPalette({
|
|
|
140
140
|
type: "action",
|
|
141
141
|
view: "media",
|
|
142
142
|
icon: ImageIcon,
|
|
143
|
-
visible: permissions?.collections?.media?.read
|
|
143
|
+
visible: isAdmin || permissions?.collections?.media?.read === true,
|
|
144
144
|
},
|
|
145
145
|
{
|
|
146
146
|
id: "action-users",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React, { useState, useRef, useEffect, type ReactNode } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
2
3
|
|
|
3
4
|
interface DropdownProps {
|
|
4
5
|
trigger: ReactNode;
|
|
@@ -14,37 +15,91 @@ export function Dropdown({
|
|
|
14
15
|
direction = "up",
|
|
15
16
|
}: DropdownProps) {
|
|
16
17
|
const [open, setOpen] = useState(false);
|
|
17
|
-
const
|
|
18
|
+
const triggerRef = useRef<HTMLDivElement>(null);
|
|
19
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
20
|
+
const [coords, setCoords] = useState<{ top?: number; bottom?: number; left?: number; right?: number }>({});
|
|
21
|
+
const [mounted, setMounted] = useState(false);
|
|
18
22
|
|
|
19
23
|
useEffect(() => {
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
setMounted(true);
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!open || !triggerRef.current) return;
|
|
29
|
+
|
|
30
|
+
const sidebar = triggerRef.current.closest("#kyro-sidebar");
|
|
31
|
+
const isMinimized = sidebar?.getAttribute("data-minimized") === "true";
|
|
32
|
+
const rect = triggerRef.current.getBoundingClientRect();
|
|
33
|
+
|
|
34
|
+
if (isMinimized) {
|
|
35
|
+
// Sidebar is minimized: float dropdown menu to the right of the sidebar rail
|
|
36
|
+
setCoords({
|
|
37
|
+
left: rect.right + 12,
|
|
38
|
+
bottom: Math.max(12, window.innerHeight - rect.bottom),
|
|
39
|
+
});
|
|
40
|
+
} else {
|
|
41
|
+
// Standard dropdown positioning inside trigger bounds
|
|
42
|
+
if (align === "right") {
|
|
43
|
+
setCoords({
|
|
44
|
+
right: Math.max(12, window.innerWidth - rect.right),
|
|
45
|
+
bottom: direction === "up" ? Math.max(12, window.innerHeight - rect.top + 8) : undefined,
|
|
46
|
+
top: direction === "down" ? rect.bottom + 8 : undefined,
|
|
47
|
+
});
|
|
48
|
+
} else {
|
|
49
|
+
setCoords({
|
|
50
|
+
left: rect.left,
|
|
51
|
+
bottom: direction === "up" ? Math.max(12, window.innerHeight - rect.top + 8) : undefined,
|
|
52
|
+
top: direction === "down" ? rect.bottom + 8 : undefined,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const handleOutsideClick = (e: MouseEvent) => {
|
|
58
|
+
if (
|
|
59
|
+
triggerRef.current && !triggerRef.current.contains(e.target as Node) &&
|
|
60
|
+
menuRef.current && !menuRef.current.contains(e.target as Node)
|
|
61
|
+
) {
|
|
22
62
|
setOpen(false);
|
|
23
63
|
}
|
|
24
64
|
};
|
|
25
65
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
66
|
+
const handleScrollOrResize = () => {
|
|
67
|
+
setOpen(false);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
document.addEventListener("mousedown", handleOutsideClick);
|
|
71
|
+
window.addEventListener("scroll", handleScrollOrResize, { capture: true, passive: true });
|
|
72
|
+
window.addEventListener("resize", handleScrollOrResize);
|
|
73
|
+
|
|
74
|
+
return () => {
|
|
75
|
+
document.removeEventListener("mousedown", handleOutsideClick);
|
|
76
|
+
window.removeEventListener("scroll", handleScrollOrResize, { capture: true });
|
|
77
|
+
window.removeEventListener("resize", handleScrollOrResize);
|
|
78
|
+
};
|
|
79
|
+
}, [open, align, direction]);
|
|
31
80
|
|
|
32
81
|
return (
|
|
33
|
-
<div className="relative" ref={
|
|
82
|
+
<div className="relative inline-block" ref={triggerRef}>
|
|
34
83
|
<div onClick={() => setOpen(!open)} className="cursor-pointer">
|
|
35
84
|
{trigger}
|
|
36
85
|
</div>
|
|
37
|
-
|
|
86
|
+
|
|
87
|
+
{open && mounted && createPortal(
|
|
38
88
|
<div
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
89
|
+
ref={menuRef}
|
|
90
|
+
style={{
|
|
91
|
+
position: "fixed",
|
|
92
|
+
top: coords.top !== undefined ? `${coords.top}px` : "auto",
|
|
93
|
+
bottom: coords.bottom !== undefined ? `${coords.bottom}px` : "auto",
|
|
94
|
+
left: coords.left !== undefined ? `${coords.left}px` : "auto",
|
|
95
|
+
right: coords.right !== undefined ? `${coords.right}px` : "auto",
|
|
96
|
+
}}
|
|
97
|
+
className="z-[99999] min-w-[220px] max-h-[80vh] overflow-y-auto py-2 bg-[var(--kyro-surface)] rounded-2xl shadow-2xl border border-[var(--kyro-border)] animate-in fade-in zoom-in-95 duration-100"
|
|
44
98
|
onClick={() => setOpen(false)}
|
|
45
99
|
>
|
|
46
100
|
{children}
|
|
47
|
-
</div
|
|
101
|
+
</div>,
|
|
102
|
+
document.body
|
|
48
103
|
)}
|
|
49
104
|
</div>
|
|
50
105
|
);
|
|
@@ -170,7 +170,7 @@ export function PageHeader({
|
|
|
170
170
|
const lastBreadcrumb = breadcrumbs?.[breadcrumbs.length - 1];
|
|
171
171
|
|
|
172
172
|
return (
|
|
173
|
-
<div className="surface-tile px-3 md:px-6 py-3 md:pt-4 mb-4 md:mb-8">
|
|
173
|
+
<div className="surface-tile px-3 md:px-6 py-3 md:pt-4 mb-4 md:mb-8 rounded-lg">
|
|
174
174
|
{/* ─── MOBILE ─── */}
|
|
175
175
|
<div className="md:hidden space-y-2">
|
|
176
176
|
{(breadcrumbs || back) && (
|
|
@@ -13,7 +13,7 @@ export function Pagination({ page, totalPages, totalDocs, limit, onPageChange, o
|
|
|
13
13
|
if (totalPages <= 1) return null;
|
|
14
14
|
|
|
15
15
|
return (
|
|
16
|
-
<div className="surface-tile flex flex-col sm:flex-row items-center justify-between gap-3 px-4 py-3 border-t border-[var(--kyro-border)]">
|
|
16
|
+
<div className="surface-tile flex flex-col sm:flex-row items-center justify-between gap-3 px-4 py-3 border-t border-[var(--kyro-border)] rounded-lg">
|
|
17
17
|
{totalDocs !== undefined && limit ? (
|
|
18
18
|
<span className="text-xs text-[var(--kyro-text-secondary)] font-medium">
|
|
19
19
|
Showing {(page - 1) * limit + 1} to {Math.min(page * limit, totalDocs)} of {totalDocs}
|