@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
@@ -197,6 +197,7 @@ import { javascript } from "@codemirror/lang-javascript";
197
197
  import { json } from "@codemirror/lang-json";
198
198
  import { CompletionContext, autocompletion } from "@codemirror/autocomplete";
199
199
  import { aura } from "@uiw/codemirror-theme-aura";
200
+ import { useTranslation } from "react-i18next";
200
201
 
201
202
  function prettifyQuery(query: string): string {
202
203
  let indent = 0;
@@ -421,6 +422,7 @@ export function GraphQLPlayground({
421
422
  initialVariables,
422
423
  initialShowDocs = false,
423
424
  }: GraphQLPlaygroundProps) {
425
+ const { t } = useTranslation();
424
426
  const [token, setToken] = useState<string>("");
425
427
  const [showToken, setShowToken] = useState(false);
426
428
  const [isConnected, setIsConnected] = useState(false);
@@ -837,7 +839,7 @@ export function GraphQLPlayground({
837
839
  type={showToken ? "text" : "password"}
838
840
  value={token}
839
841
  onChange={(e) => setToken(e.target.value)}
840
- placeholder="API key"
842
+ placeholder={t("fields.apiKey", { defaultValue: "API key" })}
841
843
  className="w-24 px-2 py-1 text-[10px] font-mono bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-md text-[var(--kyro-text-primary)] placeholder:text-[var(--kyro-text-muted)] focus:outline-none focus:border-[var(--kyro-primary)]"
842
844
  />
843
845
  <button onClick={() => setShowToken(!showToken)} className="p-1 text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)]">
@@ -846,22 +848,22 @@ export function GraphQLPlayground({
846
848
  </div>
847
849
  )}
848
850
  <div className="ml-auto flex items-center gap-1 flex-wrap justify-end">
849
- <button onClick={() => { setShowDocs(!showDocs); setRightTab("docs"); }} className={`p-1.5 rounded-lg transition-all ${rightTab === "docs" && showDocs ? "bg-[var(--kyro-primary)] text-[var(--kyro-sidebar-text-active)]" : "text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]"}`} title="Schema docs">
851
+ <button onClick={() => { setShowDocs(!showDocs); setRightTab("docs"); }} className={`p-1.5 rounded-lg transition-all ${rightTab === "docs" && showDocs ? "bg-[var(--kyro-primary)] text-[var(--kyro-sidebar-text-active)]" : "text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]"}`} title={t("tooltips.schemaDocs", { defaultValue: "Schema docs" })}>
850
852
  <Book className="w-3.5 h-3.5" />
851
853
  </button>
852
- <button onClick={() => setRightTab("history")} className={`p-1.5 rounded-lg transition-all ${rightTab === "history" ? "bg-[var(--kyro-primary)] text-[var(--kyro-sidebar-text-active)]" : "text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]"}`} title="History">
854
+ <button onClick={() => setRightTab("history")} className={`p-1.5 rounded-lg transition-all ${rightTab === "history" ? "bg-[var(--kyro-primary)] text-[var(--kyro-sidebar-text-active)]" : "text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]"}`} title={t("tooltips.history", { defaultValue: "History" })}>
853
855
  <Clock className="w-3.5 h-3.5" />
854
856
  </button>
855
- <button onClick={handlePrettify} className="p-1.5 rounded-lg text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]" title="Prettify (Cmd+Shift+P)">
857
+ <button onClick={handlePrettify} className="p-1.5 rounded-lg text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]" title={t("tooltips.prettifyCmdshiftp", { defaultValue: "Prettify (Cmd+Shift+P)" })}>
856
858
  <Code2 className="w-3.5 h-3.5" />
857
859
  </button>
858
- <button onClick={handleCopyResponse} className="p-1.5 rounded-lg text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]" title="Copy response">
860
+ <button onClick={handleCopyResponse} className="p-1.5 rounded-lg text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]" title={t("tooltips.copyResponse", { defaultValue: "Copy response" })}>
859
861
  {copied ? <Check className="w-3.5 h-3.5 text-[var(--kyro-success)]" /> : <Copy className="w-3.5 h-3.5" />}
860
862
  </button>
861
- <button onClick={handleDownloadResponse} className="p-1.5 rounded-lg text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]" title="Download response">
863
+ <button onClick={handleDownloadResponse} className="p-1.5 rounded-lg text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]" title={t("tooltips.downloadResponse", { defaultValue: "Download response" })}>
862
864
  <Download className="w-3.5 h-3.5" />
863
865
  </button>
864
- <button onClick={handleClearEditor} className="p-1.5 rounded-lg text-[var(--kyro-text-muted)] hover:text-[var(--kyro-danger)] hover:bg-[var(--kyro-danger-bg)]" title="Clear editor">
866
+ <button onClick={handleClearEditor} className="p-1.5 rounded-lg text-[var(--kyro-text-muted)] hover:text-[var(--kyro-danger)] hover:bg-[var(--kyro-danger-bg)]" title={t("tooltips.clearEditor", { defaultValue: "Clear editor" })}>
865
867
  <Trash2 className="w-3.5 h-3.5" />
866
868
  </button>
867
869
  <div className="h-4 w-px bg-[var(--kyro-border)] mx-1" />
@@ -977,7 +979,7 @@ export function GraphQLPlayground({
977
979
  type="text"
978
980
  value={searchQuery}
979
981
  onChange={(e) => setSearchQuery(e.target.value)}
980
- placeholder="Search types and fields..."
982
+ placeholder={t("fields.searchTypesAndFields", { defaultValue: "Search types and fields..." })}
981
983
  className="w-full pl-7 pr-6 py-1 text-[10px] bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-md text-[var(--kyro-text-primary)] placeholder:text-[var(--kyro-text-muted)] focus:outline-none focus:border-[var(--kyro-primary)]"
982
984
  />
983
985
  {searchQuery && (
@@ -11,7 +11,8 @@ import { adminPath as ADMIN_BASE } from "../lib/paths";
11
11
  import { PageHeader } from "./ui/PageHeader";
12
12
  import { Badge } from "./ui/Badge";
13
13
  import { Pagination } from "./ui/Pagination";
14
-
14
+ import { useTranslation } from "react-i18next";
15
+ import "../lib/i18n";
15
16
 
16
17
  import type { CollectionConfig, Field } from "@kyro-cms/core";
17
18
  import { resolveFieldValue } from "../lib/resolve-field-value";
@@ -60,6 +61,7 @@ export function ListView({
60
61
  onEdit: providedOnEdit,
61
62
  config,
62
63
  }: ListViewProps) {
64
+ const { t } = useTranslation();
63
65
  const collectionSlug = providedSlug || collection.slug;
64
66
  const { permissions } = useAuthStore();
65
67
  const canCreate = permissions?.collections?.[collectionSlug]?.create !== false;
@@ -71,7 +73,13 @@ export function ListView({
71
73
  if (providedOnCreate) {
72
74
  providedOnCreate();
73
75
  } else {
74
- window.location.href = `${ADMIN_BASE}/${collectionSlug}/new`;
76
+ const href = `${ADMIN_BASE}/${collectionSlug}/new`;
77
+ // @ts-ignore
78
+ import("astro:transitions/client").then(({ navigate }) => {
79
+ navigate(href);
80
+ }).catch(() => {
81
+ window.location.href = href;
82
+ });
75
83
  }
76
84
  };
77
85
 
@@ -79,7 +87,13 @@ export function ListView({
79
87
  if (providedOnEdit) {
80
88
  providedOnEdit(id);
81
89
  } else {
82
- window.location.href = `${ADMIN_BASE}/${collectionSlug}/${id}`;
90
+ const href = `${ADMIN_BASE}/${collectionSlug}/${id}`;
91
+ // @ts-ignore
92
+ import("astro:transitions/client").then(({ navigate }) => {
93
+ navigate(href);
94
+ }).catch(() => {
95
+ window.location.href = href;
96
+ });
83
97
  }
84
98
  };
85
99
 
@@ -201,17 +215,24 @@ export function ListView({
201
215
 
202
216
  const displayFields = useMemo(
203
217
  () => {
204
- const fields = allFields.filter((f): f is typeof f & { name: string } => !!f.name && visibleColumns.has(f.name));
205
- if (visibleColumns.has("status")) {
206
- fields.push({
207
- name: "status",
208
- type: "select",
209
- label: "Status",
210
- options: [
211
- { value: "draft", label: "Draft" },
212
- { value: "published", label: "Published" },
213
- ],
214
- } as any);
218
+ const fields: (FieldConfig & { name: string })[] = [];
219
+ for (const colName of Array.from(visibleColumns)) {
220
+ if (colName === "status") {
221
+ fields.push({
222
+ name: "status",
223
+ type: "select",
224
+ label: "Status",
225
+ options: [
226
+ { value: "draft", label: "Draft" },
227
+ { value: "published", label: "Published" },
228
+ ],
229
+ } as any);
230
+ } else {
231
+ const field = allFields.find((f) => f.name === colName);
232
+ if (field && field.name) {
233
+ fields.push(field as typeof field & { name: string });
234
+ }
235
+ }
215
236
  }
216
237
  return fields;
217
238
  },
@@ -297,8 +318,8 @@ export function ListView({
297
318
 
298
319
  const handleBulkDelete = () => {
299
320
  confirm({
300
- title: "Delete Documents",
301
- message: `Are you sure you want to delete ${selectedIds.size} document(s)? This cannot be undone.`,
321
+ title: t("listView.deleteBulkTitle", { defaultValue: "Delete Documents" }),
322
+ message: t("listView.deleteBulkMessage", { defaultValue: "Are you sure you want to delete {{count}} document(s)? This cannot be undone.", count: selectedIds.size }),
302
323
  variant: "danger",
303
324
  onConfirm: async () => {
304
325
  try {
@@ -318,8 +339,8 @@ export function ListView({
318
339
 
319
340
  const handleDeleteSingle = (id: string) => {
320
341
  confirm({
321
- title: "Delete Document",
322
- message: "Are you sure you want to delete this document? This cannot be undone.",
342
+ title: t("listView.deleteTitle", { defaultValue: "Delete Document" }),
343
+ message: t("listView.deleteMessage", { defaultValue: "Are you sure you want to delete this document? This cannot be undone." }),
323
344
  variant: "danger",
324
345
  onConfirm: async () => {
325
346
  try {
@@ -348,8 +369,9 @@ export function ListView({
348
369
  </span>
349
370
  ] : undefined}
350
371
  action={canCreate ? {
351
- label: `Create ${collection.singularLabel || collection.label || collectionSlug}`,
352
- onClick: handleCreate,
372
+ label: t("actions.create", { defaultValue: "Create {{item}}", item: collection.singularLabel || collection.label || collectionSlug }),
373
+ href: providedOnCreate ? undefined : `${ADMIN_BASE}/${collectionSlug}/new`,
374
+ onClick: providedOnCreate ? handleCreate : undefined,
353
375
  icon: Plus,
354
376
  } : undefined}
355
377
 
@@ -363,7 +385,7 @@ export function ListView({
363
385
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-muted)]" />
364
386
  <input
365
387
  type="text"
366
- placeholder="Search..."
388
+ placeholder={t("listView.searchPlaceholder", { defaultValue: "Search..." })}
367
389
  value={search}
368
390
  onChange={(e) => setSearch(e.target.value)}
369
391
  className="w-full pl-10 pr-4 py-2.5 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-xl text-sm font-medium text-[var(--kyro-text-primary)] placeholder:text-[var(--kyro-text-muted)] focus:outline-none focus:ring-2 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
@@ -381,7 +403,7 @@ export function ListView({
381
403
  }`}
382
404
  >
383
405
  <Filter className="w-4 h-4" />
384
- Filters
406
+ {t("listView.filters", { defaultValue: "Filters" })}
385
407
  {filters.length > 0 && (
386
408
  <span className="ml-1 px-1.5 py-0.5 bg-[var(--kyro-sidebar-text-active)] text-[var(--kyro-sidebar-active)] rounded-full text-xs">
387
409
  {filters.length}
@@ -397,13 +419,13 @@ export function ListView({
397
419
  className="flex items-center gap-2 px-4 py-2 rounded-xl font-bold text-sm bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-all"
398
420
  >
399
421
  <Columns3 className="w-4 h-4" />
400
- Columns
422
+ {t("listView.columns", { defaultValue: "Columns" })}
401
423
  </button>
402
424
  {showColumns && (
403
425
  <div className="absolute right-0 top-full mt-2 w-56 surface-tile border border-[var(--kyro-border)] rounded-lg shadow-xl z-50 overflow-hidden">
404
426
  <div className="p-3 border-b border-[var(--kyro-border)]">
405
427
  <span className="text-xs font-bold tracking-wider text-[var(--kyro-text-secondary)]">
406
- Toggle Columns
428
+ {t("listView.toggleColumns", { defaultValue: "Toggle Columns" })}
407
429
  </span>
408
430
  </div>
409
431
  <div className="p-2 max-h-64 overflow-y-auto">
@@ -438,7 +460,7 @@ export function ListView({
438
460
  onClick={clearAll}
439
461
  className="px-4 py-2 rounded-xl font-bold text-sm text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 transition-all"
440
462
  >
441
- Clear All
463
+ {t("listView.clearAll", { defaultValue: "Clear All" })}
442
464
  </button>
443
465
  )}
444
466
  </div>
@@ -449,7 +471,7 @@ export function ListView({
449
471
  <div className="surface-tile p-4 border-l-4 border-[var(--kyro-sidebar-active)]">
450
472
  <div className="flex items-center justify-between mb-4">
451
473
  <h3 className="font-medium text-[var(--kyro-text-primary)]">
452
- Advanced Filters
474
+ {t("listView.advancedFilters", { defaultValue: "Advanced Filters" })}
453
475
  </h3>
454
476
  <button
455
477
  type="button"
@@ -457,7 +479,7 @@ export function ListView({
457
479
  className="flex items-center gap-2 px-3 py-1.5 text-sm font-bold text-[var(--kyro-sidebar-active)] hover:bg-[var(--kyro-surface-accent)] rounded-lg transition-all"
458
480
  >
459
481
  <Plus className="w-4 h-4" />
460
- Add Filter
482
+ {t("listView.addFilter", { defaultValue: "Add Filter" })}
461
483
  </button>
462
484
  </div>
463
485
  <div className="space-y-3">
@@ -498,7 +520,7 @@ export function ListView({
498
520
  onChange={(e) =>
499
521
  updateFilter(index, { value: e.target.value })
500
522
  }
501
- placeholder="Value..."
523
+ placeholder={t("fields.value", { defaultValue: "Value..." })}
502
524
  className="flex-1 min-w-[150px] px-3 py-2 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-lg text-sm font-medium text-[var(--kyro-text-primary)]"
503
525
  />
504
526
  <button
@@ -512,7 +534,7 @@ export function ListView({
512
534
  ))}
513
535
  {filters.length === 0 && (
514
536
  <p className="text-sm text-[var(--kyro-text-muted)]">
515
- No filters applied. Click "Add Filter" to create one.
537
+ {t("listView.noFilters", { defaultValue: 'No filters applied. Click "Add Filter" to create one.' })}
516
538
  </p>
517
539
  )}
518
540
  </div>
@@ -523,7 +545,7 @@ export function ListView({
523
545
  {selectedIds.size > 0 && (
524
546
  <div className="surface-tile p-4 flex items-center justify-between border-l-4 border-[var(--kyro-sidebar-active)]">
525
547
  <span className="text-sm font-medium text-[var(--kyro-text-primary)]">
526
- {selectedIds.size} selected
548
+ {t("listView.selectedCount", { defaultValue: "{{count}} selected", count: selectedIds.size })}
527
549
  </span>
528
550
  <div className="flex gap-2">
529
551
  {canDelete && (
@@ -533,7 +555,7 @@ export function ListView({
533
555
  className="flex items-center gap-2 px-4 py-2 bg-red-500 text-white rounded-lg font-bold text-sm hover:bg-red-600 transition-all"
534
556
  >
535
557
  <Trash2 className="w-4 h-4" />
536
- Delete Selected
558
+ {t("listView.deleteSelected", { defaultValue: "Delete Selected" })}
537
559
  </button>
538
560
  )}
539
561
  <button
@@ -541,7 +563,7 @@ export function ListView({
541
563
  onClick={() => setSelectedIds(new Set())}
542
564
  className="px-4 py-2 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] font-bold text-sm transition-all"
543
565
  >
544
- Cancel
566
+ {t("actions.cancel", { defaultValue: "Cancel" })}
545
567
  </button>
546
568
  </div>
547
569
  </div>
@@ -559,12 +581,12 @@ export function ListView({
559
581
  <Archive className="w-4 h-4" />
560
582
  </div>
561
583
  <p className="font-medium text-[var(--kyro-text-primary)] text-base">
562
- No documents found
584
+ {t("listView.noDocuments", { defaultValue: "No documents found" })}
563
585
  </p>
564
586
  <p className="text-sm text-[var(--kyro-text-secondary)] mt-1">
565
587
  {hasActiveFilters
566
- ? "Try adjusting your filters or search query."
567
- : `Get started by creating your first ${((collection.singularLabel || collection.label || collectionSlug) as string).toLowerCase()}.`}
588
+ ? t("listView.tryAdjustingFilters", { defaultValue: "Try adjusting your filters or search query." })
589
+ : t("listView.getStartedCreate", { defaultValue: "Get started by creating your first {{item}}.", item: ((collection.singularLabel || collection.label || collectionSlug) as string).toLowerCase() })}
568
590
  </p>
569
591
  {!hasActiveFilters && canCreate && (
570
592
  <button
@@ -573,8 +595,7 @@ export function ListView({
573
595
  className="mt-4 kyro-btn kyro-btn-md kyro-btn-primary shadow-md flex items-center gap-2"
574
596
  >
575
597
  <Plus className="w-4 h-4" />
576
- Create{" "}
577
- {String(collection.singularLabel || collection.label || collectionSlug)}
598
+ {t("actions.create", { defaultValue: "Create {{item}}", item: String(collection.singularLabel || collection.label || collectionSlug) })}
578
599
  </button>
579
600
  )}
580
601
  </div>
@@ -609,12 +630,12 @@ export function ListView({
609
630
  </th>
610
631
  ))}
611
632
  {collection.timestamps ? (
612
- <th className="px-4 py-4">Created</th>
633
+ <th className="px-4 py-4">{t("listView.createdCol", { defaultValue: "Created" })}</th>
613
634
  ) : null}
614
635
  {collection.timestamps ? (
615
- <th className="px-4 py-4">Last Modified</th>
636
+ <th className="px-4 py-4">{t("listView.lastModifiedCol", { defaultValue: "Last Modified" })}</th>
616
637
  ) : null}
617
- <th className="px-4 py-4 text-right">Actions</th>
638
+ <th className="px-4 py-4 text-right">{t("listView.actionsCol", { defaultValue: "Actions" })}</th>
618
639
  </tr>
619
640
  </thead>
620
641
  <tbody className="divide-y divide-[var(--kyro-border)]">
@@ -641,7 +662,7 @@ export function ListView({
641
662
  field.type === "select" && rawValue && Array.isArray(field.options)
642
663
  ? field.options.find((o: any) => o.value === rawValue)
643
664
  ?.label || rawValue
644
- : formatCellValue(rawValue, field.type);
665
+ : formatCellValue(rawValue, field.type, t);
645
666
  return (
646
667
  <td
647
668
  key={field.name}
@@ -688,7 +709,7 @@ export function ListView({
688
709
  type="button"
689
710
  onClick={() => handleEdit(doc.id)}
690
711
  className="flex items-center gap-2 px-3 py-1.5 hover:bg-[var(--kyro-surface-accent)] rounded-lg text-sm font-bold text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-all"
691
- title={canUpdate ? "Edit" : "View"}
712
+ title={canUpdate ? t("actions.edit", { defaultValue: "Edit" }) : t("actions.view", { defaultValue: "View" })}
692
713
  >
693
714
  <Edit2 className="w-4 h-4" />
694
715
  </button>
@@ -697,7 +718,7 @@ export function ListView({
697
718
  type="button"
698
719
  onClick={() => handleDeleteSingle(doc.id)}
699
720
  className="inline-flex items-center justify-center w-8 h-8 rounded-md text-[var(--kyro-text-muted)] hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-500/10 transition-colors"
700
- title="Delete"
721
+ title={t("actions.delete", { defaultValue: "Delete" })}
701
722
  >
702
723
  <Trash2 className="w-4 h-4" />
703
724
  </button>
@@ -728,9 +749,9 @@ export function ListView({
728
749
  );
729
750
  }
730
751
 
731
- function formatCellValue(value: any, type?: string): string {
752
+ function formatCellValue(value: any, type?: string, t?: any): string {
732
753
  if (value === null || value === undefined) return "—";
733
- if (typeof value === "boolean") return value ? "Yes" : "No";
754
+ if (typeof value === "boolean") return value ? (t ? t("common.yes", { defaultValue: "Yes" }) : "Yes") : (t ? t("common.no", { defaultValue: "No" }) : "No");
734
755
  if (type === "number" || type === "price") return String(value);
735
756
  if (type === "date" || type === "datetime") {
736
757
  return new Date(value).toLocaleDateString("en-US", {
@@ -3,6 +3,7 @@ import { apiGet, apiPost } from "../lib/api";
3
3
  import { ThemeProvider, type ThemeMode } from "./ThemeProvider";
4
4
  import { Toaster } from "./ui/Toaster";
5
5
  import { useToastStore } from "../lib/stores";
6
+ import { useTranslation } from "react-i18next";
6
7
 
7
8
  interface LoginPageProps {
8
9
  onAuth: (token: string, user: Record<string, unknown>) => void;
@@ -12,6 +13,7 @@ interface LoginPageProps {
12
13
  type AuthMode = "login" | "register";
13
14
 
14
15
  export function LoginPage({ onAuth, theme = "light" }: LoginPageProps) {
16
+ const { t } = useTranslation();
15
17
  const [mode, setMode] = useState<AuthMode>("login");
16
18
  const [email, setEmail] = useState("");
17
19
  const [password, setPassword] = useState("");
@@ -54,11 +56,11 @@ export function LoginPage({ onAuth, theme = "light" }: LoginPageProps) {
54
56
  localStorage.setItem("kyro_user", JSON.stringify(data.user));
55
57
  addToast(
56
58
  "success",
57
- mode === "login" ? "Welcome back!" : "Account created!",
59
+ mode === "login" ? t("toast.welcomeBack", { defaultValue: "Welcome back!" }) : t("toast.accountCreated", { defaultValue: "Account created!" }),
58
60
  );
59
61
  onAuth(data.token, data.user);
60
62
  } catch {
61
- addToast("error", "Connection failed");
63
+ addToast("error", t("toast.connectionFailed", { defaultValue: "Connection failed" }));
62
64
  } finally {
63
65
  setLoading(false);
64
66
  }
@@ -71,23 +73,23 @@ export function LoginPage({ onAuth, theme = "light" }: LoginPageProps) {
71
73
  <div className="kyro-login-header">
72
74
  <h1 className="kyro-login-title">
73
75
  {isFirstUser
74
- ? "Create Admin Account"
76
+ ? t("login.createAdminAccount", { defaultValue: "Create Admin Account" })
75
77
  : mode === "login"
76
- ? "Sign In"
77
- : "Create Account"}
78
+ ? t("login.signIn", { defaultValue: "Sign In" })
79
+ : t("login.createAccount", { defaultValue: "Create Account" })}
78
80
  </h1>
79
81
  <p className="kyro-login-subtitle">
80
82
  {isFirstUser
81
- ? "Set up your admin account to get started"
83
+ ? t("login.setupAdmin", { defaultValue: "Set up your admin account to get started" })
82
84
  : mode === "login"
83
- ? "Enter your credentials to access the admin"
84
- : "Create an account to access the admin"}
85
+ ? t("login.enterCredentials", { defaultValue: "Enter your credentials to access the admin" })
86
+ : t("login.createAccountDesc", { defaultValue: "Create an account to access the admin" })}
85
87
  </p>
86
88
  </div>
87
89
 
88
90
  <form onSubmit={handleSubmit} className="kyro-login-form">
89
91
  <div className="kyro-form-group">
90
- <label htmlFor="email">Email</label>
92
+ <label htmlFor="email">{t("login.email", { defaultValue: "Email" })}</label>
91
93
  <input
92
94
  id="email"
93
95
  type="email"
@@ -100,7 +102,7 @@ export function LoginPage({ onAuth, theme = "light" }: LoginPageProps) {
100
102
  </div>
101
103
 
102
104
  <div className="kyro-form-group">
103
- <label htmlFor="password">Password</label>
105
+ <label htmlFor="password">{t("login.password", { defaultValue: "Password" })}</label>
104
106
  <input
105
107
  id="password"
106
108
  type="password"
@@ -117,7 +119,7 @@ export function LoginPage({ onAuth, theme = "light" }: LoginPageProps) {
117
119
 
118
120
  {mode === "register" && (
119
121
  <div className="kyro-form-group">
120
- <label htmlFor="confirmPassword">Confirm Password</label>
122
+ <label htmlFor="confirmPassword">{t("login.confirmPassword", { defaultValue: "Confirm Password" })}</label>
121
123
  <input
122
124
  id="confirmPassword"
123
125
  type="password"
@@ -138,11 +140,11 @@ export function LoginPage({ onAuth, theme = "light" }: LoginPageProps) {
138
140
  >
139
141
  {loading
140
142
  ? mode === "login"
141
- ? "Signing in..."
142
- : "Creating account..."
143
+ ? t("login.signingIn", { defaultValue: "Signing in..." })
144
+ : t("login.creatingAccount", { defaultValue: "Creating account..." })
143
145
  : mode === "login"
144
- ? "Sign In"
145
- : "Create Account"}
146
+ ? t("login.signIn", { defaultValue: "Sign In" })
147
+ : t("login.createAccount", { defaultValue: "Create Account" })}
146
148
  </button>
147
149
  </form>
148
150
 
@@ -151,24 +153,24 @@ export function LoginPage({ onAuth, theme = "light" }: LoginPageProps) {
151
153
  <p>
152
154
  {mode === "login" ? (
153
155
  <>
154
- Don't have an account?{" "}
156
+ {t("login.noAccount", { defaultValue: "Don't have an account?" })}{" "}
155
157
  <button
156
158
  type="button"
157
159
  className="kyro-login-link"
158
160
  onClick={() => setMode("register")}
159
161
  >
160
- Sign up
162
+ {t("login.signUp", { defaultValue: "Sign up" })}
161
163
  </button>
162
164
  </>
163
165
  ) : (
164
166
  <>
165
- Already have an account?{" "}
167
+ {t("login.hasAccount", { defaultValue: "Already have an account?" })}{" "}
166
168
  <button
167
169
  type="button"
168
170
  className="kyro-login-link"
169
171
  onClick={() => setMode("login")}
170
172
  >
171
- Sign in
173
+ {t("login.signInAction", { defaultValue: "Sign in" })}
172
174
  </button>
173
175
  </>
174
176
  )}
@@ -12,6 +12,7 @@ import {
12
12
  } from "./ui/icons";
13
13
  import { useUIStore, toast } from "../lib/stores";
14
14
  import { Badge } from "./ui/Badge";
15
+ import { useTranslation } from "react-i18next";
15
16
 
16
17
  interface Extension {
17
18
  id: string;
@@ -98,6 +99,7 @@ const mockExtensions: Extension[] = [
98
99
  ];
99
100
 
100
101
  export function MarketplaceManager() {
102
+ const { t } = useTranslation();
101
103
  const [extensions, setExtensions] = useState<Extension[]>(mockExtensions);
102
104
  const [searchQuery, setSearchQuery] = useState("");
103
105
  const [activeCategory, setActiveCategory] = useState("All");
@@ -152,7 +154,7 @@ export function MarketplaceManager() {
152
154
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--kyro-text-secondary)] opacity-40 group-focus-within:opacity-100 transition-opacity" />
153
155
  <input
154
156
  type="text"
155
- placeholder="Search marketplace..."
157
+ placeholder={t("fields.searchMarketplace", { defaultValue: "Search marketplace..." })}
156
158
  value={searchQuery}
157
159
  onChange={(e) => setSearchQuery(e.target.value)}
158
160
  className="pl-9 pr-4 py-2 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-xl text-xs font-bold w-64 focus:outline-none focus:ring-2 focus:ring-[var(--kyro-sidebar-active)] transition-all"
@@ -34,7 +34,9 @@ import {
34
34
  withCacheBust,
35
35
  apiUpload,
36
36
  } from "../lib/api";
37
+ import { adminPath } from "../lib/paths";
37
38
  import { useAuthStore, useUIStore, toast } from "../lib/stores";
39
+ import { useTranslation } from "react-i18next";
38
40
 
39
41
  interface MediaItem {
40
42
  id: string;
@@ -141,6 +143,7 @@ export function MediaGallery({
141
143
  multiple?: boolean;
142
144
  pickerMode?: boolean;
143
145
  }) {
146
+ const { t } = useTranslation();
144
147
  const { permissions } = useAuthStore();
145
148
  const canUpload = permissions?.media?.create !== false;
146
149
  const canDelete = permissions?.media?.delete !== false;
@@ -490,7 +493,7 @@ export function MediaGallery({
490
493
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-muted)]" />
491
494
  <input
492
495
  type="text"
493
- placeholder="Search assets..."
496
+ placeholder={t("fields.searchAssets", { defaultValue: "Search assets..." })}
494
497
  value={search}
495
498
  onChange={(e) => setSearch(e.target.value)}
496
499
  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"
@@ -968,7 +971,7 @@ export function MediaGallery({
968
971
  <SlidePanel
969
972
  open={!!panelItem}
970
973
  onClose={() => setPanelItem(null)}
971
- title="Asset Details"
974
+ title={t("tooltips.assetDetails", { defaultValue: "Asset Details" })}
972
975
  subtitle={panelItem?.filename}
973
976
  >
974
977
  {panelItem && (
@@ -1064,7 +1067,7 @@ export function MediaGallery({
1064
1067
  updateMetadata(panelItem.id, { alt: e.target.value })
1065
1068
  }
1066
1069
  className="w-full px-4 py-3 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg text-xs font-bold transition-all resize-none min-h-[80px] focus:outline-none focus:ring-2 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
1067
- placeholder="Describe this asset..."
1070
+ placeholder={t("fields.describeThisAsset", { defaultValue: "Describe this asset..." })}
1068
1071
  />
1069
1072
  </div>
1070
1073
  <div>
@@ -1079,7 +1082,7 @@ export function MediaGallery({
1079
1082
  })
1080
1083
  }
1081
1084
  className="w-full px-4 py-3 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg text-xs font-bold transition-all resize-none min-h-[80px] focus:outline-none focus:ring-2 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
1082
- placeholder="Add a caption..."
1085
+ placeholder={t("fields.addACaption", { defaultValue: "Add a caption..." })}
1083
1086
  />
1084
1087
  </div>
1085
1088
  </div>
@@ -1119,7 +1122,7 @@ export function MediaGallery({
1119
1122
  try {
1120
1123
  await apiDelete(`/api/media/${panelItem.id}`);
1121
1124
  setPanelItem(null);
1122
- loadMedia();
1125
+ loadMedia(1);
1123
1126
  } catch (error) {
1124
1127
  console.error("Delete failed:", error);
1125
1128
  toast.error("Failed to delete asset");
@@ -1213,15 +1216,15 @@ export function MediaGallery({
1213
1216
  open={showNewFolderModal}
1214
1217
  onClose={() => setShowNewFolderModal(false)}
1215
1218
  onSubmit={createFolder}
1216
- title="Create New Folder"
1217
- placeholder="Folder name"
1219
+ title={t("tooltips.createNewFolder", { defaultValue: "Create New Folder" })}
1220
+ placeholder={t("fields.folderName", { defaultValue: "Folder name" })}
1218
1221
  />
1219
1222
  )}
1220
1223
  {!pickerMode && (
1221
1224
  <Modal
1222
1225
  open={showStorageConfigModal}
1223
1226
  onClose={() => setShowStorageConfigModal(false)}
1224
- title="Storage Not Configured"
1227
+ title={t("tooltips.storageNotConfigured", { defaultValue: "Storage Not Configured" })}
1225
1228
  size="md"
1226
1229
  >
1227
1230
  <div className="text-center">
@@ -1235,7 +1238,7 @@ export function MediaGallery({
1235
1238
  </p>
1236
1239
  <div className="flex gap-3">
1237
1240
  <a
1238
- href="/settings/storage-settings"
1241
+ href={`${adminPath}/settings/storage-settings`}
1239
1242
  className="flex-1 px-4 py-3 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-xl font-bold text-center hover:opacity-90 transition-colors"
1240
1243
  >
1241
1244
  Configure Storage