@kyro-cms/admin 0.12.3 → 0.12.5

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 (110) hide show
  1. package/dist/index.cjs +64 -26
  2. package/dist/index.d.cts +2 -1
  3. package/dist/index.d.ts +2 -1
  4. package/dist/index.js +64 -26
  5. package/package.json +8 -2
  6. package/src/components/ActionBar.tsx +16 -16
  7. package/src/components/ApiHealth.tsx +522 -0
  8. package/src/components/ApiKeysManager.tsx +6 -4
  9. package/src/components/AuditLogsPage.tsx +4 -2
  10. package/src/components/AutoForm.tsx +10 -7
  11. package/src/components/BrandingHub.tsx +3 -1
  12. package/src/components/Dashboard.tsx +44 -37
  13. package/src/components/DashboardMetrics.tsx +168 -58
  14. package/src/components/DetailView.tsx +125 -50
  15. package/src/components/DeveloperCenter.tsx +5 -3
  16. package/src/components/GraphQLPlayground.tsx +10 -8
  17. package/src/components/ListView.tsx +66 -45
  18. package/src/components/LoginPage.tsx +21 -19
  19. package/src/components/MarketplaceManager.tsx +3 -1
  20. package/src/components/MediaGallery.tsx +12 -9
  21. package/src/components/PluginsManager.tsx +6 -4
  22. package/src/components/RestPlayground.tsx +8 -6
  23. package/src/components/SessionsManager.tsx +3 -1
  24. package/src/components/Sidebar.astro +180 -92
  25. package/src/components/UserManagement.tsx +8 -6
  26. package/src/components/UserMenu.tsx +19 -17
  27. package/src/components/VersionHistoryPanel.tsx +6 -4
  28. package/src/components/WebhookManager.tsx +143 -15
  29. package/src/components/autoform/AutoFormApiView.tsx +3 -1
  30. package/src/components/autoform/AutoFormEditView.tsx +5 -3
  31. package/src/components/autoform/AutoFormHeader.tsx +6 -6
  32. package/src/components/blocks/AccordionBlock.tsx +5 -3
  33. package/src/components/blocks/ArrayBlock.tsx +5 -3
  34. package/src/components/blocks/BlockWrapper.tsx +5 -3
  35. package/src/components/blocks/CodeBlock.tsx +4 -2
  36. package/src/components/blocks/FileBlock.tsx +5 -3
  37. package/src/components/blocks/HeroBlock.tsx +5 -3
  38. package/src/components/blocks/ImageBlock.tsx +6 -4
  39. package/src/components/blocks/ListBlock.tsx +5 -3
  40. package/src/components/blocks/ParagraphBlock.tsx +3 -1
  41. package/src/components/blocks/RelationshipBlock.tsx +5 -3
  42. package/src/components/blocks/RichTextBlock.tsx +5 -3
  43. package/src/components/blocks/VideoBlock.tsx +5 -3
  44. package/src/components/fields/AccordionField.tsx +10 -51
  45. package/src/components/fields/ArrayField.tsx +9 -7
  46. package/src/components/fields/ArrayLayout.tsx +3 -1
  47. package/src/components/fields/BlocksField.tsx +24 -75
  48. package/src/components/fields/ButtonField.tsx +3 -1
  49. package/src/components/fields/CardField.tsx +6 -4
  50. package/src/components/fields/GroupLayout.tsx +2 -49
  51. package/src/components/fields/HeadingField.tsx +3 -1
  52. package/src/components/fields/HeadingSubheadingField.tsx +4 -2
  53. package/src/components/fields/HeroField.tsx +8 -6
  54. package/src/components/fields/JSONField.tsx +9 -10
  55. package/src/components/fields/LinkField.tsx +4 -2
  56. package/src/components/fields/ListField.tsx +3 -1
  57. package/src/components/fields/MarkdownField.tsx +3 -17
  58. package/src/components/fields/RelationshipBlockField.tsx +7 -5
  59. package/src/components/fields/RelationshipField.tsx +3 -1
  60. package/src/components/fields/RichTextField.tsx +19 -16
  61. package/src/components/fields/SecretField.tsx +9 -69
  62. package/src/components/fields/SelectField.tsx +11 -6
  63. package/src/components/fields/TabsLayout.tsx +2 -48
  64. package/src/components/fields/TextField.tsx +9 -7
  65. package/src/components/fields/UploadField.tsx +3 -1
  66. package/src/components/fields/VideoField.tsx +6 -4
  67. package/src/components/ui/BlockDrawer.tsx +3 -1
  68. package/src/components/ui/CommandPalette.tsx +9 -7
  69. package/src/components/ui/CommandPaletteWrapper.tsx +3 -1
  70. package/src/components/ui/IconPickerModal.tsx +4 -2
  71. package/src/components/ui/PageHeader.tsx +48 -20
  72. package/src/components/users/UserDetail.tsx +3 -1
  73. package/src/components/users/UserForm.tsx +5 -3
  74. package/src/fields/examples/sample-field-2.tsx +3 -1
  75. package/src/fields/examples/sample-field.tsx +3 -1
  76. package/src/hooks/useAutoFormState.ts +19 -11
  77. package/src/integration.ts +21 -0
  78. package/src/layouts/AdminLayout.astro +252 -189
  79. package/src/layouts/AuthLayout.astro +52 -31
  80. package/src/lib/api.ts +7 -7
  81. package/src/lib/autoform-store.ts +4 -0
  82. package/src/lib/deep-equal.ts +3 -3
  83. package/src/lib/i18n.ts +44 -0
  84. package/src/lib/vite-shim-plugin.ts +3 -0
  85. package/src/locales/de.json +163 -0
  86. package/src/locales/en.json +163 -0
  87. package/src/locales/es.json +163 -0
  88. package/src/locales/fr.json +163 -0
  89. package/src/locales/pt.json +163 -0
  90. package/src/pages/403.astro +2 -1
  91. package/src/pages/[collection]/[id].astro +23 -10
  92. package/src/pages/[collection]/index.astro +48 -34
  93. package/src/pages/audit/index.astro +2 -1
  94. package/src/pages/graphql-explorer.astro +2 -1
  95. package/src/pages/graphql.astro +2 -1
  96. package/src/pages/health.astro +9 -0
  97. package/src/pages/index.astro +6 -5
  98. package/src/pages/keys.astro +2 -1
  99. package/src/pages/marketplace.astro +2 -1
  100. package/src/pages/media.astro +2 -1
  101. package/src/pages/plugins.astro +2 -1
  102. package/src/pages/rest-playground.astro +2 -1
  103. package/src/pages/roles/index.astro +16 -52
  104. package/src/pages/sessions.astro +2 -1
  105. package/src/pages/settings/[slug].astro +45 -30
  106. package/src/pages/users/[id].astro +1 -0
  107. package/src/pages/users/index.astro +2 -1
  108. package/src/pages/users/new.astro +2 -1
  109. package/src/pages/webhooks.astro +2 -1
  110. package/src/pages/index_ALT.astro +0 -3
@@ -4,6 +4,7 @@ import { Shield, Monitor, Trash2, Clock, AlertTriangle, Info, LogOut, Globe, Act
4
4
  import { PageHeader } from "./ui/PageHeader";
5
5
  import { toast } from "../lib/stores";
6
6
  import { Badge } from "./ui/Badge";
7
+ import { useTranslation } from "react-i18next";
7
8
 
8
9
  interface Session {
9
10
  id: string;
@@ -33,6 +34,7 @@ function timeAgo(ts: number): string {
33
34
  }
34
35
 
35
36
  export function SessionsManager() {
37
+ const { t } = useTranslation();
36
38
  const [sessions, setSessions] = useState<Session[]>([]);
37
39
  const [loading, setLoading] = useState(true);
38
40
  const [error, setError] = useState("");
@@ -82,7 +84,7 @@ export function SessionsManager() {
82
84
  return (
83
85
  <div className="w-full space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700 pb-32">
84
86
  <PageHeader
85
- title="Active Sessions"
87
+ title={t("tooltips.activeSessions", { defaultValue: "Active Sessions" })}
86
88
  description="Monitor and manage your cryptographic access across all devices."
87
89
  icon={Monitor}
88
90
  actions={otherCount > 0 ? [
@@ -21,6 +21,9 @@ const darkLogo = brandSettings?.identity?.darkLogo;
21
21
  const logoWidth = siteLogo?.width || darkLogo?.width;
22
22
  const logoHeight = siteLogo?.height || darkLogo?.height;
23
23
  const logoAlt = siteLogo?.altText || darkLogo?.altText || siteName;
24
+ const showSiteName = brandSettings?.identity?.showSiteName ?? true;
25
+
26
+ import i18next from "../lib/i18n";
24
27
 
25
28
  interface Props {
26
29
  title: string;
@@ -29,38 +32,59 @@ interface Props {
29
32
  const { title } = Astro.props;
30
33
  const currentPath = Astro.url.pathname;
31
34
 
32
- const collectionItems: NavItem[] = nonAuthCollections
35
+ const groupedCollections = new Map<string, NavItem[]>();
36
+ const defaultCollections: NavItem[] = [];
37
+
38
+ nonAuthCollections
33
39
  .filter((col) => col.slug !== "media") //removed here cos it's been added manually in the navSections.
34
- .map((col) => ({
35
- href: `${adminPath}/${col.slug}`,
36
- label: col.label || col.slug,
37
- icon: col.admin?.icon || "collection",
38
- }));
40
+ .forEach((col) => {
41
+ const item: NavItem = {
42
+ href: `${adminPath}/${col.slug}`,
43
+ label: col.label || col.slug,
44
+ icon: (col.admin?.icon as string) || "collection",
45
+ };
46
+
47
+ if (col.admin?.group) {
48
+ const groupName = col.admin.group as string;
49
+ if (!groupedCollections.has(groupName)) {
50
+ groupedCollections.set(groupName, []);
51
+ }
52
+ groupedCollections.get(groupName)!.push(item);
53
+ } else {
54
+ defaultCollections.push(item);
55
+ }
56
+ });
57
+
58
+ const dynamicSections = Array.from(groupedCollections.entries()).map(([group, items]) => ({
59
+ label: group,
60
+ items: items,
61
+ }));
39
62
 
40
63
  const navSections: { label: string; items: NavItem[] }[] = [
41
64
  {
42
- label: "Home",
65
+ label: "Dashboard",
43
66
  items: [
44
- { href: adminPath, label: "Dashboard", icon: "home" },
67
+ { href: adminPath, label: "nav.dashboard", icon: "home" },
45
68
  {
46
69
  href: adminPath + "/media",
47
- label: "Media Library",
70
+ label: "nav.media",
48
71
  icon: "media",
49
72
  },
50
73
  ],
51
74
  },
52
- {
53
- label: "Collections",
54
- items: collectionItems,
55
- },
75
+ ...dynamicSections,
76
+ ...(defaultCollections.length > 0 ? [{
77
+ label: "Content",
78
+ items: defaultCollections,
79
+ }] : []),
56
80
  {
57
81
  label: "Settings",
58
82
  items: [
59
- { href: adminPath + "/users", label: "Users", icon: "users" },
60
- { href: adminPath + "/plugins", label: "Plugins", icon: "plugins" },
83
+ { href: adminPath + "/users", label: "nav.users", icon: "users" },
84
+ { href: adminPath + "/plugins", label: "nav.plugins", icon: "plugins" },
61
85
  {
62
86
  href: adminPath + "/settings",
63
- label: "Settings",
87
+ label: "nav.settings",
64
88
  icon: "settings",
65
89
  },
66
90
  ],
@@ -101,36 +125,47 @@ function isActive(item: NavItem): boolean {
101
125
  <div class="px-6 md:px-8 py-6 md:py-8 flex items-center justify-between gap-3">
102
126
  {
103
127
  siteLogo || darkLogo ? (
104
- <div class="flex items-center">
105
- {siteLogo && (
106
- <img
107
- src={siteLogo.url}
108
- alt={logoAlt}
109
- style={{
110
- width: logoWidth ? `${logoWidth}px` : "auto",
111
- height: logoHeight ? `${logoHeight}px` : "32px",
112
- objectFit: "contain",
113
- }}
114
- class={`rounded-lg ${darkLogo ? "block dark:hidden" : ""}`}
115
- />
116
- )}
117
- {darkLogo && (
118
- <img
119
- src={darkLogo.url}
120
- alt={logoAlt}
121
- style={{
122
- width: logoWidth ? `${logoWidth}px` : "auto",
123
- height: logoHeight ? `${logoHeight}px` : "32px",
124
- objectFit: "contain",
125
- }}
126
- class={`rounded-lg ${siteLogo ? "hidden dark:block" : ""}`}
127
- />
128
+ <div class="flex items-center gap-3">
129
+ <div class="flex items-center">
130
+ {siteLogo && (
131
+ <img
132
+ src={siteLogo.url}
133
+ alt={logoAlt}
134
+ style={{
135
+ width: logoWidth ? `${logoWidth}px` : "auto",
136
+ height: logoHeight ? `${logoHeight}px` : "32px",
137
+ objectFit: "contain",
138
+ }}
139
+ class={`rounded-lg ${darkLogo ? "block dark:hidden" : ""}`}
140
+ />
141
+ )}
142
+ {darkLogo && (
143
+ <img
144
+ src={darkLogo.url}
145
+ alt={logoAlt}
146
+ style={{
147
+ width: logoWidth ? `${logoWidth}px` : "auto",
148
+ height: logoHeight ? `${logoHeight}px` : "32px",
149
+ objectFit: "contain",
150
+ }}
151
+ class={`rounded-lg ${siteLogo ? "hidden dark:block" : ""}`}
152
+ />
153
+ )}
154
+ </div>
155
+ {showSiteName && (
156
+ <span class="text-xl font-bold tracking-tight text-[var(--kyro-text-primary)]">
157
+ {siteName}
158
+ </span>
128
159
  )}
129
160
  </div>
130
161
  ) : (
131
- <span class="text-2xl font-black tracking-tighter text-[var(--kyro-text-primary)] ">
132
- {siteName}
133
- </span>
162
+ <div class="flex items-center gap-3">
163
+ <img src="/logo-white.svg" alt="Kyro CMS" class="w-7 h-7 hidden dark:block" />
164
+ <img src="/logo.svg" alt="Kyro CMS" class="w-7 h-7 block dark:hidden" />
165
+ <span class="text-2xl font-black tracking-tighter text-[var(--kyro-text-primary)]">
166
+ {siteName}
167
+ </span>
168
+ </div>
134
169
  )
135
170
  }
136
171
  <button id="mobile-close-btn" class="md:hidden p-2 -mr-2 rounded-lg text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)] transition-colors">
@@ -142,43 +177,54 @@ function isActive(item: NavItem): boolean {
142
177
  <div class="space-y-4">
143
178
  {
144
179
  navSections.map((section) => (
145
- <div class="space-y-2" data-section={section.label}>
180
+ <div class="space-y-2 group/section" data-section={section.label}>
146
181
  <div class="pt-4 pb-2">
147
- <p class="px-6 text-[10px] font-medium text-[var(--kyro-text-secondary)] tracking-[0.2em] opacity-40">
148
- {section.label}
149
- </p>
182
+ <button type="button" class="w-full flex items-center justify-between px-6 focus:outline-none sidebar-section-toggle transition-opacity hover:opacity-80">
183
+ <p class="text-[10px] font-medium text-[var(--kyro-text-secondary)] tracking-[0.2em] opacity-40 uppercase">
184
+ {i18next.t(section.label, { defaultValue: section.label.split('.').pop()?.toUpperCase() || section.label }) || (section.label.split('.').pop()?.toUpperCase() || section.label)}
185
+ </p>
186
+ <Icons.ChevronDown className="w-3.5 h-3.5 text-[var(--kyro-text-secondary)] opacity-40 transition-transform duration-200 group-data-[collapsed=true]/section:-rotate-90" />
187
+ </button>
188
+ </div>
189
+ <div class="section-items space-y-1">
190
+ {section.items.map((item) => {
191
+ const slug = item.href.split("/").pop();
192
+ const type = item.href.includes("/settings")
193
+ ? "global"
194
+ : "collection";
195
+ return (
196
+ <a
197
+ href={item.href}
198
+ data-nav-item
199
+ data-slug={slug}
200
+ data-type={type}
201
+ class={`flex items-center gap-4 px-6 py-2 rounded-2xl transition-all font-medium text-[13px] ${
202
+ item.icon === "collection"
203
+ ? currentPath === item.href ||
204
+ currentPath.startsWith(item.href + "/")
205
+ ? "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-primary)] active-nav-item"
206
+ : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)] hover:text-[var(--kyro-text-primary)]"
207
+ : isActive(item)
208
+ ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] shadow-lg active-nav-item"
209
+ : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)] hover:text-[var(--kyro-text-primary)]"
210
+ }`}
211
+ >
212
+ {(() => {
213
+ const iconName = iconAliases[item.icon] || item.icon;
214
+ const Icon = (Icons as any)[iconName] || Icons.Dot;
215
+ return <Icon className="w-4 h-4" strokeWidth={2.5} />;
216
+ })()}
217
+ <span>
218
+ {(() => {
219
+ const fallback = item.label.split('.').pop() || item.label;
220
+ const capitalized = fallback.charAt(0).toUpperCase() + fallback.slice(1);
221
+ return i18next.t(item.label, { defaultValue: capitalized }) || capitalized;
222
+ })()}
223
+ </span>
224
+ </a>
225
+ );
226
+ })}
150
227
  </div>
151
- {section.items.map((item) => {
152
- const slug = item.href.split("/").pop();
153
- const type = item.href.includes("/settings")
154
- ? "global"
155
- : "collection";
156
- return (
157
- <a
158
- href={item.href}
159
- data-nav-item
160
- data-slug={slug}
161
- data-type={type}
162
- class={`flex items-center gap-4 px-6 py-2 rounded-2xl transition-all font-medium text-[13px] ${
163
- item.icon === "collection"
164
- ? currentPath === item.href ||
165
- currentPath.startsWith(item.href + "/")
166
- ? "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-primary)]"
167
- : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)] hover:text-[var(--kyro-text-primary)]"
168
- : isActive(item)
169
- ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] shadow-lg"
170
- : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)] hover:text-[var(--kyro-text-primary)]"
171
- }`}
172
- >
173
- {(() => {
174
- const iconName = iconAliases[item.icon] || item.icon;
175
- const Icon = (Icons as any)[iconName] || Icons.Dot;
176
- return <Icon className="w-4 h-4" strokeWidth={2.5} />;
177
- })()}
178
- <span>{item.label}</span>
179
- </a>
180
- );
181
- })}
182
228
  </div>
183
229
  ))
184
230
  }
@@ -186,42 +232,84 @@ function isActive(item: NavItem): boolean {
186
232
  </nav>
187
233
 
188
234
  <script>
235
+ function initSidebar() {
236
+ const sections = document.querySelectorAll("[data-section]");
237
+ sections.forEach((section) => {
238
+ const label = section.getAttribute("data-section");
239
+ const toggleBtn = section.querySelector('.sidebar-section-toggle');
240
+ const itemsContainer = section.querySelector('.section-items');
241
+
242
+ const isCollapsed = localStorage.getItem(`kyro-sidebar-collapsed-${label}`) === 'true';
243
+ if (isCollapsed) {
244
+ section.setAttribute('data-collapsed', 'true');
245
+ if (itemsContainer) itemsContainer.classList.add('hidden');
246
+ }
247
+
248
+ const activeItem = section.querySelector('.active-nav-item');
249
+ if (activeItem && isCollapsed) {
250
+ section.removeAttribute('data-collapsed');
251
+ if (itemsContainer) itemsContainer.classList.remove('hidden');
252
+ localStorage.setItem(`kyro-sidebar-collapsed-${label}`, 'false');
253
+ }
254
+
255
+ // Remove old listeners to prevent duplicates
256
+ const newToggleBtn = toggleBtn?.cloneNode(true);
257
+ if (toggleBtn && newToggleBtn && toggleBtn.parentNode) {
258
+ toggleBtn.parentNode.replaceChild(newToggleBtn, toggleBtn);
259
+ newToggleBtn.addEventListener('click', () => {
260
+ const currentlyCollapsed = section.getAttribute('data-collapsed') === 'true';
261
+ if (currentlyCollapsed) {
262
+ section.removeAttribute('data-collapsed');
263
+ if (itemsContainer) itemsContainer.classList.remove('hidden');
264
+ localStorage.setItem(`kyro-sidebar-collapsed-${label}`, 'false');
265
+ } else {
266
+ section.setAttribute('data-collapsed', 'true');
267
+ if (itemsContainer) itemsContainer.classList.add('hidden');
268
+ localStorage.setItem(`kyro-sidebar-collapsed-${label}`, 'true');
269
+ }
270
+ });
271
+ }
272
+ });
273
+ }
274
+
275
+ // Run on load and view transitions
276
+ initSidebar();
277
+ document.addEventListener('astro:page-load', initSidebar);
278
+
189
279
  // Handle frontend RBAC pruning in the sidebar
190
- window.addEventListener("kyro:auth-ready", (event: any) => {
280
+ window.addEventListener("kyro:auth-ready", (event) => {
281
+ // @ts-ignore
191
282
  const { permissions } = event.detail;
192
283
  if (!permissions) return;
193
284
 
194
285
  const navItems = document.querySelectorAll("[data-nav-item]");
195
- navItems.forEach((item: any) => {
286
+ navItems.forEach((item) => {
196
287
  const slug = item.getAttribute("data-slug");
197
288
  const type = item.getAttribute("data-type");
198
289
 
199
290
  let hasAccess = true;
200
-
201
291
  if (type === "collection" && permissions.collections) {
202
292
  const colPerms = permissions.collections[slug];
203
- if (colPerms && colPerms.read === false) {
204
- hasAccess = false;
205
- }
293
+ if (colPerms && colPerms.read === false) hasAccess = false;
206
294
  } else if (type === "global" && permissions.globals) {
207
295
  const globalPerms = permissions.globals[slug];
208
- if (globalPerms && globalPerms.read === false) {
209
- hasAccess = false;
210
- }
296
+ if (globalPerms && globalPerms.read === false) hasAccess = false;
211
297
  }
212
298
 
213
299
  if (!hasAccess) {
300
+ // @ts-ignore
214
301
  item.style.display = "none";
215
302
  }
216
303
  });
217
304
 
218
- // Hide empty sections
305
+ // Hide empty sections after RBAC
219
306
  const sections = document.querySelectorAll("[data-section]");
220
- sections.forEach((section: any) => {
307
+ sections.forEach((section) => {
221
308
  const visibleItems = section.querySelectorAll(
222
- '[data-nav-item]:not([style*="display: none"])',
309
+ '[data-nav-item]:not([style*="display: none"])'
223
310
  );
224
311
  if (visibleItems.length === 0) {
312
+ // @ts-ignore
225
313
  section.style.display = "none";
226
314
  }
227
315
  });
@@ -238,14 +326,14 @@ function isActive(item: NavItem): boolean {
238
326
  <button
239
327
  id="theme-light-btn"
240
328
  class="p-2 rounded-lg transition-all active:scale-95"
241
- title="Light Mode"
329
+ title={i18next.t("sidebar.lightMode", { defaultValue: "Light Mode" })}
242
330
  >
243
331
  <Icons.Sun className="w-4 h-4" strokeWidth={2.5} />
244
332
  </button>
245
333
  <button
246
334
  id="theme-dark-btn"
247
335
  class="p-2 rounded-lg transition-all active:scale-95"
248
- title="Dark Mode"
336
+ title={i18next.t("sidebar.darkMode", { defaultValue: "Dark Mode" })}
249
337
  >
250
338
  <Icons.Moon className="w-4 h-4" strokeWidth={2.5} />
251
339
  </button>
@@ -15,6 +15,7 @@ import { useUIStore, toast } from "../lib/stores";
15
15
  import { Modal, ModalContent, ModalActions } from "./ui/Modal";
16
16
  import { Badge } from "./ui/Badge";
17
17
  import { PageHeader } from "./ui/PageHeader";
18
+ import { useTranslation } from "react-i18next";
18
19
 
19
20
  interface User {
20
21
  id: string;
@@ -29,6 +30,7 @@ interface User {
29
30
  }
30
31
 
31
32
  export function UserManagement() {
33
+ const { t } = useTranslation();
32
34
  const [users, setUsers] = useState<User[]>([]);
33
35
  const [loading, setLoading] = useState(true);
34
36
  const [searchQuery, setSearchQuery] = useState("");
@@ -135,7 +137,7 @@ export function UserManagement() {
135
137
  <div className="w-full space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-700 px-4 md:px-8 pb-12">
136
138
  {/* Header */}
137
139
  <PageHeader
138
- title="Identity & Access"
140
+ title={t("tooltips.identityAccess", { defaultValue: "Identity & Access" })}
139
141
  description="Manage the core administrative team and security permissions."
140
142
  icon={Users}
141
143
  action={{
@@ -155,7 +157,7 @@ export function UserManagement() {
155
157
  <Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-secondary)] opacity-40 group-focus-within:opacity-100 transition-opacity" />
156
158
  <input
157
159
  type="text"
158
- placeholder="Search by identity or email..."
160
+ placeholder={t("fields.searchByIdentityOr", { defaultValue: "Search by identity or email..." })}
159
161
  value={searchQuery}
160
162
  onChange={(e) => setSearchQuery(e.target.value)}
161
163
  className="w-full pl-10 pr-4 py-2.5 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-xl focus:outline-none focus:ring-2 focus:ring-[var(--kyro-sidebar-active)] transition-all text-xs font-bold"
@@ -241,7 +243,7 @@ export function UserManagement() {
241
243
  <button
242
244
  onClick={() => handleDelete(user)}
243
245
  className="p-1.5 rounded-lg border border-red-500/20 bg-red-500/10 text-red-500 hover:bg-red-500/20 transition-all"
244
- title="Delete User"
246
+ title={t("tooltips.deleteUser", { defaultValue: "Delete User" })}
245
247
  >
246
248
  <Trash2 className="w-3.5 h-3.5" />
247
249
  </button>
@@ -258,7 +260,7 @@ export function UserManagement() {
258
260
  <Modal
259
261
  open={showCreateModal}
260
262
  onClose={() => setShowCreateModal(false)}
261
- title="Create User"
263
+ title={t("tooltips.createUser", { defaultValue: "Create User" })}
262
264
  size="lg"
263
265
  >
264
266
  <ModalContent>
@@ -269,7 +271,7 @@ export function UserManagement() {
269
271
  type="text"
270
272
  value={createForm.name}
271
273
  onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
272
- placeholder="John Doe"
274
+ placeholder={t("fields.johnDoe", { defaultValue: "John Doe" })}
273
275
  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)]"
274
276
  />
275
277
  </div>
@@ -290,7 +292,7 @@ export function UserManagement() {
290
292
  type="password"
291
293
  value={createForm.password}
292
294
  onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
293
- placeholder="Minimum 12 characters"
295
+ placeholder={t("fields.minimum12Characters", { defaultValue: "Minimum 12 characters" })}
294
296
  required
295
297
  minLength={12}
296
298
  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)]"
@@ -3,12 +3,14 @@ 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 { useTranslation } from "react-i18next";
6
7
 
7
8
  interface UserMenuProps {
8
9
  adminPath: string;
9
10
  }
10
11
 
11
12
  export function UserMenu({ adminPath }: UserMenuProps) {
13
+ const { t } = useTranslation();
12
14
  const currentUser = useAuthStore((s) => s.user);
13
15
  const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
14
16
  const [apiAccess, setApiAccess] = useState<{ graphqlEnabled?: boolean } | null>(null);
@@ -41,7 +43,7 @@ export function UserMenu({ adminPath }: UserMenuProps) {
41
43
  trigger={
42
44
  <div
43
45
  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"
44
- title="Account"
46
+ title={t("userMenu.account", { defaultValue: "Account" })}
45
47
  >
46
48
  {avatarUrl ? (
47
49
  <img src={avatarUrl} alt="" className="w-8 h-8 rounded-full object-cover" />
@@ -53,7 +55,7 @@ export function UserMenu({ adminPath }: UserMenuProps) {
53
55
  >
54
56
  <div className="px-4 py-2 mb-1">
55
57
  <p className="text-[10px] font-medium tracking-[0.2em] text-[var(--kyro-text-secondary)] opacity-40">
56
- Account
58
+ {t("userMenu.account", { defaultValue: "Account" })}
57
59
  </p>
58
60
  </div>
59
61
 
@@ -68,21 +70,21 @@ export function UserMenu({ adminPath }: UserMenuProps) {
68
70
  }
69
71
  }}
70
72
  >
71
- Profile Settings
73
+ {t("userMenu.profileSettings", { defaultValue: "Profile Settings" })}
72
74
  </DropdownItem>
73
75
 
74
76
  <DropdownItem
75
77
  icon={<Shield className="w-4 h-4" />}
76
78
  onClick={() => window.location.href = `${adminPath}/roles`}
77
79
  >
78
- Permissions
80
+ {t("userMenu.permissions", { defaultValue: "Permissions" })}
79
81
  </DropdownItem>
80
82
 
81
83
  <DropdownSeparator />
82
84
 
83
85
  <div className="px-4 py-2 mb-1">
84
86
  <p className="text-[10px] font-medium tracking-[0.2em] text-[var(--kyro-text-secondary)] opacity-40">
85
- Developer
87
+ {t("userMenu.developer", { defaultValue: "Developer" })}
86
88
  </p>
87
89
  </div>
88
90
 
@@ -90,33 +92,33 @@ export function UserMenu({ adminPath }: UserMenuProps) {
90
92
  icon={<Key className="w-4 h-4" />}
91
93
  onClick={() => (window.location.href = `${adminPath}/keys`)}
92
94
  >
93
- API Keys
95
+ {t("userMenu.apiKeys", { defaultValue: "API Keys" })}
94
96
  </DropdownItem>
95
97
 
96
98
  <DropdownItem
97
99
  icon={<Webhook className="w-4 h-4" />}
98
100
  onClick={() => (window.location.href = `${adminPath}/webhooks`)}
99
101
  >
100
- Web Hooks
102
+ {t("userMenu.webHooks", { defaultValue: "Web Hooks" })}
101
103
  </DropdownItem>
102
104
  <DropdownItem
103
105
  icon={<Clock className="w-4 h-4" />}
104
106
  onClick={() => (window.location.href = `${adminPath}/sessions`)}
105
107
  >
106
- Sessions
108
+ {t("userMenu.sessions", { defaultValue: "Sessions" })}
107
109
  </DropdownItem>
108
110
  <DropdownItem
109
111
  icon={<FileText className="w-4 h-4" />}
110
112
  onClick={() => (window.location.href = `${adminPath}/audit`)}
111
113
  >
112
- Audit Logs
114
+ {t("userMenu.auditLogs", { defaultValue: "Audit Logs" })}
113
115
  </DropdownItem>
114
116
 
115
117
  <DropdownItem
116
118
  icon={<Terminal className="w-4 h-4" />}
117
119
  onClick={() => (window.location.href = `${adminPath}/rest-playground`)}
118
120
  >
119
- API Explorer
121
+ {t("userMenu.apiExplorer", { defaultValue: "API Explorer" })}
120
122
  </DropdownItem>
121
123
 
122
124
  {(apiAccess === null || apiAccess?.graphqlEnabled) && (
@@ -124,7 +126,7 @@ export function UserMenu({ adminPath }: UserMenuProps) {
124
126
  icon={<Zap className="w-4 h-4" />}
125
127
  onClick={() => (window.location.href = `${adminPath}/graphql`)}
126
128
  >
127
- GraphQL Playground
129
+ {t("userMenu.graphqlPlayground", { defaultValue: "GraphQL Playground" })}
128
130
  </DropdownItem>
129
131
  )}
130
132
 
@@ -132,22 +134,22 @@ export function UserMenu({ adminPath }: UserMenuProps) {
132
134
 
133
135
  <div className="px-4 py-2 mb-1">
134
136
  <p className="text-[10px] font-medium tracking-[0.2em] text-[var(--kyro-text-secondary)] opacity-40">
135
- Resources
137
+ {t("userMenu.resources", { defaultValue: "Resources" })}
136
138
  </p>
137
139
  </div>
138
140
 
139
141
  <DropdownItem
140
142
  icon={<ExternalLink className="w-4 h-4" />}
141
- onClick={() => window.open("https://docs.kyro.dev", "_blank")}
143
+ onClick={() => window.open("https://kyro-cms.com/getting-started.html", "_blank")}
142
144
  >
143
- Documentation
145
+ {t("userMenu.documentation", { defaultValue: "Documentation" })}
144
146
  </DropdownItem>
145
147
 
146
148
  <DropdownItem
147
149
  icon={<HelpCircle className="w-4 h-4" />}
148
- onClick={() => window.open("https://github.com/danielDozie/kyro-cms/issues", "_blank")}
150
+ onClick={() => window.open("https://github.com/danielDozie/kyro-cms/issues/new", "_blank")}
149
151
  >
150
- Get Support
152
+ {t("userMenu.getSupport", { defaultValue: "Get Support" })}
151
153
  </DropdownItem>
152
154
 
153
155
  <DropdownSeparator />
@@ -157,7 +159,7 @@ export function UserMenu({ adminPath }: UserMenuProps) {
157
159
  danger
158
160
  onClick={() => document.getElementById("logout-btn")?.click()}
159
161
  >
160
- Sign Out
162
+ {t("actions.signOut", { defaultValue: "Sign Out" })}
161
163
  </DropdownItem>
162
164
  </Dropdown>
163
165
  );
@@ -2,6 +2,7 @@ import React from "react";
2
2
  import { SlidePanel } from "./ui/SlidePanel";
3
3
  import { Spinner } from "./ui/Spinner";
4
4
  import { History, Eye, GitCompare, Undo2, CheckCircle2, Clock, User } from "lucide-react";
5
+ import { useTranslation } from "react-i18next";
5
6
 
6
7
  interface Version {
7
8
  id: string;
@@ -38,6 +39,7 @@ export function VersionHistoryPanel({
38
39
  onCompare,
39
40
  loading = false,
40
41
  }: VersionHistoryPanelProps) {
42
+ const { t } = useTranslation();
41
43
  const formatDate = (dateValue: any) => {
42
44
  if (!dateValue) return "Unknown date";
43
45
  const date = new Date(dateValue);
@@ -82,7 +84,7 @@ export function VersionHistoryPanel({
82
84
  <SlidePanel
83
85
  open={open}
84
86
  onClose={onClose}
85
- title="Version History"
87
+ title={t("tooltips.versionHistory", { defaultValue: "Version History" })}
86
88
  width="md"
87
89
  >
88
90
  {loading ? (
@@ -159,7 +161,7 @@ export function VersionHistoryPanel({
159
161
  <button type="button"
160
162
  onClick={() => onPreview(version)}
161
163
  className="p-2 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-primary)] hover:bg-[var(--kyro-primary)]/10 rounded-md transition-colors"
162
- title="Preview this version"
164
+ title={t("tooltips.previewThisVersion", { defaultValue: "Preview this version" })}
163
165
  >
164
166
  <Eye className="w-4 h-4" />
165
167
  </button>
@@ -172,7 +174,7 @@ export function VersionHistoryPanel({
172
174
  )
173
175
  }
174
176
  className="p-2 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-primary)] hover:bg-[var(--kyro-primary)]/10 rounded-md transition-colors"
175
- title="Compare with current"
177
+ title={t("tooltips.compareWithCurrent", { defaultValue: "Compare with current" })}
176
178
  >
177
179
  <GitCompare className="w-4 h-4" />
178
180
  </button>
@@ -181,7 +183,7 @@ export function VersionHistoryPanel({
181
183
  <button type="button"
182
184
  onClick={() => onRestore(version)}
183
185
  className="p-2 text-amber-600 hover:bg-amber-500/10 rounded-md transition-colors"
184
- title="Restore this version"
186
+ title={t("tooltips.restoreThisVersion", { defaultValue: "Restore this version" })}
185
187
  >
186
188
  <Undo2 className="w-4 h-4" />
187
189
  </button>