@actuate-media/cms-admin 0.48.0 → 0.49.1

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 (72) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/AdminRoot.d.ts +10 -0
  3. package/dist/AdminRoot.d.ts.map +1 -1
  4. package/dist/AdminRoot.js +25 -43
  5. package/dist/AdminRoot.js.map +1 -1
  6. package/dist/LoginRoot.d.ts +54 -0
  7. package/dist/LoginRoot.d.ts.map +1 -0
  8. package/dist/LoginRoot.js +59 -0
  9. package/dist/LoginRoot.js.map +1 -0
  10. package/dist/__tests__/components/modal.render.test.d.ts +2 -0
  11. package/dist/__tests__/components/modal.render.test.d.ts.map +1 -0
  12. package/dist/__tests__/components/modal.render.test.js +37 -0
  13. package/dist/__tests__/components/modal.render.test.js.map +1 -0
  14. package/dist/__tests__/views/login-root.render.test.d.ts +2 -0
  15. package/dist/__tests__/views/login-root.render.test.d.ts.map +1 -0
  16. package/dist/__tests__/views/login-root.render.test.js +79 -0
  17. package/dist/__tests__/views/login-root.render.test.js.map +1 -0
  18. package/dist/actuate-admin.css +1 -1
  19. package/dist/components/MediaPickerModal.d.ts +1 -1
  20. package/dist/components/MediaPickerModal.d.ts.map +1 -1
  21. package/dist/components/MediaPickerModal.js +12 -11
  22. package/dist/components/MediaPickerModal.js.map +1 -1
  23. package/dist/components/SchedulePublishDialog.d.ts +1 -1
  24. package/dist/components/SchedulePublishDialog.d.ts.map +1 -1
  25. package/dist/components/SchedulePublishDialog.js +6 -4
  26. package/dist/components/SchedulePublishDialog.js.map +1 -1
  27. package/dist/components/SharePreviewLinkDialog.d.ts +1 -1
  28. package/dist/components/SharePreviewLinkDialog.d.ts.map +1 -1
  29. package/dist/components/SharePreviewLinkDialog.js +10 -9
  30. package/dist/components/SharePreviewLinkDialog.js.map +1 -1
  31. package/dist/components/VersionHistory.d.ts +1 -1
  32. package/dist/components/VersionHistory.d.ts.map +1 -1
  33. package/dist/components/VersionHistory.js +14 -14
  34. package/dist/components/VersionHistory.js.map +1 -1
  35. package/dist/components/ui/Modal.d.ts +17 -1
  36. package/dist/components/ui/Modal.d.ts.map +1 -1
  37. package/dist/components/ui/Modal.js +16 -15
  38. package/dist/components/ui/Modal.js.map +1 -1
  39. package/dist/fields/BlockBuilderField.d.ts.map +1 -1
  40. package/dist/fields/BlockBuilderField.js +6 -4
  41. package/dist/fields/BlockBuilderField.js.map +1 -1
  42. package/dist/index.d.ts +2 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +1 -0
  45. package/dist/index.js.map +1 -1
  46. package/dist/lib/root-chrome.d.ts +15 -0
  47. package/dist/lib/root-chrome.d.ts.map +1 -0
  48. package/dist/lib/root-chrome.js +41 -0
  49. package/dist/lib/root-chrome.js.map +1 -0
  50. package/dist/views/HiddenLoginNotFound.d.ts +8 -0
  51. package/dist/views/HiddenLoginNotFound.d.ts.map +1 -0
  52. package/dist/views/HiddenLoginNotFound.js +29 -0
  53. package/dist/views/HiddenLoginNotFound.js.map +1 -0
  54. package/dist/views/page-builder/AIGenerateDialog.d.ts +1 -1
  55. package/dist/views/page-builder/AIGenerateDialog.d.ts.map +1 -1
  56. package/dist/views/page-builder/AIGenerateDialog.js +23 -23
  57. package/dist/views/page-builder/AIGenerateDialog.js.map +1 -1
  58. package/package.json +2 -2
  59. package/src/AdminRoot.tsx +61 -59
  60. package/src/LoginRoot.tsx +166 -0
  61. package/src/__tests__/components/modal.render.test.tsx +57 -0
  62. package/src/__tests__/views/login-root.render.test.tsx +115 -0
  63. package/src/components/MediaPickerModal.tsx +143 -119
  64. package/src/components/SchedulePublishDialog.tsx +118 -93
  65. package/src/components/SharePreviewLinkDialog.tsx +115 -93
  66. package/src/components/VersionHistory.tsx +98 -82
  67. package/src/components/ui/Modal.tsx +48 -39
  68. package/src/fields/BlockBuilderField.tsx +26 -19
  69. package/src/index.ts +2 -0
  70. package/src/lib/root-chrome.ts +40 -0
  71. package/src/views/HiddenLoginNotFound.tsx +45 -0
  72. package/src/views/page-builder/AIGenerateDialog.tsx +347 -335
@@ -1,9 +1,11 @@
1
1
  'use client'
2
2
 
3
3
  import { useState, useEffect } from 'react'
4
+ import * as Dialog from '@radix-ui/react-dialog'
4
5
  import { X, RotateCcw, Clock, User, Loader2 } from 'lucide-react'
5
6
  import { toast } from 'sonner'
6
7
  import { cmsApi } from '../lib/api.js'
8
+ import { adminPortalContainer } from '../lib/portal-container.js'
7
9
 
8
10
  interface Version {
9
11
  id: string
@@ -38,10 +40,10 @@ function timeAgo(dateStr: string): string {
38
40
  }
39
41
 
40
42
  const CHANGE_TYPE_LABELS: Record<string, { label: string; color: string }> = {
41
- CREATE: { label: 'Created', color: 'bg-green-100 text-green-800' },
42
- UPDATE: { label: 'Updated', color: 'bg-blue-100 text-blue-800' },
43
- DELETE: { label: 'Deleted', color: 'bg-red-100 text-red-800' },
44
- RESTORE: { label: 'Restored', color: 'bg-purple-100 text-purple-800' },
43
+ CREATE: { label: 'Created', color: 'bg-success/15 text-success' },
44
+ UPDATE: { label: 'Updated', color: 'bg-primary/15 text-primary' },
45
+ DELETE: { label: 'Deleted', color: 'bg-destructive/15 text-destructive' },
46
+ RESTORE: { label: 'Restored', color: 'bg-accent text-accent-foreground' },
45
47
  }
46
48
 
47
49
  export function VersionHistory({
@@ -88,91 +90,105 @@ export function VersionHistory({
88
90
  setRestoring(null)
89
91
  }
90
92
 
91
- if (!open) return null
92
-
93
93
  return (
94
- <div className="fixed inset-0 z-50 flex justify-end">
95
- <div className="fixed inset-0 bg-black/30" onClick={onClose} />
96
- <div className="animate-in slide-in-from-right relative flex w-full max-w-md flex-col bg-white shadow-xl">
97
- <div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
98
- <div className="flex items-center gap-2">
99
- <Clock className="h-5 w-5 text-gray-600" />
100
- <h2 className="text-lg font-semibold text-gray-900">Version History</h2>
94
+ <Dialog.Root open={open} onOpenChange={(next) => !next && onClose()}>
95
+ <Dialog.Portal container={adminPortalContainer()}>
96
+ <Dialog.Overlay className="fixed inset-0 z-50 bg-black/30" />
97
+ <Dialog.Content
98
+ aria-describedby={undefined}
99
+ className="bg-card text-card-foreground border-border animate-in slide-in-from-right fixed inset-y-0 right-0 z-50 flex w-full max-w-md flex-col border-l shadow-xl focus:outline-none"
100
+ >
101
+ <div className="border-border flex items-center justify-between border-b px-4 py-3">
102
+ <div className="flex items-center gap-2">
103
+ <Clock className="text-muted-foreground h-5 w-5" aria-hidden />
104
+ <Dialog.Title className="text-foreground text-lg font-medium">
105
+ Version History
106
+ </Dialog.Title>
107
+ </div>
108
+ <Dialog.Close asChild>
109
+ <button
110
+ type="button"
111
+ aria-label="Close version history"
112
+ className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring rounded-lg p-1.5 transition-colors focus-visible:ring-2 focus-visible:outline-none"
113
+ >
114
+ <X className="h-5 w-5" aria-hidden />
115
+ </button>
116
+ </Dialog.Close>
101
117
  </div>
102
- <button
103
- onClick={onClose}
104
- className="rounded-lg p-1.5 transition-colors hover:bg-gray-100"
105
- >
106
- <X className="h-5 w-5 text-gray-500" />
107
- </button>
108
- </div>
109
118
 
110
- <div className="flex-1 overflow-y-auto">
111
- {loading ? (
112
- <div className="flex items-center justify-center py-16">
113
- <Loader2 className="h-6 w-6 animate-spin text-gray-400" />
114
- </div>
115
- ) : versions.length === 0 ? (
116
- <div className="py-16 text-center text-sm text-gray-500">
117
- No version history available
118
- </div>
119
- ) : (
120
- <div className="divide-y divide-gray-100">
121
- {versions.map((version, index) => {
122
- const typeInfo = CHANGE_TYPE_LABELS[version.changeType] ?? {
123
- label: version.changeType,
124
- color: 'bg-gray-100 text-gray-800',
125
- }
126
- const isLatest = index === 0
119
+ <div className="flex-1 overflow-y-auto">
120
+ {loading ? (
121
+ <div className="flex items-center justify-center py-16">
122
+ <Loader2 className="text-muted-foreground h-6 w-6 animate-spin" aria-hidden />
123
+ </div>
124
+ ) : versions.length === 0 ? (
125
+ <div className="text-muted-foreground py-16 text-center text-sm">
126
+ No version history available
127
+ </div>
128
+ ) : (
129
+ <div className="divide-border divide-y">
130
+ {versions.map((version, index) => {
131
+ const typeInfo = CHANGE_TYPE_LABELS[version.changeType] ?? {
132
+ label: version.changeType,
133
+ color: 'bg-muted text-muted-foreground',
134
+ }
135
+ const isLatest = index === 0
127
136
 
128
- return (
129
- <div key={version.id} className="px-4 py-3 transition-colors hover:bg-gray-50">
130
- <div className="flex items-start justify-between gap-3">
131
- <div className="min-w-0 flex-1">
132
- <div className="mb-1 flex items-center gap-2">
133
- <span
134
- className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${typeInfo.color}`}
135
- >
136
- {typeInfo.label}
137
- </span>
138
- {isLatest && (
139
- <span className="text-xs font-medium text-gray-400">Current</span>
140
- )}
141
- </div>
142
- <div className="flex items-center gap-3 text-xs text-gray-500">
143
- {version.changedBy && (
144
- <span className="flex items-center gap-1">
145
- <User className="h-3 w-3" />
146
- {version.changedBy.name ?? version.changedBy.email ?? 'Unknown'}
137
+ return (
138
+ <div
139
+ key={version.id}
140
+ className="hover:bg-accent/50 px-4 py-3 transition-colors"
141
+ >
142
+ <div className="flex items-start justify-between gap-3">
143
+ <div className="min-w-0 flex-1">
144
+ <div className="mb-1 flex items-center gap-2">
145
+ <span
146
+ className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${typeInfo.color}`}
147
+ >
148
+ {typeInfo.label}
149
+ </span>
150
+ {isLatest && (
151
+ <span className="text-muted-foreground text-xs font-medium">
152
+ Current
153
+ </span>
154
+ )}
155
+ </div>
156
+ <div className="text-muted-foreground flex items-center gap-3 text-xs">
157
+ {version.changedBy && (
158
+ <span className="flex items-center gap-1">
159
+ <User className="h-3 w-3" aria-hidden />
160
+ {version.changedBy.name ?? version.changedBy.email ?? 'Unknown'}
161
+ </span>
162
+ )}
163
+ <span title={new Date(version.createdAt).toLocaleString()}>
164
+ {timeAgo(version.createdAt)}
147
165
  </span>
148
- )}
149
- <span title={new Date(version.createdAt).toLocaleString()}>
150
- {timeAgo(version.createdAt)}
151
- </span>
166
+ </div>
152
167
  </div>
168
+ {!isLatest && (
169
+ <button
170
+ type="button"
171
+ onClick={() => handleRestore(version)}
172
+ disabled={restoring === version.id}
173
+ className="bg-primary/10 text-primary hover:bg-primary/20 flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors disabled:opacity-50"
174
+ >
175
+ {restoring === version.id ? (
176
+ <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
177
+ ) : (
178
+ <RotateCcw className="h-3.5 w-3.5" aria-hidden />
179
+ )}
180
+ Restore
181
+ </button>
182
+ )}
153
183
  </div>
154
- {!isLatest && (
155
- <button
156
- onClick={() => handleRestore(version)}
157
- disabled={restoring === version.id}
158
- className="flex items-center gap-1.5 rounded-lg bg-blue-50 px-2.5 py-1.5 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-100 disabled:opacity-50"
159
- >
160
- {restoring === version.id ? (
161
- <Loader2 className="h-3.5 w-3.5 animate-spin" />
162
- ) : (
163
- <RotateCcw className="h-3.5 w-3.5" />
164
- )}
165
- Restore
166
- </button>
167
- )}
168
184
  </div>
169
- </div>
170
- )
171
- })}
172
- </div>
173
- )}
174
- </div>
175
- </div>
176
- </div>
185
+ )
186
+ })}
187
+ </div>
188
+ )}
189
+ </div>
190
+ </Dialog.Content>
191
+ </Dialog.Portal>
192
+ </Dialog.Root>
177
193
  )
178
194
  }
@@ -1,6 +1,9 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, type ReactNode } from 'react'
3
+ import * as Dialog from '@radix-ui/react-dialog'
4
+ import { X } from 'lucide-react'
5
+ import { type ReactNode } from 'react'
6
+ import { adminPortalContainer } from '../../lib/portal-container.js'
4
7
 
5
8
  export interface ModalProps {
6
9
  open: boolean
@@ -8,47 +11,53 @@ export interface ModalProps {
8
11
  title: string
9
12
  children: ReactNode
10
13
  actions?: ReactNode
14
+ /**
15
+ * Accessible description for the dialog, announced after the title. Optional —
16
+ * omit when the body content is self-explanatory.
17
+ */
18
+ description?: string
11
19
  }
12
20
 
13
- export function Modal({ open, onClose, title, children, actions }: ModalProps) {
14
- useEffect(() => {
15
- if (!open) return
16
-
17
- function handleKey(e: KeyboardEvent) {
18
- if (e.key === 'Escape') onClose()
19
- }
20
-
21
- document.addEventListener('keydown', handleKey)
22
- return () => document.removeEventListener('keydown', handleKey)
23
- }, [open, onClose])
24
-
25
- if (!open) return null
26
-
21
+ /**
22
+ * The canonical admin modal. Built on Radix `Dialog`, which provides the
23
+ * accessibility contract for free: focus is trapped while open and restored to
24
+ * the trigger on close, Escape and overlay-click dismiss, the surface is
25
+ * exposed as `role="dialog"` + `aria-modal`, and the title/description are wired
26
+ * via `aria-labelledby`/`aria-describedby`.
27
+ *
28
+ * The portal mounts inside `.actuate-admin` (see `adminPortalContainer`) so the
29
+ * dialog inherits theme tokens and dark mode instead of rendering unstyled on
30
+ * `document.body`.
31
+ */
32
+ export function Modal({ open, onClose, title, children, actions, description }: ModalProps) {
27
33
  return (
28
- <div className="fixed inset-0 z-[60] flex items-center justify-center">
29
- <div className="fixed inset-0 bg-black/50" onClick={onClose} role="presentation" />
30
- <div className="relative z-10 w-full max-w-lg rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-xl">
31
- <div className="flex items-center justify-between border-b border-[var(--border)] px-6 py-4">
32
- <h2 className="text-lg font-semibold">{title}</h2>
33
- <button onClick={onClose} className="rounded-md p-1 hover:bg-[var(--accent)]">
34
- <svg
35
- className="h-4 w-4"
36
- fill="none"
37
- viewBox="0 0 24 24"
38
- stroke="currentColor"
39
- strokeWidth={2}
40
- >
41
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
42
- </svg>
43
- </button>
44
- </div>
45
- <div className="px-6 py-4">{children}</div>
46
- {actions && (
47
- <div className="flex justify-end gap-2 border-t border-[var(--border)] px-6 py-4">
48
- {actions}
34
+ <Dialog.Root open={open} onOpenChange={(next) => !next && onClose()}>
35
+ <Dialog.Portal container={adminPortalContainer()}>
36
+ <Dialog.Overlay className="fixed inset-0 z-60 bg-black/50" />
37
+ <Dialog.Content className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-60 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none">
38
+ <div className="border-border flex items-center justify-between border-b px-6 py-4">
39
+ <Dialog.Title className="text-foreground text-lg font-medium">{title}</Dialog.Title>
40
+ <Dialog.Close asChild>
41
+ <button
42
+ type="button"
43
+ aria-label="Close dialog"
44
+ className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring rounded-md p-1 transition-colors focus-visible:ring-2 focus-visible:outline-none"
45
+ >
46
+ <X className="h-4 w-4" aria-hidden />
47
+ </button>
48
+ </Dialog.Close>
49
49
  </div>
50
- )}
51
- </div>
52
- </div>
50
+ {description && (
51
+ <Dialog.Description className="text-muted-foreground px-6 pt-4 text-sm">
52
+ {description}
53
+ </Dialog.Description>
54
+ )}
55
+ <div className="px-6 py-4">{children}</div>
56
+ {actions && (
57
+ <div className="border-border flex justify-end gap-2 border-t px-6 py-4">{actions}</div>
58
+ )}
59
+ </Dialog.Content>
60
+ </Dialog.Portal>
61
+ </Dialog.Root>
53
62
  )
54
63
  }
@@ -18,6 +18,7 @@ import {
18
18
  arrayMove,
19
19
  } from '@dnd-kit/sortable'
20
20
  import { CSS } from '@dnd-kit/utilities'
21
+ import * as Dialog from '@radix-ui/react-dialog'
21
22
  import {
22
23
  Plus,
23
24
  GripVertical,
@@ -33,6 +34,7 @@ import {
33
34
  X,
34
35
  } from 'lucide-react'
35
36
  import { PRESET_BLOCKS, type BlockTypeDefinition } from './block-types.js'
37
+ import { adminPortalContainer } from '../lib/portal-container.js'
36
38
 
37
39
  const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
38
40
  layout: Layout,
@@ -359,19 +361,24 @@ export function BlockBuilderField({
359
361
  </button>
360
362
  </div>
361
363
 
362
- {pickerOpen && (
363
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
364
- <div className="w-full max-w-2xl rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-xl">
365
- <div className="flex items-center justify-between border-b border-[var(--border)] px-5 py-4">
366
- <h3 className="text-base font-semibold">Add Block</h3>
367
- <button
368
- type="button"
369
- onClick={() => setPickerOpen(false)}
370
- className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
371
- aria-label="Close"
372
- >
373
- <X className="h-5 w-5" />
374
- </button>
364
+ <Dialog.Root open={pickerOpen} onOpenChange={setPickerOpen}>
365
+ <Dialog.Portal container={adminPortalContainer()}>
366
+ <Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
367
+ <Dialog.Content
368
+ aria-describedby={undefined}
369
+ className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-50 w-[calc(100%-2rem)] max-w-2xl -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none"
370
+ >
371
+ <div className="border-border flex items-center justify-between border-b px-5 py-4">
372
+ <Dialog.Title className="text-base font-medium">Add Block</Dialog.Title>
373
+ <Dialog.Close asChild>
374
+ <button
375
+ type="button"
376
+ className="text-muted-foreground hover:text-foreground focus-visible:ring-ring rounded-md p-1 focus-visible:ring-2 focus-visible:outline-none"
377
+ aria-label="Close block picker"
378
+ >
379
+ <X className="h-5 w-5" aria-hidden />
380
+ </button>
381
+ </Dialog.Close>
375
382
  </div>
376
383
  <div className="grid grid-cols-2 gap-3 p-5 sm:grid-cols-3">
377
384
  {allBlockTypes.map((bt) => {
@@ -381,20 +388,20 @@ export function BlockBuilderField({
381
388
  key={bt.type}
382
389
  type="button"
383
390
  onClick={() => addBlock(bt.type)}
384
- className="flex flex-col items-start gap-1.5 rounded-md border border-[var(--border)] p-3 text-left transition-colors hover:border-[var(--primary)] hover:bg-[var(--accent)]"
391
+ className="border-border hover:border-primary hover:bg-accent flex flex-col items-start gap-1.5 rounded-md border p-3 text-left transition-colors"
385
392
  >
386
- {Icon && <Icon className="h-5 w-5 text-[var(--primary)]" />}
393
+ {Icon && <Icon className="text-primary h-5 w-5" aria-hidden />}
387
394
  <span className="text-sm font-medium">{bt.label}</span>
388
- <span className="text-xs leading-snug text-[var(--muted-foreground)]">
395
+ <span className="text-muted-foreground text-xs leading-snug">
389
396
  {bt.description}
390
397
  </span>
391
398
  </button>
392
399
  )
393
400
  })}
394
401
  </div>
395
- </div>
396
- </div>
397
- )}
402
+ </Dialog.Content>
403
+ </Dialog.Portal>
404
+ </Dialog.Root>
398
405
 
399
406
  {helpText && <p className="mt-1 text-xs text-[var(--muted-foreground)]">{helpText}</p>}
400
407
  </div>
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { AdminRoot } from './AdminRoot.js'
2
2
  export type { AdminRootProps } from './AdminRoot.js'
3
+ export { LoginRoot } from './LoginRoot.js'
4
+ export type { LoginRootProps } from './LoginRoot.js'
3
5
 
4
6
  // Pluggable canvas renderers for custom section types (see `AdminRoot`'s
5
7
  // `sectionRenderers` prop). The types structurally mirror
@@ -0,0 +1,40 @@
1
+ import { useEffect } from 'react'
2
+ import type React from 'react'
3
+
4
+ /**
5
+ * Full-viewport overlay style shared by the admin and login mounts so the CMS
6
+ * UI renders above any consumer page chrome, with its own stacking context.
7
+ */
8
+ export const ISOLATION_STYLE: React.CSSProperties = {
9
+ position: 'fixed',
10
+ inset: '0',
11
+ zIndex: 50,
12
+ overflow: 'auto',
13
+ isolation: 'isolate',
14
+ }
15
+
16
+ /**
17
+ * The admin (and its login screens) are private application UI and must never
18
+ * be indexed. Ensure a `noindex, nofollow` robots meta tag is present while
19
+ * mounted, as defense-in-depth alongside the server `robots.txt` Disallow
20
+ * rule. This covers every consumer install (the admin page is typically a
21
+ * client component that can't export Next.js route metadata).
22
+ */
23
+ export function useRobotsNoIndex(): void {
24
+ useEffect(() => {
25
+ if (typeof document === 'undefined') return
26
+ const prev = document.querySelector<HTMLMetaElement>('meta[name="robots"]')
27
+ const prevContent = prev?.getAttribute('content') ?? null
28
+ const meta = prev ?? document.createElement('meta')
29
+ meta.setAttribute('name', 'robots')
30
+ meta.setAttribute('content', 'noindex, nofollow')
31
+ if (!prev) document.head.appendChild(meta)
32
+ return () => {
33
+ if (prevContent === null) {
34
+ meta.remove()
35
+ } else {
36
+ meta.setAttribute('content', prevContent)
37
+ }
38
+ }
39
+ }, [])
40
+ }
@@ -0,0 +1,45 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Rendered at the admin mount for signed-out visitors on split-login installs
5
+ * (`config.admin.loginPath` set). Mimics the framework's default 404 page so
6
+ * hitting `/admin` reveals nothing — no login form, no admin branding, and no
7
+ * redirect that would leak the real login location.
8
+ */
9
+ export function HiddenLoginNotFound() {
10
+ return (
11
+ <div
12
+ style={{
13
+ fontFamily: 'system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
14
+ height: '100vh',
15
+ display: 'flex',
16
+ alignItems: 'center',
17
+ justifyContent: 'center',
18
+ background: '#fff',
19
+ color: '#000',
20
+ textAlign: 'center',
21
+ }}
22
+ >
23
+ <div style={{ display: 'flex', alignItems: 'center', lineHeight: '49px' }}>
24
+ <h1
25
+ style={{
26
+ display: 'inline-block',
27
+ margin: '0 20px 0 0',
28
+ paddingRight: 23,
29
+ fontSize: 24,
30
+ fontWeight: 500,
31
+ verticalAlign: 'top',
32
+ borderRight: '1px solid rgba(0, 0, 0, .3)',
33
+ }}
34
+ >
35
+ 404
36
+ </h1>
37
+ <div style={{ display: 'inline-block' }}>
38
+ <h2 style={{ margin: 0, fontSize: 14, fontWeight: 400, lineHeight: '49px' }}>
39
+ This page could not be found.
40
+ </h2>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ )
45
+ }