---
name: "mediapreview2board"
description: "Bild-/Media-Preview + Upload in Flask+SQLite Kanban-Board: Endpoint, Thumbnails, Modal-Grid, Drag&Drop, Paste, Mixed-Content-Rewrite."
status: proposal
version: "v2"
date: "2026-07-04T08:48:30.610Z"
---

# Mediapreview2Board

Bild- und Datei-Previews in ein Kanban-Board integrieren, damit Tasks visuelle Anhänge zeigen (Thumbnail auf Karte + volles Preview-Grid im Detail-Modal). Erprobt an **Mission Control** (`tasks.raumkommander.at`, Flask + SQLite + Sortable.js).

## Wann anwenden

Der User will Bild-/Datei-Vorschauen in einem existierenden Kanban / Task-Board — typische Trigger:
- "Bild-Preview im Board"
- "Media/Anhänge im Task Manager"
- "Screenshot direkt hochladen"
- "Thumbnails auf Task-Karten"

Passt für Flask-basierte Boards mit SQLite-Backend. Adaption auf andere Stacks möglich, Kern-Logik bleibt gleich.

## ⚠️ WICHTIG: Mixed-Content Falle (immer zuerst prüfen!)

Wenn das Board über HTTPS läuft (`https://tasks.example.com`), aber die Bild-URLs **HTTP** sind (`http://1.2.3.4/downloads/...`), **blockiert der Browser die Bilder komplett stumm** (keine Fehlermeldung, nur leere Thumbnail-Slots).

**Symptom:** Badge `🖼️ 1` wird angezeigt, aber KEIN Thumbnail. Karte sieht kaputt aus.

**Fix:** URL-Rewrite direkt in `task_to_dict()` — alte HTTP-URLs werden zu HTTPS-Äquivalenten umgeschrieben (siehe Schritt 5).

**Diagnose:**
```bash
sqlite3 tasks.db "SELECT id, substr(media,1,120) FROM tasks WHERE media != '[]';"
# Wenn media-URLs mit http:// beginnen → Mixed-Content-Problem
```

## Voraussetzungen prüfen (in dieser Reihenfolge)

1. **Board existiert und läuft** — API-Test:
   ```bash
   curl -s http://127.0.0.1:5555/api/stats
   ```
2. **DB-Schema hat `media` Spalte** (JSON-Array). Wenn nicht:
   ```python
   # in init_db(), nach anderen Migrations:
   if "media" not in cols:
       db.execute("ALTER TABLE tasks ADD COLUMN media TEXT DEFAULT '[]'")
   ```
3. **Templates + CSS Datei-Pfade kennen** (`templates/board.html`, `static/board.css`).
4. **Service-Management** kennen (systemd-Service, z.B. `task-manager.service`).
5. **HTTPS-Deployment?** Wenn ja → Mixed-Content-Rewrite (Schritt 5) einbauen!

## Umsetzung — 5 Schritte

### 1. Upload-Endpoint (app.py)

```python
import os, uuid, json
from pathlib import Path
from flask import send_from_directory
from werkzeug.utils import secure_filename

BASE_DIR = os.path.dirname(__file__)
UPLOAD_DIR = os.path.join(BASE_DIR, "uploads")
MAX_UPLOAD_MB = 25
ALLOWED_EXT = {".png",".jpg",".jpeg",".gif",".webp",".svg",".pdf",".txt",".md",".mp4",".webm"}
IMAGE_EXT = {".png",".jpg",".jpeg",".gif",".webp",".svg"}
os.makedirs(UPLOAD_DIR, exist_ok=True)

app.config["MAX_CONTENT_LENGTH"] = MAX_UPLOAD_MB * 1024 * 1024

@app.get("/uploads/<path:fname>")
def serve_upload(fname):
    return send_from_directory(UPLOAD_DIR, fname, as_attachment=False)

@app.post("/api/task/<tid>/media/upload")
def api_media_upload(tid):
    db = get_db()
    row = db.execute("SELECT media FROM tasks WHERE id=?", [tid]).fetchone()
    if not row:
        return jsonify({"ok": False, "error": "not found"}), 404
    files = request.files.getlist("files")
    if not files:
        return jsonify({"ok": False, "error": "no files"}), 400
    caption = (request.form.get("caption") or "").strip()

    task_dir = os.path.join(UPLOAD_DIR, tid)
    os.makedirs(task_dir, exist_ok=True)

    media = parse_json_field(row["media"], [])
    added = []
    for f in files:
        if not f or not f.filename:
            continue
        orig = secure_filename(f.filename)
        ext = os.path.splitext(orig)[1].lower()
        if ext not in ALLOWED_EXT:
            return jsonify({"ok": False, "error": f"ext not allowed: {ext}"}), 400
        new_name = f"{uuid.uuid4().hex[:10]}{ext}"
        dst = os.path.join(task_dir, new_name)
        f.save(dst)
        kind = "image" if ext in IMAGE_EXT else "file"
        entry = {
            "id": str(uuid.uuid4())[:6],
            "url": f"/uploads/{tid}/{new_name}",
            "kind": kind,
            "caption": caption or orig,
            "local_path": dst,
            "orig_name": orig,
            "size": os.path.getsize(dst),
            "at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
        }
        media.append(entry)
        added.append(entry)

    db.execute("UPDATE tasks SET media=? WHERE id=?", [json.dumps(media), tid])
    db.commit()
    return jsonify({"ok": True, "added": added, "media": media})


@app.post("/api/task/<tid>/media/<mid>/delete")
def api_media_delete(tid, mid):
    db = get_db()
    row = db.execute("SELECT media FROM tasks WHERE id=?", [tid]).fetchone()
    if not row:
        return jsonify({"ok": False, "error": "not found"}), 404
    all_media = parse_json_field(row["media"], [])
    to_del = next((m for m in all_media if m.get("id") == mid), None)
    if to_del and to_del.get("local_path"):
        try:
            p = Path(to_del["local_path"]).resolve()
            up = Path(UPLOAD_DIR).resolve()
            if up in p.parents and p.is_file():
                p.unlink()
        except Exception:
            pass
    media = [m for m in all_media if m.get("id") != mid]
    db.execute("UPDATE tasks SET media=? WHERE id=?", [json.dumps(media), tid])
    db.commit()
    return jsonify({"ok": True, "media": media})
```

**Task-Delete Cleanup ergänzen:**
```python
# in api_delete() nach commit:
try:
    import shutil
    p = (Path(UPLOAD_DIR) / tid).resolve()
    if p.is_dir() and Path(UPLOAD_DIR).resolve() in p.parents:
        shutil.rmtree(p)
except Exception:
    pass
```

### 2. Karten-Thumbnails (board.html)

In der `.card`-Schleife (nach `.card-meta`):

```jinja
{% if t.media %}
<div class="card-media-thumb">
  {% for m in t.media[:3] if m.kind == 'image' %}<img src="{{ m.url }}" alt="" loading="lazy">{% endfor %}
  {% if t.media|length > 3 %}<div class="more">+{{ t.media|length - 3 }}</div>{% endif %}
</div>
{% endif %}
```

In `.card-meta` Badge ergänzen:
```jinja
{% if t.media %}<span class="comments">🖼️ {{ t.media|length }}</span>{% endif %}
```

### 3. Modal-Preview-Grid + Upload-UI (board.html JS)

In `renderModal(t)` — Media-HTML bauen:

```javascript
const media = (t.media || []);
const mediaHtml = media.length ? `
  <div class="media-grid">
    ${media.map(m => {
      if (m.kind === 'image') {
        return `
          <div class="media-item">
            <a href="${m.url}" target="_blank"><img src="${m.url}" alt="${escapeHtml(m.caption||'')}" loading="lazy"></a>
            <div class="media-cap">
              ${m.caption ? `<div>${escapeHtml(m.caption)}</div>` : ''}
              <div class="media-links">
                <a href="${m.url}" target="_blank">local</a>
                <button class="mini-del" onclick="delMedia('${t.id}','${m.id}')">×</button>
              </div>
            </div>
          </div>`;
      }
      return `
        <div class="media-item file">
          <a href="${m.url}" target="_blank">📄 ${escapeHtml(m.caption || m.url)}</a>
          <button class="mini-del" onclick="delMedia('${t.id}','${m.id}')">×</button>
        </div>`;
    }).join('')}
  </div>` : '<div class="empty">Keine Vorschau.</div>';
```

Uploader-Block einfügen (vor `mediaHtml`):
```html
<label class="lbl">🖼️ Preview / Media${media.length?` (${media.length})`:''}</label>
<div class="media-uploader" id="drop-${t.id}"
     ondragover="dragOver(event)" ondragleave="dragLeave(event)" ondrop="dropFiles(event,'${t.id}')">
  <input type="file" id="file-${t.id}" multiple
         accept="image/*,application/pdf,video/mp4,video/webm,.txt,.md"
         style="display:none" onchange="uploadFiles('${t.id}', this.files)">
  <button type="button" class="btn-upload" onclick="document.getElementById('file-${t.id}').click()">📎 Dateien wählen</button>
  <span class="drop-hint">oder Dateien hierher ziehen (max 25MB)</span>
  <div class="upload-progress" id="prog-${t.id}"></div>
</div>
${mediaHtml}
```

Upload-JS + Paste-Handler:
```javascript
function dragOver(e){ e.preventDefault(); e.currentTarget.classList.add('drag-over'); }
function dragLeave(e){ e.currentTarget.classList.remove('drag-over'); }
function dropFiles(e, tid){
  e.preventDefault();
  e.currentTarget.classList.remove('drag-over');
  const files = e.dataTransfer.files;
  if (files && files.length) uploadFiles(tid, files);
}
async function uploadFiles(tid, files){
  if (!files || !files.length) return;
  const prog = document.getElementById('prog-'+tid);
  const fd = new FormData();
  for (const f of files) fd.append('files', f);
  prog.textContent = `⬆️ Upload ${files.length} Datei(en)…`;
  try {
    const r = await fetch(`/api/task/${tid}/media/upload`, {method:'POST', body: fd});
    const j = await r.json();
    if (!j.ok) throw new Error(j.error || 'Upload fehlgeschlagen');
    prog.textContent = `✅ ${j.added.length} hochgeladen`;
    setTimeout(()=>{ prog.textContent=''; }, 1500);
    refreshModal(tid);
    setTimeout(()=>{ if(!document.getElementById('modal').classList.contains('open')) location.reload(); }, 500);
  } catch(err){
    prog.textContent = '❌ ' + err.message;
  }
}
async function delMedia(tid, mid) {
  await fetch(`/api/task/${tid}/media/${mid}/delete`, {method:'POST'});
  refreshModal(tid);
}

// Paste-from-Clipboard direkt ins offene Modal
document.addEventListener('paste', async (e) => {
  const modal = document.getElementById('modal');
  if (!modal.classList.contains('open')) return;
  const items = e.clipboardData?.items;
  if (!items) return;
  const files = [];
  for (const it of items) {
    if (it.kind === 'file') {
      const f = it.getAsFile();
      if (f) files.push(f);
    }
  }
  if (!files.length) return;
  const idTag = document.querySelector('#modal-content .m-head .tag');
  if (!idTag) return;
  uploadFiles(idTag.textContent.trim(), files);
});
```

### 4. CSS (board.css anhängen)

```css
/* Media Grid im Modal */
.media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; margin-bottom: 8px; }
.media-item { background: #1a2340; border: 1px solid #2a3a5f; border-radius: 4px; padding: 6px; }
.media-item img { width: 100%; height: 140px; object-fit: cover; border-radius: 3px; display: block; cursor: zoom-in; background: #0a0e1a; }
.media-item.file { padding: 12px; display: flex; justify-content: space-between; align-items: center; }
.media-item.file a { color: #7ee8fa; text-decoration: none; }
.media-cap { font-size: 11px; color: #7ee8fa; margin-top: 6px; }
.media-cap > div { margin-bottom: 3px; word-break: break-word; }
.media-links { display: flex; gap: 6px; align-items: center; font-size: 11px; }
.media-links a { color: #a5b4ff; text-decoration: none; margin-right: 6px; }
.media-links a:hover { text-decoration: underline; }
.media-links .mini-del { margin-left: auto; }

/* Karten-Thumbnails */
.card-media-thumb { margin-top: 6px; display: flex; gap: 4px; overflow: hidden; }
.card-media-thumb img { width: 40px; height: 40px; object-fit: cover; border-radius: 2px; }
.card-media-thumb .more { width: 40px; height: 40px; background: #131b2d; color: #7ee8fa; display: flex; align-items: center; justify-content: center; border-radius: 2px; font-size: 10px; }

/* Uploader */
.media-uploader {
  background: #131b2d;
  border: 2px dashed #2a3a5f;
  border-radius: 6px;
  padding: 12px 14px;
  margin-bottom: 10px;
  display: flex;
  align-items: center;
  gap: 12px;
  transition: all 0.15s;
}
.media-uploader.drag-over {
  background: #1a2340;
  border-color: #7ee8fa;
  box-shadow: 0 0 12px rgba(126, 232, 250, 0.3);
}
.btn-upload {
  background: linear-gradient(135deg, #7ee8fa, #4a90e2);
  color: #0a0e1a;
  border: none;
  padding: 7px 14px;
  border-radius: 4px;
  font-weight: 600;
  cursor: pointer;
  font-size: 13px;
}
.btn-upload:hover { transform: translateY(-1px); }
.drop-hint { color: #6a7896; font-size: 12px; flex: 1; }
.upload-progress { color: #7ee8fa; font-size: 12px; font-weight: 600; min-width: 120px; text-align: right; }
```

### 5. Mixed-Content Rewrite (PFLICHT bei HTTPS-Deployment)

Wenn Media-Einträge früher mit HTTP-URLs (z.B. `http://<vps-ip>/downloads/...`) gefüllt wurden, blockt der Browser sie auf HTTPS-Seiten — Badge zeigt `🖼️ 1`, aber KEIN Thumbnail lädt.

**Fix in `app.py` — Rewrite direkt in `task_to_dict()`:**

```python
# Mixed-Content Rewrite: alte HTTP-URLs, die auf HTTPS-Site geblockt werden
URL_REWRITES = [
    ("http://<vps-ip>/downloads/", "https://<https-mirror>/"),
    # weitere alte → neue Mappings hier
]

def fix_media_url(u):
    if not u or not isinstance(u, str):
        return u
    for old, new in URL_REWRITES:
        if u.startswith(old):
            return new + u[len(old):]
    return u

def task_to_dict(row):
    d = dict(row)
    d["subtasks"] = parse_json_field(d.get("subtasks"), [])
    d["comments"] = parse_json_field(d.get("comments"), [])
    media = parse_json_field(d.get("media"), [])
    for m in media:
        if isinstance(m, dict):
            m["url"] = fix_media_url(m.get("url"))
            if m.get("pcloud_url"):
                m["pcloud_url"] = fix_media_url(m["pcloud_url"])
    d["media"] = media
    return d
```

**Datenbank-Migration (einmalig, für alle bestehenden Einträge persistent):**

```bash
# Backup zuerst
cp tasks.db tasks.db.bak-mixed-fix

sqlite3 tasks.db <<'SQL'
UPDATE tasks
SET media = replace(media, 'http://<vps-ip>/downloads/', 'https://<https-mirror>/')
WHERE media LIKE '%http://<vps-ip>/downloads/%';
SELECT changes() AS rows_changed;
SQL
```

**Diagnose-Kommando:** zeigt problematische Einträge:
```bash
sqlite3 tasks.db "SELECT id, title, substr(media,1,80) FROM tasks WHERE media LIKE '%http://%';"
```

Der Runtime-Rewrite in `task_to_dict()` ist die **Safety-Net-Ebene** — falls neue Executor/Sub-Agents wieder alte URLs schreiben, korrigiert der Read-Path das transparent (DB behält aber die alte URL — daher Migration zusätzlich empfohlen).

## Test-Sequenz nach Änderung

1. Service restart: `systemctl restart task-manager.service` (oder analog)
2. Test-Task erzeugen + Bild uploaden:
   ```bash
   TID=$(curl -s -X POST http://127.0.0.1:5555/api/task/create \
     -H "Content-Type: application/json" \
     -d '{"title":"Preview Test","status":"in_progress"}' \
     | python3 -c "import sys,json;print(json.load(sys.stdin)['task']['id'])")
   curl -s -X POST "http://127.0.0.1:5555/api/task/$TID/media/upload" \
     -F "files=@test.png" -F "caption=Test"
   curl -s "http://127.0.0.1:5555/api/task/$TID" | python3 -m json.tool
   ```
3. Board öffnen, Karte anklicken → Modal zeigt Preview + Uploader.
4. **Bei HTTPS-Deployment:** Browser DevTools → Console-Tab prüfen auf "Mixed Content" Warnings. Wenn welche kommen → Rewrite-Regel für die Domain ergänzen.
5. Task wieder löschen: `curl -X POST http://127.0.0.1:5555/api/task/$TID/delete`
6. Prüfen dass `uploads/$TID/` weg ist.

## Security-Hinweise

- **Whitelist statt Blacklist** für Extensions (`ALLOWED_EXT`).
- **`secure_filename` immer** (werkzeug).
- **UUID-Rename** verhindert Path-Traversal, Konflikte, XSS via Dateiname.
- **`MAX_CONTENT_LENGTH`** setzen (default 25MB), sonst OOM-Risiko.
- Uploads-Ordner **nicht direkt vom Webserver serven**, sondern über Flask-Endpoint mit `send_from_directory` (Path-Traversal-Schutz durch werkzeug).
- Falls hinter Nginx: `client_max_body_size 25m;` im vhost-Block, sonst 413.
- **HTTPS-Only:** wenn Board TLS ist, alle Bild-URLs müssen HTTPS oder protokoll-relativ sein (siehe Schritt 5).

## Reverse-Proxy (Nginx)

```nginx
server {
    server_name tasks.example.com;
    client_max_body_size 25m;
    location / {
        proxy_pass http://127.0.0.1:5555;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}
```

## Erweiterungen (optional)

- **pCloud-Sync:** nach Upload asynchron zu pCloud kopieren, `pcloud_url` in Media-Entry setzen (siehe skill `pcloud-image-archive-by-lash`).
- **Bild-Kompression:** Pillow `Image.thumbnail()` beim Upload für riesige Fotos.
- **Video-Thumbnails:** ffmpeg-oneliner erzeugt Poster-Frame.
- **Executor-Integration:** wenn ein Sub-Agent Bilder erzeugt, kann er `/api/task/<id>/media` (URL-basiert) callen statt Upload — spart Bandbreite.

## Bekannte Fallstricke

- **🚨 Mixed-Content-Block (häufigster Fehler):** HTTPS-Board + HTTP-URL im Media-Feld = Thumbnail lädt nicht, Browser meldet nichts sichtbares. Fix: Schritt 5.
- **Modal-Reload nach Upload:** wenn User weiter im Modal arbeitet, kein Full-Reload. Nur `refreshModal(tid)`.
- **Auto-Refresh alle 5s** (bei `in_progress` Tasks) darf keine Uploads unterbrechen — Check auf `document.activeElement` (INPUT/TEXTAREA/contentEditable) einbauen.
- **Datei-Endung im Dropdown:** `accept="image/*"` filtert nur den Picker, nicht Drag&Drop. Server-Validierung in `ALLOWED_EXT` ist Pflicht.
- **`app.config["MAX_CONTENT_LENGTH"]`** löst 413 aus BEVOR Flask den Request sieht — Fehler dann in Nginx sichtbar, nicht im Flask-Log.