Add local vs R2 destination picker for admin asset uploads; v2.0.39
Publish Container / docker (push) Successful in 14m12s
Publish Container / docker (push) Successful in 14m12s
Upload dropdown lets you choose R2 folder (uploads/images/guides/finished) or local server disk. Asset table shows location column for each file. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
4e6449f3a7
commit
c79589b311
3 files changed
+35
-8
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.38",
|
"version": "2.0.39",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -37,10 +37,12 @@ export function register(app) {
|
|||||||
const filename = typeof req.body?.filename === 'string' ? req.body.filename : ''
|
const filename = typeof req.body?.filename === 'string' ? req.body.filename : ''
|
||||||
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
|
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
|
||||||
const rawFolder = typeof req.body?.folder === 'string' ? req.body.folder : 'uploads'
|
const rawFolder = typeof req.body?.folder === 'string' ? req.body.folder : 'uploads'
|
||||||
|
const rawStorage = typeof req.body?.storage === 'string' ? req.body.storage : 'r2'
|
||||||
const ext = inferImageExtensionFromDataUrl(dataUrl)
|
const ext = inferImageExtensionFromDataUrl(dataUrl)
|
||||||
|
|
||||||
const ALLOWED_FOLDERS = ['uploads', 'images', 'guides', 'finished']
|
const ALLOWED_FOLDERS = ['uploads', 'images', 'guides', 'finished']
|
||||||
const folder = ALLOWED_FOLDERS.includes(rawFolder) ? rawFolder : 'uploads'
|
const folder = ALLOWED_FOLDERS.includes(rawFolder) ? rawFolder : 'uploads'
|
||||||
|
const useLocal = rawStorage === 'local' || !isR2Configured()
|
||||||
|
|
||||||
if (!ext) {
|
if (!ext) {
|
||||||
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, GIF, PDF, DOC, or DOCX data URL.' })
|
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, GIF, PDF, DOC, or DOCX data URL.' })
|
||||||
@@ -58,20 +60,20 @@ export function register(app) {
|
|||||||
const finalName = `${baseName}-${Date.now()}${ext}`
|
const finalName = `${baseName}-${Date.now()}${ext}`
|
||||||
|
|
||||||
let url
|
let url
|
||||||
if (isR2Configured()) {
|
if (useLocal) {
|
||||||
const contentType = dataUrl.match(/^data:([^;]+);base64,/)?.[1] ?? 'application/octet-stream'
|
|
||||||
url = await uploadToR2(buffer, `${folder}/${finalName}`, contentType)
|
|
||||||
} else {
|
|
||||||
await mkdir(UPLOADS_DIR, { recursive: true })
|
await mkdir(UPLOADS_DIR, { recursive: true })
|
||||||
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
|
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
|
||||||
url = `/uploads/${finalName}`
|
url = `/uploads/${finalName}`
|
||||||
|
} else {
|
||||||
|
const contentType = dataUrl.match(/^data:([^;]+);base64,/)?.[1] ?? 'application/octet-stream'
|
||||||
|
url = await uploadToR2(buffer, `${folder}/${finalName}`, contentType)
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = await readUploadsMetadata()
|
const metadata = await readUploadsMetadata()
|
||||||
metadata[finalName] = { tags: [], url, folder }
|
metadata[finalName] = { tags: [], url, folder: useLocal ? 'local' : folder }
|
||||||
await writeUploadsMetadata(metadata)
|
await writeUploadsMetadata(metadata)
|
||||||
|
|
||||||
res.json({ ok: true, asset: { filename: finalName, url, folder } })
|
res.json({ ok: true, asset: { filename: finalName, url, folder: useLocal ? 'local' : folder, storage: useLocal ? 'local' : 'r2' } })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[admin-assets] upload failed:', err)
|
console.error('[admin-assets] upload failed:', err)
|
||||||
res.status(500).json({ message: 'Upload failed.' })
|
res.status(500).json({ message: 'Upload failed.' })
|
||||||
|
|||||||
+26
-1
@@ -1553,6 +1553,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
const [assetTagEdits, setAssetTagEdits] = useState<Record<string, string>>({})
|
const [assetTagEdits, setAssetTagEdits] = useState<Record<string, string>>({})
|
||||||
const [opsStatus, setOpsStatus] = useState<OpsStatus | null>(null)
|
const [opsStatus, setOpsStatus] = useState<OpsStatus | null>(null)
|
||||||
const [assetUploadPending, setAssetUploadPending] = useState(false)
|
const [assetUploadPending, setAssetUploadPending] = useState(false)
|
||||||
|
const [assetUploadDest, setAssetUploadDest] = useState<string>('r2:uploads')
|
||||||
|
|
||||||
const [endSeriesOpen, setEndSeriesOpen] = useState(false)
|
const [endSeriesOpen, setEndSeriesOpen] = useState(false)
|
||||||
const [endSeriesNewTitle, setEndSeriesNewTitle] = useState('')
|
const [endSeriesNewTitle, setEndSeriesNewTitle] = useState('')
|
||||||
@@ -2196,10 +2197,13 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [storage, folder] = assetUploadDest === 'local'
|
||||||
|
? ['local', 'uploads']
|
||||||
|
: ['r2', assetUploadDest.replace('r2:', '')]
|
||||||
const res = await fetch('/api/admin-assets', {
|
const res = await fetch('/api/admin-assets', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ filename: file.name, dataUrl }),
|
body: JSON.stringify({ filename: file.name, dataUrl, storage, folder }),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
@@ -5781,6 +5785,19 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|||||||
<p>Upload, tag, and reuse hosted images from this domain.</p>
|
<p>Upload, tag, and reuse hosted images from this domain.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-actions admin-actions--maintenance">
|
<div className="admin-actions admin-actions--maintenance">
|
||||||
|
<select
|
||||||
|
value={assetUploadDest}
|
||||||
|
onChange={e => setAssetUploadDest(e.target.value)}
|
||||||
|
disabled={assetUploadPending}
|
||||||
|
className="admin-select"
|
||||||
|
style={{ marginRight: '0.5rem' }}
|
||||||
|
>
|
||||||
|
<option value="r2:uploads">R2 – uploads/</option>
|
||||||
|
<option value="r2:images">R2 – images/</option>
|
||||||
|
<option value="r2:guides">R2 – guides/</option>
|
||||||
|
<option value="r2:finished">R2 – finished/</option>
|
||||||
|
<option value="local">Local (server disk)</option>
|
||||||
|
</select>
|
||||||
<label className="btn-admin-reset" style={{ cursor: 'pointer' }}>
|
<label className="btn-admin-reset" style={{ cursor: 'pointer' }}>
|
||||||
{assetUploadPending ? 'Uploading…' : 'Upload Image or PDF'}
|
{assetUploadPending ? 'Uploading…' : 'Upload Image or PDF'}
|
||||||
<input type="file" accept="image/*,.pdf" style={{ display: 'none' }} onChange={handleAssetUpload} disabled={assetUploadPending} />
|
<input type="file" accept="image/*,.pdf" style={{ display: 'none' }} onChange={handleAssetUpload} disabled={assetUploadPending} />
|
||||||
@@ -5794,6 +5811,7 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Preview</th>
|
<th>Preview</th>
|
||||||
<th>Filename</th>
|
<th>Filename</th>
|
||||||
|
<th>Location</th>
|
||||||
<th>Size</th>
|
<th>Size</th>
|
||||||
<th>Updated</th>
|
<th>Updated</th>
|
||||||
<th>Tags</th>
|
<th>Tags</th>
|
||||||
@@ -5809,6 +5827,13 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|||||||
: <span>PDF/File</span>}
|
: <span>PDF/File</span>}
|
||||||
</td>
|
</td>
|
||||||
<td>{asset.filename}</td>
|
<td>{asset.filename}</td>
|
||||||
|
<td style={{ whiteSpace: 'nowrap', fontSize: '0.8em', opacity: 0.75 }}>
|
||||||
|
{(asset as { folder?: string }).folder === 'local'
|
||||||
|
? 'Local'
|
||||||
|
: (asset as { folder?: string }).folder
|
||||||
|
? `R2: ${(asset as { folder?: string }).folder}/`
|
||||||
|
: 'R2'}
|
||||||
|
</td>
|
||||||
<td>{(asset.sizeBytes / 1024).toFixed(1)} KB</td>
|
<td>{(asset.sizeBytes / 1024).toFixed(1)} KB</td>
|
||||||
<td>{formatDate(asset.updatedAt)}</td>
|
<td>{formatDate(asset.updatedAt)}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
Reference in new issue
Block a user