Index: src/assets/js/ward.js
===================================================================
--- src/assets/js/ward.js	(revision 5412cbbcf42cb02fb3fb6d67ef433290c95bb38c)
+++ src/assets/js/ward.js	(revision 2086ea255ae8ea5a474fc805eda10bd71146c947)
@@ -423,5 +423,97 @@
 }
 
-/** De controle kost per guardian een remote fetch, dus hij komt ná het tekenen. *//** De controle kost per guardian een remote fetch, dus hij komt ná het tekenen. */
+/**
+ * De composer: schrijven met foto, geluid of video.
+ *
+ * De media gaan eerst apart naar /ward/api/upload (dezelfde bak als de app),
+ * en de post draagt daarna alleen de url mee. Zo blijft de post-payload klein
+ * en kan de server elke upload los valideren en verrijken (poster, waveform).
+ */
+function wireComposer() {
+  const dlg = document.getElementById('compose-dialog');
+  const openBtn = document.getElementById('compose-open');
+  const text = document.getElementById('compose-text');
+  const mediaBox = document.getElementById('compose-media');
+  const status = document.getElementById('compose-status');
+  const sendBtn = document.getElementById('compose-send');
+  let media = [];   // { url, mediaType, name }
+  let uploading = false;
+
+  function reset() {
+    text.value = '';
+    media = [];
+    mediaBox.replaceChildren();
+    status.textContent = '';
+    updateSend();
+  }
+  function updateSend() {
+    // Een lege post kan niet: tekst OF media, en nooit tijdens een upload.
+    sendBtn.disabled = (!text.value.trim() && !media.length) || uploading;
+  }
+  function renderMedia() {
+    mediaBox.replaceChildren();
+    media.forEach((m, i) => {
+      const chip = el('div', 'media-chip');
+      if (m.mediaType.startsWith('image/')) {
+        const img = el('img'); img.src = m.url; img.alt = m.name || ''; chip.append(img);
+      } else {
+        chip.append(el('span', 'kind', m.mediaType.startsWith('audio/') ? '🎤' : '🎬'));
+        chip.append(el('span', 'name', m.name || ''));
+      }
+      const x = el('button', 'drop', '✕'); x.type = 'button';
+      x.addEventListener('click', () => { media.splice(i, 1); renderMedia(); updateSend(); });
+      chip.append(x);
+      mediaBox.append(chip);
+    });
+  }
+
+  async function upload(file) {
+    if (media.length >= 4) return;
+    // 32 MB is de servergrens; hier alvast vangen scheelt een mislukte upload.
+    if (file.size > 32 * 1024 * 1024) { status.textContent = state.strings.tooBig; return; }
+    uploading = true; status.textContent = state.strings.uploading; updateSend();
+    try {
+      const fd = new FormData();
+      fd.append('file', file);
+      const r = await fetch('/ward/api/upload', { method: 'POST', body: fd });
+      if (r.ok) { media.push(await r.json()); renderMedia(); status.textContent = ''; }
+      else status.textContent = (await r.json().catch(() => ({}))).error === 'media_type'
+        ? 'Dat bestandstype kan niet.' : 'Uploaden lukte niet.';
+    } catch { status.textContent = 'Uploaden lukte niet.'; }
+    uploading = false; updateSend();
+  }
+
+  openBtn.addEventListener('click', () => { reset(); dlg.showModal(); });
+  document.getElementById('compose-cancel').addEventListener('click', () => dlg.close());
+  text.addEventListener('input', updateSend);
+
+  dlg.querySelectorAll('input[type=file]').forEach((inp) => {
+    inp.addEventListener('change', () => {
+      if (inp.files[0]) upload(inp.files[0]);
+      inp.value = '';   // zelfde bestand opnieuw kunnen kiezen
+    });
+  });
+
+  sendBtn.addEventListener('click', async () => {
+    if (sendBtn.disabled) return;
+    sendBtn.disabled = true;
+    try {
+      const r = await fetch('/ward/api/post', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          text: text.value,
+          media,
+          visibility: document.getElementById('compose-vis').value,
+        }),
+      });
+      if (r.ok) { dlg.close(); loadFeed(); }
+      else status.textContent = 'Plaatsen lukte niet.';
+    } catch { status.textContent = 'Plaatsen lukte niet.'; }
+    updateSend();
+  });
+}
+
+/** De controle kost per guardian een remote fetch, dus hij komt ná het tekenen. */
 async function loadChecks() {
   if (!state.guardians.length) return;
@@ -449,4 +541,5 @@
 wireBuoy();
 wireSky();
+wireComposer();
 loadFeed();
 renderOffers();
