@kyro-cms/admin 0.12.20 → 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.
Files changed (56) hide show
  1. package/dist/index.cjs +24 -24
  2. package/dist/index.d.cts +2 -1
  3. package/dist/index.d.ts +2 -1
  4. package/dist/index.js +19 -19
  5. package/package.json +6 -6
  6. package/src/components/ApiKeysManager.tsx +177 -227
  7. package/src/components/AuditLogsPage.tsx +7 -7
  8. package/src/components/AuthorDashboard.tsx +235 -0
  9. package/src/components/AutoForm.tsx +14 -4
  10. package/src/components/CustomerDashboard.tsx +213 -0
  11. package/src/components/Dashboard.tsx +1 -1
  12. package/src/components/DashboardMetrics.tsx +74 -9
  13. package/src/components/DashboardQuickSections.tsx +237 -0
  14. package/src/components/ListView.tsx +3 -3
  15. package/src/components/MediaGallery.tsx +2 -2
  16. package/src/components/PluginsManager.tsx +1 -1
  17. package/src/components/SessionsManager.tsx +15 -15
  18. package/src/components/Sidebar.astro +564 -116
  19. package/src/components/UserManagement.tsx +43 -9
  20. package/src/components/UserMenu.tsx +123 -61
  21. package/src/components/autoform/AutoFormEditView.tsx +4 -4
  22. package/src/components/autoform/AutoFormHeader.tsx +2 -2
  23. package/src/components/ui/CommandPalette.tsx +1 -1
  24. package/src/components/ui/Dropdown.tsx +71 -16
  25. package/src/components/ui/PageHeader.tsx +1 -1
  26. package/src/components/ui/Pagination.tsx +1 -1
  27. package/src/components/ui/Toast.tsx +3 -2
  28. package/src/components/ui/Toaster.tsx +1 -1
  29. package/src/components/users/UserDetail.tsx +74 -29
  30. package/src/components/users/UserForm.tsx +1 -1
  31. package/src/components/users/UsersList.tsx +2 -2
  32. package/src/integration.ts +4 -0
  33. package/src/layouts/AdminLayout.astro +26 -34
  34. package/src/layouts/AuthLayout.astro +18 -9
  35. package/src/lib/auth-server.ts +99 -0
  36. package/src/pages/403.astro +54 -35
  37. package/src/pages/[collection]/[id].astro +8 -0
  38. package/src/pages/[collection]/index.astro +7 -0
  39. package/src/pages/audit/index.astro +4 -1
  40. package/src/pages/auth/check-email.astro +146 -0
  41. package/src/pages/auth/forgot-password.astro +95 -0
  42. package/src/pages/auth/login.astro +29 -12
  43. package/src/pages/auth/register.astro +38 -12
  44. package/src/pages/auth/reset-password.astro +125 -0
  45. package/src/pages/auth/verify-email.astro +87 -0
  46. package/src/pages/index.astro +220 -121
  47. package/src/pages/keys.astro +3 -1
  48. package/src/pages/media.astro +3 -1
  49. package/src/pages/preview/[collection]/[id].astro +23 -73
  50. package/src/pages/roles/index.astro +134 -101
  51. package/src/pages/settings/[slug].astro +43 -3
  52. package/src/pages/users/[id].astro +5 -1
  53. package/src/pages/users/index.astro +4 -0
  54. package/src/pages/users/new.astro +4 -0
  55. package/src/pages/webhooks.astro +3 -1
  56. 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
- <select
304
- value={createForm.role}
305
- onChange={(e) => setCreateForm({ ...createForm, role: e.target.value })}
306
- 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)]"
307
- >
308
- {roleOptions.map((r) => (
309
- <option key={r} value={r}>{r}</option>
310
- ))}
311
- </select>
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 avatar = currentUser?.avatar;
21
- if (typeof avatar === "string" && /^[0-9a-f-]+$/i.test(avatar)) {
22
- apiGet<any>(`/api/media/${avatar}`)
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
- }, [currentUser?.avatar]);
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 p-.5 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"
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 src={avatarUrl} alt="" className="w-8 h-8 rounded-full object-cover" />
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
- <DropdownItem
78
- icon={<Shield className="w-4 h-4" />}
79
- onClick={() => navigate(`${adminPath}/roles`)}
80
- >
81
- {t("userMenu.permissions", { defaultValue: "Permissions" })}
82
- </DropdownItem>
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
- <DropdownSeparator />
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
- <div className="px-4 py-2 mb-1">
87
- <p className="text-[10px] font-medium tracking-[0.2em] text-[var(--kyro-text-secondary)] opacity-40">
88
- {t("userMenu.developer", { defaultValue: "Developer" })}
89
- </p>
90
- </div>
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
- <DropdownItem
93
- icon={<Key className="w-4 h-4" />}
94
- onClick={() => (navigate(`${adminPath}/keys`))}
95
- >
96
- {t("userMenu.apiKeys", { defaultValue: "API Keys" })}
97
- </DropdownItem>
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
- <DropdownItem
100
- icon={<Webhook className="w-4 h-4" />}
101
- onClick={() => (navigate(`${adminPath}/webhooks`))}
102
- >
103
- {t("userMenu.webHooks", { defaultValue: "Web Hooks" })}
104
- </DropdownItem>
105
- <DropdownItem
106
- icon={<Clock className="w-4 h-4" />}
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
- <DropdownItem
119
- icon={<Terminal className="w-4 h-4" />}
120
- onClick={() => (navigate(`${adminPath}/rest-playground`))}
121
- >
122
- {t("userMenu.apiExplorer", { defaultValue: "API Explorer" })}
123
- </DropdownItem>
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
- {(apiAccess === null || apiAccess?.graphqlEnabled) && (
126
- <DropdownItem
127
- icon={<Zap className="w-4 h-4" />}
128
- onClick={() => (navigate(`${adminPath}/graphql`))}
129
- >
130
- {t("userMenu.graphqlPlayground", { defaultValue: "GraphQL Playground" })}
131
- </DropdownItem>
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
- const { t } = useTranslation();
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
- const { t } = useTranslation();
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
@@ -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 !== false,
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 ref = useRef<HTMLDivElement>(null);
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
- const handleClickOutside = (e: MouseEvent) => {
21
- if (ref.current && !ref.current.contains(e.target as Node)) {
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
- if (open) {
27
- document.addEventListener("mousedown", handleClickOutside);
28
- }
29
- return () => document.removeEventListener("mousedown", handleClickOutside);
30
- }, [open]);
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={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
- {open && (
86
+
87
+ {open && mounted && createPortal(
38
88
  <div
39
- className={`absolute z-[100] min-w-[200px] py-2 bg-[var(--kyro-surface)] rounded-2xl shadow-2xl border border-[var(--kyro-border)] animate-in fade-in zoom-in-95 duration-100 ${
40
- align === "right" ? "right-0" : "left-0"
41
- } ${
42
- direction === "down" ? "top-full mt-2" : "bottom-full mb-2"
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}
@@ -14,16 +14,17 @@ type ToastType = "success" | "error" | "warning" | "info";
14
14
  interface ToastProps {
15
15
  type: ToastType;
16
16
  message: string;
17
+ duration?: number;
17
18
  onClose: () => void;
18
19
  }
19
20
 
20
- export function Toast({ type, message, onClose }: ToastProps) {
21
+ export function Toast({ type, message, duration = 3000, onClose }: ToastProps) {
21
22
  const [isPaused, setIsPaused] = React.useState(false);
22
23
  const timerRef = React.useRef<NodeJS.Timeout | null>(null);
23
24
 
24
25
  const startTimer = () => {
25
26
  if (timerRef.current) clearTimeout(timerRef.current);
26
- timerRef.current = setTimeout(onClose, 5000);
27
+ timerRef.current = setTimeout(onClose, duration);
27
28
  };
28
29
 
29
30
  const clearTimer = () => {
@@ -9,7 +9,7 @@ export function Toaster() {
9
9
  <div className="kyro-toasts-container" style={{ position: "fixed", bottom: "24px", right: "24px", display: "flex", flexDirection: "column", gap: "12px", zIndex: 100000, pointerEvents: "none" }}>
10
10
  {toasts.map((t) => (
11
11
  <div key={t.id} style={{ pointerEvents: "auto" }}>
12
- <Toast type={t.type} message={t.message} onClose={() => removeToast(t.id)} />
12
+ <Toast type={t.type} message={t.message} duration={(t as any).duration} onClose={() => removeToast(t.id)} />
13
13
  </div>
14
14
  ))}
15
15
  </div>