Admin can now upload a png/jpg event photo (SPEC §8 exception added): - new image-upload admin field kind with live preview, uploading via POST /api/admin/upload (fenja-only, type + 5MB validation); - files stored under data/uploads (gitignored, BIFROST_UPLOAD_DIR overridable) and served by GET /uploads/[file] with a traversal guard. Reworks the /pulse event card: the greeting moved inside a taller box, the "next gathering" label sits above the date + title, and the photo renders as a top-right background that blends into the indigo via gradient masks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
134 lines
4.2 KiB
Text
134 lines
4.2 KiB
Text
---
|
|
import type { ImageUploadField } from '../../resource-types';
|
|
|
|
interface Props {
|
|
field: ImageUploadField;
|
|
value: unknown;
|
|
}
|
|
|
|
const { field, value } = Astro.props;
|
|
const v = value == null ? '' : String(value);
|
|
---
|
|
|
|
<div class="bs-imgup" data-imgup>
|
|
<!-- Stored value: the image URL. Set by upload, or pasteable directly. -->
|
|
<input
|
|
type="text"
|
|
id={`f-${field.key}`}
|
|
name={field.key}
|
|
class="bs-input"
|
|
value={v}
|
|
placeholder="Upload below, or paste an image URL"
|
|
maxlength={field.maxLength}
|
|
data-imgup-value
|
|
/>
|
|
|
|
<div class="bs-imgup-row">
|
|
<label class="bs-imgup-pick">
|
|
Choose image…
|
|
<input type="file" accept="image/png,image/jpeg" data-imgup-file hidden />
|
|
</label>
|
|
<button type="button" class="bs-imgup-clear" data-imgup-clear hidden>Remove</button>
|
|
<span class="bs-imgup-status" data-imgup-status aria-live="polite"></span>
|
|
</div>
|
|
|
|
<div class="bs-imgup-preview" data-imgup-preview hidden>
|
|
<img src={v} alt="" data-imgup-img />
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.bs-imgup { display: flex; flex-direction: column; gap: var(--space-2); }
|
|
.bs-imgup-row { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
|
|
.bs-imgup-pick {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
font-size: var(--text-label-md);
|
|
padding: var(--space-2) var(--space-3);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--surface-container);
|
|
color: var(--on-surface);
|
|
cursor: pointer;
|
|
}
|
|
.bs-imgup-pick:hover { background: var(--surface-container-high); }
|
|
.bs-imgup-clear {
|
|
background: none;
|
|
border: none;
|
|
color: var(--on-surface-muted);
|
|
font-size: var(--text-label-md);
|
|
cursor: pointer;
|
|
padding: var(--space-2);
|
|
}
|
|
.bs-imgup-clear:hover { color: var(--color-danger); }
|
|
.bs-imgup-status { font-size: var(--text-label-md); color: var(--on-surface-variant); }
|
|
.bs-imgup-status.is-error { color: var(--color-danger); }
|
|
.bs-imgup-preview {
|
|
border-radius: var(--radius-md);
|
|
overflow: hidden;
|
|
max-width: 280px;
|
|
background: var(--surface-container);
|
|
}
|
|
.bs-imgup-preview img { display: block; width: 100%; height: auto; }
|
|
</style>
|
|
|
|
<script>
|
|
document.querySelectorAll<HTMLElement>('[data-imgup]').forEach((root) => {
|
|
const valueInput = root.querySelector<HTMLInputElement>('[data-imgup-value]');
|
|
const fileInput = root.querySelector<HTMLInputElement>('[data-imgup-file]');
|
|
const clearBtn = root.querySelector<HTMLButtonElement>('[data-imgup-clear]');
|
|
const status = root.querySelector<HTMLElement>('[data-imgup-status]');
|
|
const preview = root.querySelector<HTMLElement>('[data-imgup-preview]');
|
|
const img = root.querySelector<HTMLImageElement>('[data-imgup-img]');
|
|
if (!valueInput || !fileInput) return;
|
|
|
|
function setStatus(msg: string, isError = false) {
|
|
if (!status) return;
|
|
status.textContent = msg;
|
|
status.classList.toggle('is-error', isError);
|
|
}
|
|
|
|
function syncPreview() {
|
|
const url = valueInput!.value.trim();
|
|
const has = url.length > 0;
|
|
if (img && has) img.src = url;
|
|
preview?.toggleAttribute('hidden', !has);
|
|
clearBtn?.toggleAttribute('hidden', !has);
|
|
}
|
|
|
|
syncPreview();
|
|
valueInput.addEventListener('input', syncPreview);
|
|
|
|
clearBtn?.addEventListener('click', () => {
|
|
valueInput.value = '';
|
|
fileInput.value = '';
|
|
setStatus('');
|
|
syncPreview();
|
|
});
|
|
|
|
fileInput.addEventListener('change', async () => {
|
|
const file = fileInput.files?.[0];
|
|
if (!file) return;
|
|
if (!['image/png', 'image/jpeg'].includes(file.type)) {
|
|
setStatus('Only PNG or JPG images are allowed.', true);
|
|
fileInput.value = '';
|
|
return;
|
|
}
|
|
setStatus('Uploading…');
|
|
try {
|
|
const body = new FormData();
|
|
body.append('file', file);
|
|
const res = await fetch('/api/admin/upload', { method: 'POST', body });
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok || !data.url) {
|
|
setStatus(data.error ?? 'Upload failed.', true);
|
|
return;
|
|
}
|
|
valueInput.value = data.url;
|
|
setStatus('Uploaded ✓');
|
|
syncPreview();
|
|
} catch {
|
|
setStatus('Upload failed. Try again.', true);
|
|
}
|
|
});
|
|
});
|
|
</script>
|