kilo-cms 0.1.0

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 (179) hide show
  1. package/README.md +186 -0
  2. package/drizzle.kilo.config.ts +17 -0
  3. package/package.json +97 -0
  4. package/src/admin/AiSettingsEditor.tsx +48 -0
  5. package/src/admin/AiWritingAssistant.tsx +159 -0
  6. package/src/admin/ApiTokensEditor.tsx +69 -0
  7. package/src/admin/ArrayField.tsx +118 -0
  8. package/src/admin/BlocksField.tsx +131 -0
  9. package/src/admin/Button.tsx +31 -0
  10. package/src/admin/CloseButton.tsx +18 -0
  11. package/src/admin/CmsLocalesProvider.tsx +19 -0
  12. package/src/admin/CmsSidebar.tsx +147 -0
  13. package/src/admin/CmsSiteSettingsProvider.tsx +30 -0
  14. package/src/admin/CmsThemeProvider.tsx +100 -0
  15. package/src/admin/CollectionListEditor.tsx +278 -0
  16. package/src/admin/CollectionRecordEditor.tsx +420 -0
  17. package/src/admin/ContentEditorForm.tsx +21 -0
  18. package/src/admin/ContentViewSettings.tsx +55 -0
  19. package/src/admin/DashboardBuilder.tsx +284 -0
  20. package/src/admin/DragHandle.tsx +3 -0
  21. package/src/admin/DynamicField.tsx +517 -0
  22. package/src/admin/EditorHeader.tsx +13 -0
  23. package/src/admin/EditorSkeleton.tsx +27 -0
  24. package/src/admin/EmailSettingsEditor.tsx +80 -0
  25. package/src/admin/Field.tsx +215 -0
  26. package/src/admin/FilterBuilder.tsx +62 -0
  27. package/src/admin/GroupField.tsx +61 -0
  28. package/src/admin/ImageUploader.tsx +198 -0
  29. package/src/admin/ListingSettingsModal.tsx +68 -0
  30. package/src/admin/ListingView.tsx +123 -0
  31. package/src/admin/LocalesEditor.tsx +86 -0
  32. package/src/admin/LocalizedField.tsx +46 -0
  33. package/src/admin/MapPicker.tsx +53 -0
  34. package/src/admin/MediaLibrary.tsx +483 -0
  35. package/src/admin/MediaUploader.tsx +55 -0
  36. package/src/admin/Portal.tsx +10 -0
  37. package/src/admin/PublishingPanel.tsx +102 -0
  38. package/src/admin/RecordMeta.tsx +24 -0
  39. package/src/admin/ReviewQueueEditor.tsx +103 -0
  40. package/src/admin/RichTextEditor.tsx +49 -0
  41. package/src/admin/RoleEditor.tsx +121 -0
  42. package/src/admin/RolesEditor.tsx +42 -0
  43. package/src/admin/RowActionsMenu.tsx +64 -0
  44. package/src/admin/ScheduledEditor.tsx +155 -0
  45. package/src/admin/SeoSettingsEditor.tsx +53 -0
  46. package/src/admin/SingleRecordEditor.tsx +215 -0
  47. package/src/admin/SiteSettingsEditor.tsx +96 -0
  48. package/src/admin/Toast.tsx +45 -0
  49. package/src/admin/UserEditor.tsx +64 -0
  50. package/src/admin/UsersEditor.tsx +37 -0
  51. package/src/admin/WebhooksEditor.tsx +115 -0
  52. package/src/admin/admin.css +6637 -0
  53. package/src/admin/globals.css +14 -0
  54. package/src/admin/pages/admin-index.tsx +5 -0
  55. package/src/admin/pages/ai-settings.tsx +9 -0
  56. package/src/admin/pages/api-tokens.tsx +10 -0
  57. package/src/admin/pages/collection-list.tsx +22 -0
  58. package/src/admin/pages/collection-record.tsx +31 -0
  59. package/src/admin/pages/dashboard.tsx +10 -0
  60. package/src/admin/pages/email-settings.tsx +10 -0
  61. package/src/admin/pages/layout.tsx +24 -0
  62. package/src/admin/pages/locales-settings.tsx +10 -0
  63. package/src/admin/pages/login.tsx +25 -0
  64. package/src/admin/pages/media.tsx +9 -0
  65. package/src/admin/pages/review-queue.tsx +20 -0
  66. package/src/admin/pages/role-edit.tsx +11 -0
  67. package/src/admin/pages/roles.tsx +10 -0
  68. package/src/admin/pages/scheduled.tsx +17 -0
  69. package/src/admin/pages/seo-settings.tsx +9 -0
  70. package/src/admin/pages/setup-admin.tsx +76 -0
  71. package/src/admin/pages/single-record.tsx +24 -0
  72. package/src/admin/pages/site-settings.tsx +9 -0
  73. package/src/admin/pages/user-edit.tsx +11 -0
  74. package/src/admin/pages/user-new.tsx +10 -0
  75. package/src/admin/pages/users.tsx +10 -0
  76. package/src/admin/pages/webhooks-settings.tsx +10 -0
  77. package/src/admin/useKeyedRows.ts +35 -0
  78. package/src/ai-config.ts +35 -0
  79. package/src/ai-secrets.ts +17 -0
  80. package/src/ai-types.ts +10 -0
  81. package/src/ai-writer.ts +84 -0
  82. package/src/api-tokens.ts +25 -0
  83. package/src/auth-client.ts +5 -0
  84. package/src/auth.ts +33 -0
  85. package/src/calendar-grid.ts +12 -0
  86. package/src/cli/index.mjs +149 -0
  87. package/src/cms-appearance.ts +55 -0
  88. package/src/collections/content-view.ts +69 -0
  89. package/src/collections/define.ts +17 -0
  90. package/src/collections/filters.ts +92 -0
  91. package/src/collections/index.ts +11 -0
  92. package/src/collections/join.ts +64 -0
  93. package/src/collections/locale.ts +22 -0
  94. package/src/collections/query.ts +160 -0
  95. package/src/collections/registry.ts +88 -0
  96. package/src/collections/schedule.ts +12 -0
  97. package/src/collections/server.ts +12 -0
  98. package/src/collections/single.ts +26 -0
  99. package/src/collections/table.ts +14 -0
  100. package/src/collections/types.ts +336 -0
  101. package/src/collections/validation.ts +217 -0
  102. package/src/collections/values.ts +181 -0
  103. package/src/collections/workflow.ts +90 -0
  104. package/src/config.ts +34 -0
  105. package/src/content-types.ts +218 -0
  106. package/src/dashboard.ts +39 -0
  107. package/src/db.ts +50 -0
  108. package/src/email.ts +39 -0
  109. package/src/format.ts +4 -0
  110. package/src/listQuery.ts +23 -0
  111. package/src/locale-settings.ts +23 -0
  112. package/src/media-storage.ts +19 -0
  113. package/src/permissions.ts +37 -0
  114. package/src/publishing-workflow-db.ts +11 -0
  115. package/src/publishing-workflow.ts +20 -0
  116. package/src/richtext.ts +5 -0
  117. package/src/routes/admin/ai/settings/route.ts +20 -0
  118. package/src/routes/admin/ai/write/route.ts +21 -0
  119. package/src/routes/admin/api-tokens/[id]/route.ts +12 -0
  120. package/src/routes/admin/api-tokens/route.ts +26 -0
  121. package/src/routes/admin/collections/[collection]/[id]/approve/route.ts +22 -0
  122. package/src/routes/admin/collections/[collection]/[id]/duplicate/route.ts +36 -0
  123. package/src/routes/admin/collections/[collection]/[id]/restore/route.ts +19 -0
  124. package/src/routes/admin/collections/[collection]/[id]/review/route.ts +28 -0
  125. package/src/routes/admin/collections/[collection]/[id]/route.ts +110 -0
  126. package/src/routes/admin/collections/[collection]/[id]/schedule/route.ts +42 -0
  127. package/src/routes/admin/collections/[collection]/[id]/submit-review/route.ts +23 -0
  128. package/src/routes/admin/collections/[collection]/[id]/unschedule/route.ts +22 -0
  129. package/src/routes/admin/collections/[collection]/bulk/route.ts +61 -0
  130. package/src/routes/admin/collections/[collection]/options/route.ts +38 -0
  131. package/src/routes/admin/collections/[collection]/reorder/route.ts +19 -0
  132. package/src/routes/admin/collections/[collection]/route.ts +88 -0
  133. package/src/routes/admin/collections/route.ts +13 -0
  134. package/src/routes/admin/content-views/[type]/route.ts +25 -0
  135. package/src/routes/admin/dashboard/data/route.ts +39 -0
  136. package/src/routes/admin/dashboard/route.ts +30 -0
  137. package/src/routes/admin/email-settings/route.ts +49 -0
  138. package/src/routes/admin/email-settings/test/route.ts +18 -0
  139. package/src/routes/admin/locales/[code]/route.ts +38 -0
  140. package/src/routes/admin/locales/reorder/route.ts +18 -0
  141. package/src/routes/admin/locales/route.ts +46 -0
  142. package/src/routes/admin/media/[id]/restore/route.ts +13 -0
  143. package/src/routes/admin/media/[id]/route.ts +56 -0
  144. package/src/routes/admin/media/bulk/route.ts +46 -0
  145. package/src/routes/admin/media/folders/[id]/route.ts +34 -0
  146. package/src/routes/admin/media/route.ts +41 -0
  147. package/src/routes/admin/media/stats/route.ts +14 -0
  148. package/src/routes/admin/roles/[id]/route.ts +43 -0
  149. package/src/routes/admin/roles/route.ts +34 -0
  150. package/src/routes/admin/settings/route.ts +45 -0
  151. package/src/routes/admin/single/[type]/route.ts +54 -0
  152. package/src/routes/admin/singles/route.ts +15 -0
  153. package/src/routes/admin/upload/route.ts +55 -0
  154. package/src/routes/admin/users/[id]/route.ts +24 -0
  155. package/src/routes/admin/users/route.ts +24 -0
  156. package/src/routes/admin/webhooks/[id]/route.ts +38 -0
  157. package/src/routes/admin/webhooks/route.ts +26 -0
  158. package/src/routes/cron/publish-scheduled.ts +37 -0
  159. package/src/routes/setup/admin.ts +19 -0
  160. package/src/schema/admin-ui.ts +18 -0
  161. package/src/schema/auth.ts +49 -0
  162. package/src/schema/developer-tools.ts +44 -0
  163. package/src/schema/helpers.ts +35 -0
  164. package/src/schema/index.ts +33 -0
  165. package/src/schema/locales.ts +13 -0
  166. package/src/schema/media.ts +22 -0
  167. package/src/schema/rbac.ts +11 -0
  168. package/src/schema/settings.ts +28 -0
  169. package/src/secrets.ts +26 -0
  170. package/src/seo.ts +69 -0
  171. package/src/storage/index.ts +20 -0
  172. package/src/storage/local.ts +27 -0
  173. package/src/storage/s3-compatible.ts +31 -0
  174. package/src/storage/types.ts +8 -0
  175. package/src/storage/vercel-blob.ts +17 -0
  176. package/src/useBodyScrollLock.ts +12 -0
  177. package/src/useLocalStorageState.ts +26 -0
  178. package/src/webhook-events.ts +12 -0
  179. package/src/webhooks.ts +30 -0
@@ -0,0 +1,483 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState, type HTMLAttributes, type ReactNode } from 'react'
4
+ import { CmsSidebar } from './CmsSidebar'
5
+ import { EditorHeader } from './EditorHeader'
6
+ import { MediaUploader } from './MediaUploader'
7
+ import { useBodyScrollLock } from '../useBodyScrollLock'
8
+ import { Portal } from './Portal'
9
+ import { CloseButton } from './CloseButton'
10
+ import { Button } from './Button'
11
+
12
+ type Folder = { id: string; name: string; slug: string; count: number; pinned: boolean }
13
+ type Media = { id: string; filename: string; url: string; folder: string; mimeType: string; size: number; starred: boolean; deletedAt: string | null }
14
+ type View = 'library' | 'recent' | 'starred' | 'trash'
15
+ type Stats = { count: number; bytes: number; trashCount: number }
16
+
17
+ function formatSize(bytes: number) { return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB` }
18
+
19
+ function fileKind(mimeType: string): 'image' | 'video' | 'audio' | 'pdf' | 'file' {
20
+ if (mimeType.startsWith('image/')) return 'image'
21
+ if (mimeType.startsWith('video/')) return 'video'
22
+ if (mimeType.startsWith('audio/')) return 'audio'
23
+ if (mimeType === 'application/pdf') return 'pdf'
24
+ return 'file'
25
+ }
26
+
27
+ function FileTypeIcon({ mimeType }: { mimeType: string }) {
28
+ const kind = fileKind(mimeType)
29
+ const paths: Record<'video' | 'audio' | 'pdf' | 'file', ReactNode> = {
30
+ video: <><rect x="3" y="5" width="18" height="14" rx="2" /><path d="M10 9.3v5.4l4.8-2.7Z" fill="currentColor" stroke="none" /></>,
31
+ audio: <><path d="M9 18V6l10-2v12" /><circle cx="6" cy="18" r="3" /><circle cx="16" cy="16" r="3" /></>,
32
+ pdf: <><path d="M6 2h9l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z" /><path d="M14 2v6h6" /></>,
33
+ file: <><path d="M6 2h9l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z" /><path d="M14 2v6h6" /></>,
34
+ }
35
+ return <svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">{paths[kind === 'image' ? 'file' : kind]}</svg>
36
+ }
37
+
38
+ function MediaThumb({ item }: { item: Media }) {
39
+ const kind = fileKind(item.mimeType)
40
+ if (kind === 'image') return <img src={item.url} alt={item.filename} />
41
+ if (kind === 'video') return <>
42
+ {/* #t= seeks to that timestamp on load, so the frame renders as a static thumbnail with no JS or server-side processing. */}
43
+ <video src={`${item.url}#t=0.5`} muted playsInline preload="metadata" />
44
+ <span className="cms-media-video-badge"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7Z" /></svg></span>
45
+ </>
46
+ return <div className="cms-media-file-thumb"><FileTypeIcon mimeType={item.mimeType} /><span>{item.mimeType.split('/')[1]?.toUpperCase() ?? 'FILE'}</span></div>
47
+ }
48
+
49
+ function CardButton({ className, onClick, children, ...rest }: { className: string; onClick: () => void; children: ReactNode } & HTMLAttributes<HTMLDivElement>) {
50
+ return <div role="button" tabIndex={0} className={className} onClick={onClick} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onClick() } }} {...rest}>{children}</div>
51
+ }
52
+
53
+ function SelectCheckbox({ checked, onToggle }: { checked: boolean; onToggle: () => void }) {
54
+ return <button type="button" className={`cms-select-checkbox ${checked ? 'is-checked' : ''}`} onClick={(event) => { event.stopPropagation(); onToggle() }} aria-label={checked ? 'Deselect' : 'Select'} aria-pressed={checked}>
55
+ {checked && <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12.5l5 5L20 6" /></svg>}
56
+ </button>
57
+ }
58
+
59
+ function RestoreButton({ onRestore }: { onRestore: () => void }) {
60
+ return <button type="button" className="cms-media-icon-btn cms-media-icon-btn-restore" onClick={(event) => { event.stopPropagation(); onRestore() }} aria-label="Restore">
61
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 11a9 9 0 1 1 2.6 6.3" /><path d="M3 5v6h6" /></svg>
62
+ </button>
63
+ }
64
+
65
+ function DeleteForeverButton({ onDelete }: { onDelete: () => void }) {
66
+ return <button type="button" className="cms-media-icon-btn cms-media-icon-btn-danger" onClick={(event) => { event.stopPropagation(); onDelete() }} aria-label="Delete permanently">
67
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 7h16" /><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" /><path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" /><path d="M10 11v6M14 11v6" /></svg>
68
+ </button>
69
+ }
70
+
71
+ function MenuTrigger({ onToggle, open }: { onToggle: () => void; open: boolean }) {
72
+ return <button type="button" className="cms-card-menu-trigger" onClick={onToggle} aria-haspopup="menu" aria-expanded={open} aria-label="Options">
73
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="12" cy="19" r="1.8" /></svg>
74
+ </button>
75
+ }
76
+
77
+ function FolderMenu({ folder, open, onToggle, onPin, onRename, onDelete }: {
78
+ folder: Folder
79
+ open: boolean
80
+ onToggle: () => void
81
+ onPin: () => void
82
+ onRename: () => void
83
+ onDelete: () => void
84
+ }) {
85
+ return <div className="cms-card-menu" onClick={(event) => event.stopPropagation()}>
86
+ <MenuTrigger onToggle={onToggle} open={open} />
87
+ {open && <div className="cms-card-menu-popup" role="menu">
88
+ <button type="button" role="menuitem" onClick={onPin}>{folder.pinned ? 'Unpin folder' : 'Pin folder'}</button>
89
+ <button type="button" role="menuitem" onClick={onRename}>Rename folder</button>
90
+ <button type="button" role="menuitem" className="cms-card-menu-danger" onClick={onDelete}>Delete folder</button>
91
+ </div>}
92
+ </div>
93
+ }
94
+
95
+ function ItemMenu({ item, open, onToggle, onStar, onRename, onMove, onTrash }: {
96
+ item: Media
97
+ open: boolean
98
+ onToggle: () => void
99
+ onStar: () => void
100
+ onRename: () => void
101
+ onMove: () => void
102
+ onTrash: () => void
103
+ }) {
104
+ return <div className="cms-card-menu" onClick={(event) => event.stopPropagation()}>
105
+ <MenuTrigger onToggle={onToggle} open={open} />
106
+ {open && <div className="cms-card-menu-popup" role="menu">
107
+ <button type="button" role="menuitem" onClick={onStar}>{item.starred ? 'Unstar' : 'Star'}</button>
108
+ <button type="button" role="menuitem" onClick={onRename}>Rename</button>
109
+ <button type="button" role="menuitem" onClick={onMove}>Move to folder</button>
110
+ <button type="button" role="menuitem" className="cms-card-menu-danger" onClick={onTrash}>Move to trash</button>
111
+ </div>}
112
+ </div>
113
+ }
114
+
115
+ function FolderIcon({ size = 46 }: { size?: number }) {
116
+ return <svg className="cms-folder-icon" width={size} height={size} viewBox="0 0 24 24" fill="none">
117
+ <path d="M3 7a2 2 0 0 1 2-2h4.5l2 2.2H19a2 2 0 0 1 2 2v8.3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" fill="var(--acid)" stroke="var(--ink)" strokeWidth="1.3" strokeLinejoin="round" />
118
+ </svg>
119
+ }
120
+
121
+ function MoveToFolderModal({ folders, count, name, onNameChange, onMove, onCreateAndMove, onClose }: {
122
+ folders: Folder[]
123
+ count: number
124
+ name: string
125
+ onNameChange: (value: string) => void
126
+ onMove: (slug: string) => void
127
+ onCreateAndMove: () => void
128
+ onClose: () => void
129
+ }) {
130
+ return <Portal><div className="move-popup-modal" role="dialog" aria-modal="true" aria-label="Move to folder" onClick={onClose}>
131
+ <div className="move-popup" onClick={(event) => event.stopPropagation()}>
132
+ <header><strong>Move {count} item{count === 1 ? '' : 's'}</strong><CloseButton onClick={onClose} /></header>
133
+ <div className="move-popup-list">
134
+ {folders.map((folder) => <button type="button" key={folder.id} onClick={() => onMove(folder.slug)}><FolderIcon size={22} />{folder.name}</button>)}
135
+ </div>
136
+ <div className="move-popup-create">
137
+ <input value={name} onChange={(event) => onNameChange(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') onCreateAndMove() }} placeholder="New folder name" aria-label="New folder name" />
138
+ <Button type="button" variant="positive" compact onClick={onCreateAndMove}>Create &amp; move</Button>
139
+ </div>
140
+ </div>
141
+ </div></Portal>
142
+ }
143
+
144
+ export function MediaLibrary({ userName }: { userName: string }) {
145
+ const [view, setView] = useState<View>('library')
146
+ const [libraryFolder, setLibraryFolder] = useState('uploads')
147
+ const [folders, setFolders] = useState<Folder[]>([])
148
+ const [items, setItems] = useState<Media[]>([])
149
+ const [status, setStatus] = useState('Loading…')
150
+ const [loading, setLoading] = useState(true)
151
+ const [preview, setPreview] = useState<Media | null>(null)
152
+ const [copied, setCopied] = useState(false)
153
+ const [stats, setStats] = useState<Stats | null>(null)
154
+ const [openFolderMenu, setOpenFolderMenu] = useState<string | null>(null)
155
+ const [openItemMenu, setOpenItemMenu] = useState<string | null>(null)
156
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
157
+ const [movePopupIds, setMovePopupIds] = useState<string[] | null>(null)
158
+ const [moveFolderName, setMoveFolderName] = useState('')
159
+ useBodyScrollLock(!!preview || movePopupIds !== null)
160
+
161
+ useEffect(() => {
162
+ if (!openFolderMenu && !openItemMenu) return
163
+ function closeMenu(event: PointerEvent) { if (!(event.target as Element).closest('.cms-card-menu')) { setOpenFolderMenu(null); setOpenItemMenu(null) } }
164
+ function closeOnEscape(event: KeyboardEvent) { if (event.key === 'Escape') { setOpenFolderMenu(null); setOpenItemMenu(null) } }
165
+ document.addEventListener('pointerdown', closeMenu)
166
+ document.addEventListener('keydown', closeOnEscape)
167
+ return () => { document.removeEventListener('pointerdown', closeMenu); document.removeEventListener('keydown', closeOnEscape) }
168
+ }, [openFolderMenu, openItemMenu])
169
+
170
+ async function loadStats() {
171
+ const response = await fetch('/api/admin/media/stats')
172
+ if (response.ok) setStats(await response.json())
173
+ }
174
+
175
+ async function goToFolder(slug: string) {
176
+ setView('library')
177
+ setSelectedIds(new Set())
178
+ setLoading(true)
179
+ setStatus('Loading…')
180
+ try {
181
+ const response = await fetch(`/api/admin/media?folder=${encodeURIComponent(slug)}`)
182
+ const data = await response.json()
183
+ if (!response.ok) { setStatus(data.error || 'Could not load media'); return }
184
+ setFolders(data.folders)
185
+ setItems(data.media)
186
+ setLibraryFolder(slug)
187
+ setStatus(`${data.media.length} item${data.media.length === 1 ? '' : 's'}`)
188
+ } finally { setLoading(false) }
189
+ }
190
+
191
+ async function loadFlat(kind: 'recent' | 'starred' | 'trash') {
192
+ setView(kind)
193
+ setSelectedIds(new Set())
194
+ setLoading(true)
195
+ setStatus(kind === 'recent' ? 'Loading recent uploads…' : kind === 'starred' ? 'Loading starred…' : 'Loading trash…')
196
+ try {
197
+ const response = await fetch(`/api/admin/media?view=${kind}`)
198
+ const data = await response.json()
199
+ if (!response.ok) { setStatus(data.error || 'Could not load media'); return }
200
+ setFolders(data.folders)
201
+ setItems(data.media)
202
+ setStatus(`${data.media.length} item${data.media.length === 1 ? '' : 's'}`)
203
+ } finally { setLoading(false) }
204
+ }
205
+
206
+ useEffect(() => { void goToFolder('uploads'); void loadStats() }, [])
207
+
208
+ async function createFolderPrompt() {
209
+ const trimmed = window.prompt('New folder name')?.trim()
210
+ if (!trimmed) return
211
+ const response = await fetch('/api/admin/media', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: trimmed }) })
212
+ const data = await response.json()
213
+ if (!response.ok) { setStatus(data.error || 'Could not create folder'); return }
214
+ await goToFolder(data.slug)
215
+ }
216
+
217
+ async function copyUrl(url: string) { try { await navigator.clipboard.writeText(url); setCopied(true); setTimeout(() => setCopied(false), 1500) } catch { /* clipboard unavailable */ } }
218
+
219
+ async function toggleStar(item: Media) {
220
+ setOpenItemMenu(null)
221
+ const response = await fetch(`/api/admin/media/${item.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ starred: !item.starred }) })
222
+ if (!response.ok) return
223
+ const updated: Media = await response.json()
224
+ setItems((current) => view === 'starred' && !updated.starred ? current.filter((existing) => existing.id !== updated.id) : current.map((existing) => existing.id === updated.id ? updated : existing))
225
+ setPreview((current) => current && current.id === updated.id ? updated : current)
226
+ }
227
+
228
+ async function renameItem(item: Media) {
229
+ setOpenItemMenu(null)
230
+ const next = window.prompt('Rename file', item.filename)?.trim()
231
+ if (!next || next === item.filename) return
232
+ const response = await fetch(`/api/admin/media/${item.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: next }) })
233
+ if (!response.ok) { setStatus('Could not rename file'); return }
234
+ const updated: Media = await response.json()
235
+ setItems((current) => current.map((existing) => existing.id === updated.id ? updated : existing))
236
+ setPreview((current) => current && current.id === updated.id ? updated : current)
237
+ }
238
+
239
+ async function trashItem(item: Media) {
240
+ setOpenItemMenu(null)
241
+ const response = await fetch(`/api/admin/media/${item.id}`, { method: 'DELETE' })
242
+ if (!response.ok) return
243
+ setItems((current) => current.filter((existing) => existing.id !== item.id))
244
+ setPreview((current) => current && current.id === item.id ? null : current)
245
+ void loadStats()
246
+ }
247
+
248
+ async function restoreItem(item: Media) {
249
+ const response = await fetch(`/api/admin/media/${item.id}/restore`, { method: 'POST' })
250
+ if (!response.ok) return
251
+ setItems((current) => current.filter((existing) => existing.id !== item.id))
252
+ setSelectedIds((current) => { const next = new Set(current); next.delete(item.id); return next })
253
+ void loadStats()
254
+ }
255
+
256
+ async function deleteForever(item: Media) {
257
+ if (!window.confirm(`Permanently delete "${item.filename}"? This cannot be undone.`)) return
258
+ const response = await fetch(`/api/admin/media/${item.id}?permanent=1`, { method: 'DELETE' })
259
+ if (!response.ok) return
260
+ setItems((current) => current.filter((existing) => existing.id !== item.id))
261
+ setSelectedIds((current) => { const next = new Set(current); next.delete(item.id); return next })
262
+ void loadStats()
263
+ }
264
+
265
+ function toggleSelect(id: string) {
266
+ setSelectedIds((current) => { const next = new Set(current); next.has(id) ? next.delete(id) : next.add(id); return next })
267
+ }
268
+
269
+ function selectAll() { setSelectedIds(new Set(items.map((item) => item.id))) }
270
+ function clearSelection() { setSelectedIds(new Set()) }
271
+
272
+ async function bulkRestore() {
273
+ if (!selectedIds.size) return
274
+ const ids = [...selectedIds]
275
+ const response = await fetch('/api/admin/media/bulk', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids, action: 'restore' }) })
276
+ if (!response.ok) return
277
+ setItems((current) => current.filter((item) => !ids.includes(item.id)))
278
+ setSelectedIds(new Set())
279
+ void loadStats()
280
+ }
281
+
282
+ async function bulkDeleteForever() {
283
+ if (!selectedIds.size) return
284
+ if (!window.confirm(`Permanently delete ${selectedIds.size} item${selectedIds.size === 1 ? '' : 's'}? This cannot be undone.`)) return
285
+ const ids = [...selectedIds]
286
+ const response = await fetch('/api/admin/media/bulk', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids, action: 'delete' }) })
287
+ if (!response.ok) return
288
+ setItems((current) => current.filter((item) => !ids.includes(item.id)))
289
+ setSelectedIds(new Set())
290
+ void loadStats()
291
+ }
292
+
293
+ async function bulkTrash() {
294
+ if (!selectedIds.size) return
295
+ const ids = [...selectedIds]
296
+ const response = await fetch('/api/admin/media/bulk', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids, action: 'trash' }) })
297
+ if (!response.ok) return
298
+ setItems((current) => current.filter((item) => !ids.includes(item.id)))
299
+ setSelectedIds(new Set())
300
+ void loadStats()
301
+ }
302
+
303
+ async function moveItemsToFolder(ids: string[], targetFolder: string) {
304
+ const response = await fetch('/api/admin/media/bulk', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids, action: 'move', folder: targetFolder }) })
305
+ if (!response.ok) { setStatus('Could not move items'); return }
306
+ setSelectedIds(new Set())
307
+ setMovePopupIds(null)
308
+ setMoveFolderName('')
309
+ refreshCurrentView()
310
+ }
311
+
312
+ async function createFolderAndMove() {
313
+ const trimmed = moveFolderName.trim()
314
+ if (!trimmed || !movePopupIds) return
315
+ const response = await fetch('/api/admin/media', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: trimmed }) })
316
+ const data = await response.json()
317
+ if (!response.ok) { setStatus(data.error || 'Could not create folder'); return }
318
+ await moveItemsToFolder(movePopupIds, data.slug)
319
+ }
320
+
321
+ async function togglePin(folder: Folder) {
322
+ if (folder.slug === 'uploads') return
323
+ const response = await fetch(`/api/admin/media/folders/${folder.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinned: !folder.pinned }) })
324
+ if (!response.ok) return
325
+ const updated: { pinned: boolean } = await response.json()
326
+ setFolders((current) => current.map((item) => item.id === folder.id ? { ...item, pinned: updated.pinned } : item))
327
+ }
328
+
329
+ async function renameFolder(folder: Folder) {
330
+ setOpenFolderMenu(null)
331
+ const next = window.prompt('Rename folder', folder.name)?.trim()
332
+ if (!next || next === folder.name) return
333
+ const response = await fetch(`/api/admin/media/folders/${folder.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: next }) })
334
+ if (!response.ok) { setStatus('Could not rename folder'); return }
335
+ const updated: { name: string } = await response.json()
336
+ setFolders((current) => current.map((item) => item.id === folder.id ? { ...item, name: updated.name } : item))
337
+ }
338
+
339
+ async function deleteFolder(folder: Folder) {
340
+ setOpenFolderMenu(null)
341
+ if (!window.confirm(`Delete "${folder.name}"? Files inside will be moved back to Uploads.`)) return
342
+ const response = await fetch(`/api/admin/media/folders/${folder.id}`, { method: 'DELETE' })
343
+ if (!response.ok) { setStatus('Could not delete folder'); return }
344
+ if (view === 'library' && libraryFolder === folder.slug) void goToFolder('uploads')
345
+ else refreshCurrentView()
346
+ void loadStats()
347
+ }
348
+
349
+ function refreshCurrentView() {
350
+ if (view === 'library') void goToFolder(libraryFolder)
351
+ else void loadFlat(view)
352
+ void loadStats()
353
+ }
354
+
355
+ const currentFolder = folders.find((item) => item.slug === libraryFolder)
356
+ const pinnedFolders = folders.filter((folder) => folder.pinned && folder.slug !== 'uploads').map((folder) => ({ slug: folder.slug, name: folder.name }))
357
+ const mediaContext = {
358
+ view: (view === 'recent' ? 'recent' : view === 'starred' ? 'starred' : view === 'trash' ? 'trash' : 'gallery') as 'gallery' | 'recent' | 'starred' | 'trash',
359
+ onSelect: (next: 'gallery' | 'recent' | 'starred' | 'trash') => { next === 'gallery' ? void goToFolder('uploads') : void loadFlat(next) },
360
+ stats,
361
+ pinnedFolders,
362
+ activeFolder: view === 'library' ? libraryFolder : null,
363
+ onOpenFolder: (slug: string) => void goToFolder(slug),
364
+ }
365
+ const addMediaAction = <MediaUploader folder={view === 'library' ? libraryFolder : 'uploads'} onUploaded={refreshCurrentView} />
366
+
367
+ const activeActions = <>
368
+ <Button type="button" variant="secondary" onClick={clearSelection}>Clear ({selectedIds.size})</Button>
369
+ <Button type="button" variant="secondary" onClick={() => setMovePopupIds([...selectedIds])}>Move to folder</Button>
370
+ <Button type="button" variant="negative" onClick={() => void bulkTrash()}>Delete</Button>
371
+ </>
372
+ const idleActions = <>{addMediaAction}{items.length > 0 && <Button type="button" variant="secondary" onClick={selectAll}>Select all</Button>}</>
373
+ const headerActions = selectedIds.size > 0 ? activeActions : idleActions
374
+ const trashActions = selectedIds.size > 0
375
+ ? <>
376
+ <Button type="button" variant="secondary" onClick={clearSelection}>Clear selection</Button>
377
+ <Button type="button" variant="secondary" onClick={() => void bulkRestore()}>Restore ({selectedIds.size})</Button>
378
+ <Button type="button" variant="negative" onClick={() => void bulkDeleteForever()}>Delete permanently ({selectedIds.size})</Button>
379
+ </>
380
+ : items.length > 0
381
+ ? <Button type="button" variant="secondary" onClick={selectAll}>Select all</Button>
382
+ : undefined
383
+
384
+ const folderStrip = view !== 'trash' ? <div className="cms-folder-strip-row">
385
+ {folders.map((folder) => (
386
+ <div
387
+ key={folder.id}
388
+ role="button"
389
+ tabIndex={0}
390
+ className={`cms-folder-chip ${view === 'library' && libraryFolder === folder.slug ? 'active' : ''}`}
391
+ onClick={() => void goToFolder(folder.slug)}
392
+ onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); void goToFolder(folder.slug) } }}
393
+ >
394
+ {folder.slug !== 'uploads' && <FolderMenu
395
+ folder={folder}
396
+ open={openFolderMenu === folder.id}
397
+ onToggle={() => setOpenFolderMenu((current) => current === folder.id ? null : folder.id)}
398
+ onPin={() => { setOpenFolderMenu(null); void togglePin(folder) }}
399
+ onRename={() => void renameFolder(folder)}
400
+ onDelete={() => void deleteFolder(folder)}
401
+ />}
402
+ <FolderIcon size={30} />
403
+ <strong>{folder.name}</strong>
404
+ <span className="cms-folder-chip-count">{folder.count}</span>
405
+ </div>
406
+ ))}
407
+ <button type="button" className="cms-folder-chip-new" onClick={() => void createFolderPrompt()}>
408
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg>
409
+ <span>New folder</span>
410
+ </button>
411
+ </div> : null
412
+
413
+ const mediaGrid = <section className="cms-media-grid">
414
+ {loading ? <><div className="cms-skeleton-card" /><div className="cms-skeleton-card" /><div className="cms-skeleton-card" /><div className="cms-skeleton-card" /><div className="cms-skeleton-card" /><div className="cms-skeleton-card" /></> : <>
415
+ {items.map((item) => <CardButton
416
+ className="cms-media-item"
417
+ key={item.id}
418
+ onClick={() => view === 'trash' ? toggleSelect(item.id) : setPreview(item)}
419
+ >
420
+ {view === 'trash' ? <>
421
+ <SelectCheckbox checked={selectedIds.has(item.id)} onToggle={() => toggleSelect(item.id)} />
422
+ <RestoreButton onRestore={() => void restoreItem(item)} />
423
+ <DeleteForeverButton onDelete={() => void deleteForever(item)} />
424
+ </> : <>
425
+ <SelectCheckbox checked={selectedIds.has(item.id)} onToggle={() => toggleSelect(item.id)} />
426
+ <ItemMenu
427
+ item={item}
428
+ open={openItemMenu === item.id}
429
+ onToggle={() => setOpenItemMenu((current) => current === item.id ? null : item.id)}
430
+ onStar={() => void toggleStar(item)}
431
+ onRename={() => void renameItem(item)}
432
+ onMove={() => { setOpenItemMenu(null); setMovePopupIds([item.id]) }}
433
+ onTrash={() => void trashItem(item)}
434
+ />
435
+ </>}
436
+ <MediaThumb item={item} />
437
+ <p>{item.filename}</p>
438
+ </CardButton>)}
439
+ {!items.length && <p className="cms-empty-state">{view === 'starred' ? 'No starred media yet.' : view === 'recent' ? 'No media uploaded yet.' : view === 'trash' ? 'Trash is empty.' : 'No media in this folder yet.'}</p>}
440
+ </>}
441
+ </section>
442
+
443
+ const title = view === 'recent' ? 'Recent uploads' : view === 'starred' ? 'Starred' : view === 'trash' ? 'Trash' : currentFolder?.name ?? 'Files'
444
+
445
+ return <main className="cms-dashboard"><CmsSidebar active="media" userName={userName} mediaContext={mediaContext} /><section className="cms-content">
446
+ <EditorHeader compact title={title} meta={status} actions={view === 'trash' ? trashActions : headerActions} />
447
+ {folderStrip}
448
+ {mediaGrid}
449
+ </section>
450
+ {preview && <Portal><div className="media-preview-modal" role="dialog" aria-modal="true" aria-label="Media preview" onClick={() => setPreview(null)}>
451
+ <div className="media-preview" onClick={(event) => event.stopPropagation()}>
452
+ <div className="media-preview-stage">
453
+ {fileKind(preview.mimeType) === 'image' ? <img src={preview.url} alt={preview.filename} />
454
+ : fileKind(preview.mimeType) === 'video' ? <video src={preview.url} controls />
455
+ : fileKind(preview.mimeType) === 'audio' ? <div className="media-preview-audio"><FileTypeIcon mimeType={preview.mimeType} /><audio src={preview.url} controls /></div>
456
+ : <div className="media-preview-file"><FileTypeIcon mimeType={preview.mimeType} /><span>{preview.filename}</span></div>}
457
+ </div>
458
+ <div className="media-preview-side">
459
+ <header><strong>{preview.filename}</strong><CloseButton onClick={() => setPreview(null)} /></header>
460
+ <dl className="media-preview-meta"><dt>Type</dt><dd>{preview.mimeType}</dd><dt>Size</dt><dd>{formatSize(preview.size)}</dd><dt>Folder</dt><dd>{folders.find((folder) => folder.slug === preview.folder)?.name ?? preview.folder}</dd></dl>
461
+ <div className="media-preview-actions">
462
+ <input readOnly value={preview.url} onFocus={(event) => event.target.select()} />
463
+ <Button type="button" variant="secondary" onClick={() => copyUrl(preview.url)}>{copied ? 'Copied ✓' : 'Copy URL'}</Button>
464
+ <Button type="button" variant="secondary" onClick={() => void toggleStar(preview)}>{preview.starred ? '★ Starred' : '☆ Star'}</Button>
465
+ <Button type="button" variant="secondary" onClick={() => void renameItem(preview)}>Rename</Button>
466
+ <Button type="button" variant="secondary" onClick={() => setMovePopupIds([preview.id])}>Move to folder</Button>
467
+ <Button type="button" variant="negative" onClick={() => void trashItem(preview)}>Move to trash</Button>
468
+ <Button variant="primary" href={preview.url} target="_blank" rel="noreferrer" icon="↗">Open</Button>
469
+ </div>
470
+ </div>
471
+ </div>
472
+ </div></Portal>}
473
+ {movePopupIds && <MoveToFolderModal
474
+ folders={folders}
475
+ count={movePopupIds.length}
476
+ name={moveFolderName}
477
+ onNameChange={setMoveFolderName}
478
+ onMove={(slug) => void moveItemsToFolder(movePopupIds, slug)}
479
+ onCreateAndMove={() => void createFolderAndMove()}
480
+ onClose={() => { setMovePopupIds(null); setMoveFolderName('') }}
481
+ />}
482
+ </main>
483
+ }
@@ -0,0 +1,55 @@
1
+ 'use client'
2
+
3
+ import { useState, type ChangeEvent } from 'react'
4
+ import { useToast } from './Toast'
5
+
6
+ type Props = { folder: string; onUploaded: () => void; label?: string }
7
+
8
+ const ACCEPT = 'image/jpeg,image/png,image/webp,image/gif,image/svg+xml,video/mp4,video/webm,video/quicktime,audio/mpeg,audio/wav,audio/ogg,application/pdf'
9
+
10
+ function Spinner() {
11
+ return <svg className="cms-upload-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M12 3a9 9 0 1 0 9 9" /></svg>
12
+ }
13
+
14
+ export function MediaUploader({ folder, onUploaded, label = '+ Add media' }: Props) {
15
+ const toast = useToast()
16
+ const [uploading, setUploading] = useState(false)
17
+ const [justUploaded, setJustUploaded] = useState(false)
18
+
19
+ async function handleFiles(event: ChangeEvent<HTMLInputElement>) {
20
+ const files = Array.from(event.target.files ?? [])
21
+ event.target.value = ''
22
+ if (!files.length) return
23
+ setUploading(true)
24
+ setJustUploaded(false)
25
+ let failures = 0
26
+ let lastError = ''
27
+ for (const file of files) {
28
+ const form = new FormData()
29
+ form.append('file', file)
30
+ form.append('folder', folder)
31
+ try {
32
+ const response = await fetch('/api/admin/upload', { method: 'POST', body: form })
33
+ if (!response.ok) { failures += 1; lastError = (await response.json().catch(() => null))?.error || 'Upload failed' }
34
+ } catch { failures += 1; lastError = 'Upload failed' }
35
+ }
36
+ setUploading(false)
37
+ const succeeded = files.length - failures
38
+ if (succeeded > 0) {
39
+ toast.success(succeeded === 1 ? 'File uploaded.' : `${succeeded} files uploaded.`)
40
+ setJustUploaded(true)
41
+ setTimeout(() => setJustUploaded(false), 2000)
42
+ }
43
+ if (failures > 0) toast.error(files.length === 1 ? lastError : `${failures} of ${files.length} files failed — ${lastError}`)
44
+ onUploaded()
45
+ }
46
+
47
+ return <div className="image-uploader">
48
+ <div className="image-upload-actions">
49
+ <label className={`cms-upload-button ${uploading ? 'is-uploading' : ''} ${justUploaded ? 'is-success' : ''}`}>
50
+ {uploading ? <><Spinner /> Uploading…</> : justUploaded ? <>✓ Uploaded</> : label}
51
+ <input type="file" multiple accept={ACCEPT} disabled={uploading} onChange={(event) => void handleFiles(event)} />
52
+ </label>
53
+ </div>
54
+ </div>
55
+ }
@@ -0,0 +1,10 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState, type ReactNode } from 'react'
4
+ import { createPortal } from 'react-dom'
5
+
6
+ export function Portal({ children }: { children: ReactNode }) {
7
+ const [mounted, setMounted] = useState(false)
8
+ useEffect(() => { setMounted(true) }, [])
9
+ return mounted ? createPortal(children, document.body) : null
10
+ }