@kyro-cms/admin 0.12.4 → 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 (109) 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 +9 -3
  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 +41 -38
  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 +11 -10
  29. package/src/components/autoform/AutoFormApiView.tsx +3 -1
  30. package/src/components/autoform/AutoFormEditView.tsx +3 -1
  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 -19
  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/deep-equal.ts +3 -3
  82. package/src/lib/i18n.ts +44 -0
  83. package/src/lib/vite-shim-plugin.ts +3 -0
  84. package/src/locales/de.json +163 -0
  85. package/src/locales/en.json +163 -0
  86. package/src/locales/es.json +163 -0
  87. package/src/locales/fr.json +163 -0
  88. package/src/locales/pt.json +163 -0
  89. package/src/pages/403.astro +2 -1
  90. package/src/pages/[collection]/[id].astro +23 -10
  91. package/src/pages/[collection]/index.astro +48 -34
  92. package/src/pages/audit/index.astro +2 -1
  93. package/src/pages/graphql-explorer.astro +2 -1
  94. package/src/pages/graphql.astro +2 -1
  95. package/src/pages/health.astro +9 -0
  96. package/src/pages/index.astro +6 -5
  97. package/src/pages/keys.astro +2 -1
  98. package/src/pages/marketplace.astro +2 -1
  99. package/src/pages/media.astro +2 -1
  100. package/src/pages/plugins.astro +2 -1
  101. package/src/pages/rest-playground.astro +2 -1
  102. package/src/pages/roles/index.astro +16 -52
  103. package/src/pages/sessions.astro +2 -1
  104. package/src/pages/settings/[slug].astro +45 -30
  105. package/src/pages/users/[id].astro +1 -0
  106. package/src/pages/users/index.astro +2 -1
  107. package/src/pages/users/new.astro +2 -1
  108. package/src/pages/webhooks.astro +2 -1
  109. package/src/pages/index_ALT.astro +0 -3
@@ -14,6 +14,7 @@ import {
14
14
  import { Modal, ModalContent, ModalActions } from "./ui/Modal";
15
15
  import { PageHeader } from "./ui/PageHeader";
16
16
  import { Badge } from "./ui/Badge";
17
+ import { useTranslation } from "react-i18next";
17
18
 
18
19
  interface Plugin {
19
20
  id: string;
@@ -31,6 +32,7 @@ interface ToggleError {
31
32
  }
32
33
 
33
34
  export function PluginsManager() {
35
+ const { t } = useTranslation();
34
36
  const [plugins, setPlugins] = useState<Plugin[]>([]);
35
37
  const [loading, setLoading] = useState(true);
36
38
  const [searchQuery, setSearchQuery] = useState("");
@@ -71,7 +73,7 @@ export function PluginsManager() {
71
73
  return (
72
74
  <div className="w-full space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700 pb-32">
73
75
  <PageHeader
74
- title="Plugins"
76
+ title={t("tooltips.plugins", { defaultValue: "Plugins" })}
75
77
  description="Extend Kyro CMS with modular features and integrations."
76
78
  icon={Blocks}
77
79
  />
@@ -85,7 +87,7 @@ export function PluginsManager() {
85
87
  return (
86
88
  <div className="w-full space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700 pb-32">
87
89
  <PageHeader
88
- title="Plugins"
90
+ title={t("tooltips.plugins", { defaultValue: "Plugins" })}
89
91
  description="Extend Kyro CMS with modular features and integrations."
90
92
  icon={Blocks}
91
93
  actions={[
@@ -164,7 +166,7 @@ export function PluginsManager() {
164
166
  <Search className="w-4 h-4 absolute left-3.5 top-1/2 -translate-y-1/2 text-[var(--kyro-text-secondary)] opacity-40" />
165
167
  <input
166
168
  type="text"
167
- placeholder="Search plugins..."
169
+ placeholder={t("fields.searchPlugins", { defaultValue: "Search plugins..." })}
168
170
  value={searchQuery}
169
171
  onChange={(e) => setSearchQuery(e.target.value)}
170
172
  className="w-full pl-10 pr-4 py-3 bg-[var(--kyro-bg-secondary)] border border-[var(--kyro-border)] rounded-xl text-xs focus:outline-none focus:border-[var(--kyro-primary)]/50 transition-all"
@@ -247,7 +249,7 @@ export function PluginsManager() {
247
249
  <Modal
248
250
  open={!!showConfigModal}
249
251
  onClose={() => setShowConfigModal(null)}
250
- title="Plugin Configuration"
252
+ title={t("tooltips.pluginConfiguration", { defaultValue: "Plugin Configuration" })}
251
253
  size="lg"
252
254
  >
253
255
  <ModalContent>
@@ -11,6 +11,7 @@ import {
11
11
  X,
12
12
  ChevronRight,
13
13
  } from "./ui/icons";
14
+ import { useTranslation } from "react-i18next";
14
15
 
15
16
  interface EnvVariable {
16
17
  key: string;
@@ -78,6 +79,7 @@ const METHOD_BG: Record<string, string> = {
78
79
  };
79
80
 
80
81
  export function RestPlayground({ collections = [] }: RestPlaygroundProps) {
82
+ const { t } = useTranslation();
81
83
  const [sidebarTab, setSidebarTab] = useState<"collections" | "saved" | "history" | "env">("collections");
82
84
  const [folders, setFolders] = useState<RequestFolder[]>([]);
83
85
  const [history, setHistory] = useState<HistoryItem[]>([]);
@@ -357,7 +359,7 @@ export function RestPlayground({ collections = [] }: RestPlaygroundProps) {
357
359
  <button
358
360
  onClick={() => setShowSidebar(!showSidebar)}
359
361
  className="p-1 rounded-lg text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]"
360
- title="Toggle sidebar"
362
+ title={t("tooltips.toggleSidebar", { defaultValue: "Toggle sidebar" })}
361
363
  >
362
364
  <ChevronRight className={`w-4 h-4 transition-transform ${showSidebar ? "rotate-180" : ""}`} />
363
365
  </button>
@@ -694,10 +696,10 @@ export function RestPlayground({ collections = [] }: RestPlaygroundProps) {
694
696
  </>
695
697
  )}
696
698
  <div className="ml-auto flex items-center gap-1">
697
- <button onClick={handleCopyResponse} className="p-1 rounded text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]" title="Copy response">
699
+ <button onClick={handleCopyResponse} className="p-1 rounded text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]" title={t("tooltips.copyResponse", { defaultValue: "Copy response" })}>
698
700
  {copied ? <Check className="w-3 h-3 text-[var(--kyro-success)]" /> : <Copy className="w-3 h-3" />}
699
701
  </button>
700
- <button onClick={handleDownloadResponse} className="p-1 rounded text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]" title="Download response">
702
+ <button onClick={handleDownloadResponse} className="p-1 rounded text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]" title={t("tooltips.downloadResponse", { defaultValue: "Download response" })}>
701
703
  <Download className="w-3 h-3" />
702
704
  </button>
703
705
  </div>
@@ -727,7 +729,7 @@ export function RestPlayground({ collections = [] }: RestPlaygroundProps) {
727
729
  </div>
728
730
 
729
731
  {/* Modals */}
730
- <Modal open={showFolderModal} onClose={() => setShowFolderModal(false)} title="Create Folder" size="sm"
732
+ <Modal open={showFolderModal} onClose={() => setShowFolderModal(false)} title={t("tooltips.createFolder", { defaultValue: "Create Folder" })} size="sm"
731
733
  footer={
732
734
  <div className="flex gap-2">
733
735
  <button onClick={() => setShowFolderModal(false)} className="kyro-btn kyro-btn-md kyro-btn-ghost">Cancel</button>
@@ -736,11 +738,11 @@ export function RestPlayground({ collections = [] }: RestPlaygroundProps) {
736
738
  }
737
739
  >
738
740
  <input type="text" value={newFolderName} onChange={(e) => setNewFolderName(e.target.value)}
739
- placeholder="Folder name..." className="w-full bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg px-3 py-2 text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:border-[var(--kyro-primary)]"
741
+ placeholder={t("fields.folderName", { defaultValue: "Folder name..." })} className="w-full bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg px-3 py-2 text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:border-[var(--kyro-primary)]"
740
742
  />
741
743
  </Modal>
742
744
 
743
- <Modal open={showSaveModal} onClose={() => setShowSaveModal(false)} title="Save Request" size="sm"
745
+ <Modal open={showSaveModal} onClose={() => setShowSaveModal(false)} title={t("tooltips.saveRequest", { defaultValue: "Save Request" })} size="sm"
744
746
  footer={
745
747
  <div className="flex gap-2">
746
748
  <button onClick={() => setShowSaveModal(false)} className="kyro-btn kyro-btn-md kyro-btn-ghost">Cancel</button>
@@ -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)]"