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.
- package/README.md +186 -0
- package/drizzle.kilo.config.ts +17 -0
- package/package.json +97 -0
- package/src/admin/AiSettingsEditor.tsx +48 -0
- package/src/admin/AiWritingAssistant.tsx +159 -0
- package/src/admin/ApiTokensEditor.tsx +69 -0
- package/src/admin/ArrayField.tsx +118 -0
- package/src/admin/BlocksField.tsx +131 -0
- package/src/admin/Button.tsx +31 -0
- package/src/admin/CloseButton.tsx +18 -0
- package/src/admin/CmsLocalesProvider.tsx +19 -0
- package/src/admin/CmsSidebar.tsx +147 -0
- package/src/admin/CmsSiteSettingsProvider.tsx +30 -0
- package/src/admin/CmsThemeProvider.tsx +100 -0
- package/src/admin/CollectionListEditor.tsx +278 -0
- package/src/admin/CollectionRecordEditor.tsx +420 -0
- package/src/admin/ContentEditorForm.tsx +21 -0
- package/src/admin/ContentViewSettings.tsx +55 -0
- package/src/admin/DashboardBuilder.tsx +284 -0
- package/src/admin/DragHandle.tsx +3 -0
- package/src/admin/DynamicField.tsx +517 -0
- package/src/admin/EditorHeader.tsx +13 -0
- package/src/admin/EditorSkeleton.tsx +27 -0
- package/src/admin/EmailSettingsEditor.tsx +80 -0
- package/src/admin/Field.tsx +215 -0
- package/src/admin/FilterBuilder.tsx +62 -0
- package/src/admin/GroupField.tsx +61 -0
- package/src/admin/ImageUploader.tsx +198 -0
- package/src/admin/ListingSettingsModal.tsx +68 -0
- package/src/admin/ListingView.tsx +123 -0
- package/src/admin/LocalesEditor.tsx +86 -0
- package/src/admin/LocalizedField.tsx +46 -0
- package/src/admin/MapPicker.tsx +53 -0
- package/src/admin/MediaLibrary.tsx +483 -0
- package/src/admin/MediaUploader.tsx +55 -0
- package/src/admin/Portal.tsx +10 -0
- package/src/admin/PublishingPanel.tsx +102 -0
- package/src/admin/RecordMeta.tsx +24 -0
- package/src/admin/ReviewQueueEditor.tsx +103 -0
- package/src/admin/RichTextEditor.tsx +49 -0
- package/src/admin/RoleEditor.tsx +121 -0
- package/src/admin/RolesEditor.tsx +42 -0
- package/src/admin/RowActionsMenu.tsx +64 -0
- package/src/admin/ScheduledEditor.tsx +155 -0
- package/src/admin/SeoSettingsEditor.tsx +53 -0
- package/src/admin/SingleRecordEditor.tsx +215 -0
- package/src/admin/SiteSettingsEditor.tsx +96 -0
- package/src/admin/Toast.tsx +45 -0
- package/src/admin/UserEditor.tsx +64 -0
- package/src/admin/UsersEditor.tsx +37 -0
- package/src/admin/WebhooksEditor.tsx +115 -0
- package/src/admin/admin.css +6637 -0
- package/src/admin/globals.css +14 -0
- package/src/admin/pages/admin-index.tsx +5 -0
- package/src/admin/pages/ai-settings.tsx +9 -0
- package/src/admin/pages/api-tokens.tsx +10 -0
- package/src/admin/pages/collection-list.tsx +22 -0
- package/src/admin/pages/collection-record.tsx +31 -0
- package/src/admin/pages/dashboard.tsx +10 -0
- package/src/admin/pages/email-settings.tsx +10 -0
- package/src/admin/pages/layout.tsx +24 -0
- package/src/admin/pages/locales-settings.tsx +10 -0
- package/src/admin/pages/login.tsx +25 -0
- package/src/admin/pages/media.tsx +9 -0
- package/src/admin/pages/review-queue.tsx +20 -0
- package/src/admin/pages/role-edit.tsx +11 -0
- package/src/admin/pages/roles.tsx +10 -0
- package/src/admin/pages/scheduled.tsx +17 -0
- package/src/admin/pages/seo-settings.tsx +9 -0
- package/src/admin/pages/setup-admin.tsx +76 -0
- package/src/admin/pages/single-record.tsx +24 -0
- package/src/admin/pages/site-settings.tsx +9 -0
- package/src/admin/pages/user-edit.tsx +11 -0
- package/src/admin/pages/user-new.tsx +10 -0
- package/src/admin/pages/users.tsx +10 -0
- package/src/admin/pages/webhooks-settings.tsx +10 -0
- package/src/admin/useKeyedRows.ts +35 -0
- package/src/ai-config.ts +35 -0
- package/src/ai-secrets.ts +17 -0
- package/src/ai-types.ts +10 -0
- package/src/ai-writer.ts +84 -0
- package/src/api-tokens.ts +25 -0
- package/src/auth-client.ts +5 -0
- package/src/auth.ts +33 -0
- package/src/calendar-grid.ts +12 -0
- package/src/cli/index.mjs +149 -0
- package/src/cms-appearance.ts +55 -0
- package/src/collections/content-view.ts +69 -0
- package/src/collections/define.ts +17 -0
- package/src/collections/filters.ts +92 -0
- package/src/collections/index.ts +11 -0
- package/src/collections/join.ts +64 -0
- package/src/collections/locale.ts +22 -0
- package/src/collections/query.ts +160 -0
- package/src/collections/registry.ts +88 -0
- package/src/collections/schedule.ts +12 -0
- package/src/collections/server.ts +12 -0
- package/src/collections/single.ts +26 -0
- package/src/collections/table.ts +14 -0
- package/src/collections/types.ts +336 -0
- package/src/collections/validation.ts +217 -0
- package/src/collections/values.ts +181 -0
- package/src/collections/workflow.ts +90 -0
- package/src/config.ts +34 -0
- package/src/content-types.ts +218 -0
- package/src/dashboard.ts +39 -0
- package/src/db.ts +50 -0
- package/src/email.ts +39 -0
- package/src/format.ts +4 -0
- package/src/listQuery.ts +23 -0
- package/src/locale-settings.ts +23 -0
- package/src/media-storage.ts +19 -0
- package/src/permissions.ts +37 -0
- package/src/publishing-workflow-db.ts +11 -0
- package/src/publishing-workflow.ts +20 -0
- package/src/richtext.ts +5 -0
- package/src/routes/admin/ai/settings/route.ts +20 -0
- package/src/routes/admin/ai/write/route.ts +21 -0
- package/src/routes/admin/api-tokens/[id]/route.ts +12 -0
- package/src/routes/admin/api-tokens/route.ts +26 -0
- package/src/routes/admin/collections/[collection]/[id]/approve/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/[id]/duplicate/route.ts +36 -0
- package/src/routes/admin/collections/[collection]/[id]/restore/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/[id]/review/route.ts +28 -0
- package/src/routes/admin/collections/[collection]/[id]/route.ts +110 -0
- package/src/routes/admin/collections/[collection]/[id]/schedule/route.ts +42 -0
- package/src/routes/admin/collections/[collection]/[id]/submit-review/route.ts +23 -0
- package/src/routes/admin/collections/[collection]/[id]/unschedule/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/bulk/route.ts +61 -0
- package/src/routes/admin/collections/[collection]/options/route.ts +38 -0
- package/src/routes/admin/collections/[collection]/reorder/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/route.ts +88 -0
- package/src/routes/admin/collections/route.ts +13 -0
- package/src/routes/admin/content-views/[type]/route.ts +25 -0
- package/src/routes/admin/dashboard/data/route.ts +39 -0
- package/src/routes/admin/dashboard/route.ts +30 -0
- package/src/routes/admin/email-settings/route.ts +49 -0
- package/src/routes/admin/email-settings/test/route.ts +18 -0
- package/src/routes/admin/locales/[code]/route.ts +38 -0
- package/src/routes/admin/locales/reorder/route.ts +18 -0
- package/src/routes/admin/locales/route.ts +46 -0
- package/src/routes/admin/media/[id]/restore/route.ts +13 -0
- package/src/routes/admin/media/[id]/route.ts +56 -0
- package/src/routes/admin/media/bulk/route.ts +46 -0
- package/src/routes/admin/media/folders/[id]/route.ts +34 -0
- package/src/routes/admin/media/route.ts +41 -0
- package/src/routes/admin/media/stats/route.ts +14 -0
- package/src/routes/admin/roles/[id]/route.ts +43 -0
- package/src/routes/admin/roles/route.ts +34 -0
- package/src/routes/admin/settings/route.ts +45 -0
- package/src/routes/admin/single/[type]/route.ts +54 -0
- package/src/routes/admin/singles/route.ts +15 -0
- package/src/routes/admin/upload/route.ts +55 -0
- package/src/routes/admin/users/[id]/route.ts +24 -0
- package/src/routes/admin/users/route.ts +24 -0
- package/src/routes/admin/webhooks/[id]/route.ts +38 -0
- package/src/routes/admin/webhooks/route.ts +26 -0
- package/src/routes/cron/publish-scheduled.ts +37 -0
- package/src/routes/setup/admin.ts +19 -0
- package/src/schema/admin-ui.ts +18 -0
- package/src/schema/auth.ts +49 -0
- package/src/schema/developer-tools.ts +44 -0
- package/src/schema/helpers.ts +35 -0
- package/src/schema/index.ts +33 -0
- package/src/schema/locales.ts +13 -0
- package/src/schema/media.ts +22 -0
- package/src/schema/rbac.ts +11 -0
- package/src/schema/settings.ts +28 -0
- package/src/secrets.ts +26 -0
- package/src/seo.ts +69 -0
- package/src/storage/index.ts +20 -0
- package/src/storage/local.ts +27 -0
- package/src/storage/s3-compatible.ts +31 -0
- package/src/storage/types.ts +8 -0
- package/src/storage/vercel-blob.ts +17 -0
- package/src/useBodyScrollLock.ts +12 -0
- package/src/useLocalStorageState.ts +26 -0
- package/src/webhook-events.ts +12 -0
- 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 & 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
|
+
}
|