project-bifrost-platform/src/admin/components/fields/ImageUploadField.astro
Jonathan Hvid c509dc66ed feat(events): event photo upload + photo-as-background hero card
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>
2026-06-10 17:18:23 +02:00

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>