Software-Sektion + Drucker-Verwaltung + Slot-System + Selbstlern-Loop

Vier zusammenhaengende Iterationen, die das Funktions-Geruest der AG aufbauen.
Alle 6 Migrationen sind idempotent und legen Schema-Erweiterungen + initiale
Seeds an (Mock-Projekte nur, falls Profil 100 existiert — auf prod nicht).

Software-Sektion (Etappe 3):
  - software_seite-Tabelle: schlanker Karten-Katalog (slug, titel, urls,
    plattform, kategorie, sortierung, Cover-BLOB)
  - 7 Initial-Karten (Bambu Studio/Handy, Printables, MakerWorld,
    TinkerCAD, Onshape, Gridfinity-Generator)
  - Admin-Verwaltung: Liste, Anlegen-Form, Bearbeiten, Aktiv-Toggle,
    Hart-Loeschen, Bild-Upload via Crop-Tool (Strg+V/Drag&Drop, 400x400)

Drucker-Verwaltung (Etappe 5-vorgezogen):
  - drucker.freigegeben + gesperrt_grund (Lehrer-Sperre fuer Reparatur)
  - drucker.manual_url + troubleshooting_url (Override fuer Modell-Defaults)
  - SuS-View /drucker (eingeloggt): Kacheln + Sperre-Banner + Belegung
  - Admin /admin/drucker: Status-Override (idle/printing/failed/...),
    Sperren/Freigeben mit Grund, Inline-Edit fuer URLs

Slot-System / Reservierung (Etappe 7-vorgezogen):
  - profil.reservieren_freigeschaltet (Lehrer schaltet manuell frei,
    ersetzt L1-Gate-Automatismus)
  - services/slots.py: Konflikt-Check, Belegungs-Helper
  - SuS-Routen /cockpit/slot/neu (mit Konflikt-Check), /cockpit/slots,
    Stornieren
  - Admin /admin/slots Uebersicht + /admin/slot/neu (Lehrer reserviert
    fuer SuS) + Hart-Loeschen + Profil-Freigabe-Toggle

Selbstlern-Loop (Etappe 6 — Kern):
  - projekt-Spalten: 3-Achsen-Eval (Gesamt + Planung + Ausfuehrung),
    was_klappte_nicht, cover_blob, modellier_software_slug,
    drucker_id_used, makerworld_url
  - Regel: max. 1 aktives Projekt pro SuS, neues nur nach Freigabe
    (auto bei zufrieden/mittel, Lehrer-Review bei gelernt)
  - SuS-Werkstatt-Sektion: Titel + Beschreibung + Software-Dropdown +
    Drucker-Dropdown + MakerWorld-URL + Files-Upload (drag&drop,
    base64, max 30 MB)
  - Cover-Bild via Crop-Tool (Re-use admin_software_bild-Pattern)
  - Abschluss-Block: Pflicht Cover + mindestens 1 Datei
  - Eval-Form: visuelle 3x3 Radio-Karten + 2 Freitexte
  - Lehrer-Review: /admin/projekte mit Filter (wartet/in_arbeit/
    abgeschlossen/rueckfrage), Aktionen freigeben/rueckfrage/loeschen
  - Oeffentlicher Katalog /projekte: Sortier-Tabelle (Titel/Person/
    Status/Erfolg/Datum), aufklappbare Detail mit Cover/Beschreibung/
    Datei-Downloads/MakerWorld-Link
  - Wiederhol-Logik: "Sowas mache ich auch" via Verknuepfung
    (vorlage_projekt_id), bidirektionale Lineage-Banner

Cockpit-Refactoring:
  - Projekt-Karte ganz oben (vor Onboarding) mit "Hier weitermachen"
  - Check-In-Quick-Actions: nach Auswahl direkt zu /cockpit/projekt/neu,
    /drucker, /lektionen, /cockpit/projekt/<aktiv>
  - "Modus aendern"-Button im Eingecheckt-Banner
  - Admin-Dashboard-Hub mit Karten: SuS-Profile, Software, Lektionen,
    Drucker, Reservierungen, Projekte (Badge bei wartenden), Alph

Bug-Fixes:
  - Admin-Login redirected jetzt auf /admin/dashboard statt /admin/profile
  - app.py: neue Jinja-Filter iso_format + dauer_min
  - .gitignore: .claude/ ausschliessen (lokale launch.json)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 19:04:18 +02:00
parent 66f6dd96dc
commit 8cbd803328
40 changed files with 5721 additions and 55 deletions

View File

@@ -5,11 +5,70 @@
<div class="section-header">
<h1>Dashboard</h1>
<div>
<a href="{{ url_for('admin.profile_liste') }}" class="btn btn-secondary">Profile</a>
<a href="{{ url_for('admin.admin_logout') }}" class="btn btn-secondary">Admin-Logout</a>
</div>
</div>
{# ====== Schnellnavigation (Verwaltungs-Karten) ====== #}
<div class="grid"
style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.8rem; margin-bottom: 1.5rem;">
<a href="{{ url_for('admin.profile_liste') }}" class="card"
style="text-decoration: none; color: inherit; text-align: center; padding: 1.2rem 0.8rem;">
<div style="font-size: 2rem;">👥</div>
<div style="font-weight: 600; margin-top: 0.3rem; color: var(--primary-dark);">SuS-Profile</div>
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.3rem;">Anlegen, PIN, Karten drucken</div>
</a>
<a href="{{ url_for('admin.software_liste') }}" class="card"
style="text-decoration: none; color: inherit; text-align: center; padding: 1.2rem 0.8rem;">
<div style="font-size: 2rem;">🖥️</div>
<div style="font-weight: 600; margin-top: 0.3rem; color: var(--primary-dark);">Software &amp; Generatoren</div>
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.3rem;">Karten, Bilder, Tutorials</div>
</a>
<a href="{{ url_for('admin.admin_lektionen') }}" class="card"
style="text-decoration: none; color: inherit; text-align: center; padding: 1.2rem 0.8rem; opacity: 0.6;"
title="Verwaltung kommt mit Etappe 3-Inline-Edit-UI">
<div style="font-size: 2rem;">📚</div>
<div style="font-weight: 600; margin-top: 0.3rem; color: var(--primary-dark);">Lektionen</div>
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.3rem;">Bearbeiten — kommt</div>
</a>
<a href="{{ url_for('admin.admin_drucker') }}" class="card"
style="text-decoration: none; color: inherit; text-align: center; padding: 1.2rem 0.8rem;">
<div style="font-size: 2rem;">🖨️</div>
<div style="font-weight: 600; margin-top: 0.3rem; color: var(--primary-dark);">Drucker</div>
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.3rem;">Status, Sperre, Links</div>
</a>
<a href="{{ url_for('admin.slot_uebersicht') }}" class="card"
style="text-decoration: none; color: inherit; text-align: center; padding: 1.2rem 0.8rem;">
<div style="font-size: 2rem;">🔖</div>
<div style="font-weight: 600; margin-top: 0.3rem; color: var(--primary-dark);">Reservierungen</div>
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.3rem;">Slot-Plan, fuer SuS anlegen</div>
</a>
<a href="{{ url_for('admin.projekte_uebersicht') }}" class="card"
style="text-decoration: none; color: inherit; text-align: center; padding: 1.2rem 0.8rem;
{% if projekte_warten > 0 %}border-top: 4px solid var(--warning);{% endif %}">
<div style="font-size: 2rem; position: relative; display: inline-block;">
🎯
{% if projekte_warten > 0 %}
<span style="position: absolute; top: -4px; right: -16px; background: var(--warning); color: white;
border-radius: 10px; padding: 0.1rem 0.5rem; font-size: 0.7rem; font-weight: 700;">
{{ projekte_warten }}
</span>
{% endif %}
</div>
<div style="font-weight: 600; margin-top: 0.3rem; color: var(--primary-dark);">Projekte</div>
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.3rem;">
{% if projekte_warten > 0 %}<strong style="color: var(--warning);">{{ projekte_warten }} wartet</strong>
{% else %}alle Projekte ansehen{% endif %}
</div>
</a>
<a href="/admin/feedback" class="card"
style="text-decoration: none; color: inherit; text-align: center; padding: 1.2rem 0.8rem;">
<div style="font-size: 2rem;">🐝</div>
<div style="font-weight: 600; margin-top: 0.3rem; color: var(--primary-dark);">Alph-Feedback</div>
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.3rem;">SuS-Rueckmeldungen</div>
</a>
</div>
{# ====== Schnell-Zahlen ====== #}
<div class="grid" style="grid-template-columns: repeat(3, 1fr); gap: 1rem;">
<div class="stat-card">

View File

@@ -0,0 +1,157 @@
{% extends "base.html" %}
{% block title %}Drucker &mdash; Admin{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Drucker-Verwaltung</h1>
<div>
<a href="{{ url_for('oeffentlich.drucker') }}" class="btn btn-secondary">SuS-Ansicht</a>
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">&larr; Dashboard</a>
</div>
</div>
<div class="card" style="background:#FEF9E7; border-left: 4px solid var(--warning);">
<strong>Hinweis:</strong> Live-Status via Bambu-Cloud-API kommt mit Etappe 5 (siehe
<code>docs/offene-fragen.md</code> &mdash; Bambu-Account muss noch geklaert werden).
Bis dahin pflegst du den Status hier manuell.
</div>
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));">
{% for d in drucker_liste %}
<div class="card {% if not d.freigegeben %}drucker-failed{% endif %}"
style="display: flex; flex-direction: column; gap: 0.8rem;
{% if not d.freigegeben %}border-left: 4px solid var(--danger);{% endif %}">
<div style="display: flex; justify-content: space-between; align-items: baseline;">
<h2 style="margin: 0; color: var(--primary-dark);">{{ d.name }}</h2>
<span style="font-size: 0.85rem; color: var(--text-light);">
{{ d.modell }}{% if not d.geschlossenes_gehaeuse %} · <span style="color: var(--danger);">offen</span>{% endif %}
</span>
</div>
{# ============== Status setzen ============== #}
<fieldset style="border: 1px solid var(--border); padding: 0.6rem; border-radius: 4px;">
<legend style="font-size: 0.8rem; color: var(--text-light); padding: 0 0.3rem;">Status</legend>
<form method="post" action="{{ url_for('admin.drucker_status_setzen', drucker_id=d.id) }}"
style="display: grid; gap: 0.4rem;">
<select name="status" style="padding: 0.4rem; border: 1px solid var(--border); border-radius: 4px;">
<option value="">— kein Status —</option>
{% for s, label in status_optionen %}
<option value="{{ s }}" {% if d.status == s %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<input type="text" name="job_name" value="{{ d.job_name or '' }}" placeholder="Job-Name (optional)"
style="padding: 0.4rem; border: 1px solid var(--border); border-radius: 4px;">
<input type="text" name="fehlermeldung" value="{{ d.fehlermeldung or '' }}" placeholder="Fehlermeldung (optional)"
style="padding: 0.4rem; border: 1px solid var(--border); border-radius: 4px;">
<button class="btn btn-primary" style="font-size: 0.85rem;">Status speichern</button>
</form>
{% if d.gepollt_am %}
<div style="font-size: 0.75rem; color: var(--text-light); margin-top: 0.4rem;">
zuletzt: {{ d.gepollt_am[:16].replace('T', ' ') }}
{% if d.manuell_gesetzt %}· manuell{% endif %}
</div>
{% endif %}
</fieldset>
{# ============== Freigabe/Sperre ============== #}
<fieldset style="border: 1px solid var(--border); padding: 0.6rem; border-radius: 4px;">
<legend style="font-size: 0.8rem; color: var(--text-light); padding: 0 0.3rem;">Reservierungs-Freigabe</legend>
{% if d.freigegeben %}
<div style="margin-bottom: 0.4rem; color: var(--success); font-weight: 600;">✅ freigegeben</div>
<form method="post" action="{{ url_for('admin.drucker_freigabe_toggle', drucker_id=d.id) }}"
style="display: grid; gap: 0.4rem;">
<input type="hidden" name="freigeben" value="0">
<input type="text" name="gesperrt_grund" placeholder="Grund fuer Sperrung (z.B. 'Filament-Wechsel')" required
style="padding: 0.4rem; border: 1px solid var(--border); border-radius: 4px;">
<button class="btn btn-secondary" style="font-size: 0.85rem; color: var(--danger);">Sperren</button>
</form>
{% else %}
<div style="margin-bottom: 0.4rem; color: var(--danger); font-weight: 600;">
🔒 gesperrt{% if d.gesperrt_grund %}: {{ d.gesperrt_grund }}{% endif %}
</div>
<form method="post" action="{{ url_for('admin.drucker_freigabe_toggle', drucker_id=d.id) }}">
<input type="hidden" name="freigeben" value="1">
<button class="btn btn-primary" style="font-size: 0.85rem;">Wieder freigeben</button>
</form>
{% endif %}
</fieldset>
{# ============== Inline-Edit: URLs + Notiz ============== #}
<fieldset style="border: 1px solid var(--border); padding: 0.6rem; border-radius: 4px;">
<legend style="font-size: 0.8rem; color: var(--text-light); padding: 0 0.3rem;">
Links &amp; Notiz <span style="font-style: italic;">(klick zum Editieren)</span>
</legend>
<div style="font-size: 0.85rem; margin-bottom: 0.5rem;">
<strong>Manual:</strong>
<span class="edit-feld" data-tabelle="drucker" data-id="{{ d.id }}" data-feld="manual_url"
data-default="{{ d.manual or '' }}"
style="cursor: pointer; border-bottom: 1px dashed var(--text-light);">
{% if d.manual_url %}{{ d.manual_url }}
{% elif d.manual %}<em style="color: var(--text-light);">{{ d.manual }}</em>
{% else %}<em style="color: var(--text-light);">[setzen]</em>{% endif %}
</span>
{% if d.manual %}<a href="{{ d.manual }}" target="_blank" rel="noopener noreferrer" style="font-size: 0.85rem;"></a>{% endif %}
</div>
<div style="font-size: 0.85rem; margin-bottom: 0.5rem;">
<strong>Troubleshooting:</strong>
<span class="edit-feld" data-tabelle="drucker" data-id="{{ d.id }}" data-feld="troubleshooting_url"
style="cursor: pointer; border-bottom: 1px dashed var(--text-light);">
{% if d.troubleshooting_url %}{{ d.troubleshooting_url }}
{% elif d.troubleshooting %}<em style="color: var(--text-light);">{{ d.troubleshooting }}</em>
{% else %}<em style="color: var(--text-light);">[setzen]</em>{% endif %}
</span>
{% if d.troubleshooting %}<a href="{{ d.troubleshooting }}" target="_blank" rel="noopener noreferrer" style="font-size: 0.85rem;"></a>{% endif %}
</div>
<div style="font-size: 0.85rem;">
<strong>Notiz:</strong>
<span class="edit-feld" data-tabelle="drucker" data-id="{{ d.id }}" data-feld="notiz"
style="cursor: pointer; border-bottom: 1px dashed var(--text-light);">
{{ d.notiz or '[setzen]' }}
</span>
</div>
<div style="font-size: 0.75rem; color: var(--text-light); margin-top: 0.5rem; font-style: italic;">
Tipp: Wenn Manual/Troubleshooting leer, wird der Modell-Default benutzt (kursiv angezeigt).
</div>
</fieldset>
</div>
{% endfor %}
</div>
{% endblock %}
{% block scripts %}
{# Minimalistischer Inline-Edit-Handler (prompt-basiert, kein contenteditable).
Kompatibel zum bestehenden /admin/inline-edit-Endpunkt mit der Whitelist
in routes/admin.py. #}
<script>
document.querySelectorAll('.edit-feld').forEach(function(el) {
el.addEventListener('click', function() {
var tabelle = el.dataset.tabelle;
var id = el.dataset.id;
var feld = el.dataset.feld;
var alterWert = el.textContent.trim();
if (alterWert === '[setzen]') alterWert = '';
var neu = prompt('Neuer Wert fuer ' + feld + ':', alterWert);
if (neu === null) return; // abgebrochen
var fd = new FormData();
fd.append('tabelle', tabelle);
fd.append('id', id);
fd.append('feld', feld);
fd.append('wert', neu);
fetch('{{ url_for("admin.inline_edit") }}', {method: 'POST', body: fd})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) { location.reload(); }
else { alert('Fehler: ' + (data.error || 'unbekannt')); }
})
.catch(function() { alert('Verbindungsfehler'); });
});
});
</script>
{% endblock %}

View File

@@ -54,6 +54,7 @@
<th>Tier</th>
<th>AG</th>
<th>Status</th>
<th>Reservieren</th>
<th>Angelegt</th>
<th>Aktionen</th>
</tr>
@@ -66,6 +67,17 @@
<td>{{ tier_name(p.tier) }}</td>
<td>{{ p.ag_name or p.ag_id }}</td>
<td>{% if p.aktiv %}<span class="badge badge-anfaenger">aktiv</span>{% else %}<span class="badge">inaktiv</span>{% endif %}</td>
<td>
<form method="post" action="{{ url_for('admin.profil_reservieren_toggle', profil_id=p.id) }}" style="display:inline;">
{% if p.reservieren_freigeschaltet %}
<button class="btn btn-secondary" style="padding: 0.3rem 0.6rem; font-size: 0.8rem;"
title="Klicken zum Sperren">✅ freigeschaltet</button>
{% else %}
<button class="btn btn-secondary" style="padding: 0.3rem 0.6rem; font-size: 0.8rem; color: var(--text-light);"
title="Klicken zum Freischalten">⚪ noch nicht</button>
{% endif %}
</form>
</td>
<td style="font-size: 0.85rem; color: var(--text-light);">{{ p.erstellt_am[:10] if p.erstellt_am else '' }}</td>
<td>
<form method="post" action="{{ url_for('admin.profil_pin_reset', profil_id=p.id) }}" style="display:inline;"

View File

@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}Projekt-Freigabe &mdash; Admin{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Projekte: Wartend auf Freigabe</h1>
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">&larr; Dashboard</a>
</div>
<p style="color: var(--text-light);">
SuS haben diese Projekte als "Gelernt" (Misserfolg) abgeschlossen — schau drueber, dann freigeben
oder Rueckfrage stellen. Erst danach darf der SuS ein neues Projekt anfangen.
</p>
{% if queue %}
<div class="grid" style="grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));">
{% for p in queue %}
<a href="{{ url_for('admin.projekt_review', projekt_id=p.id) }}" class="card"
style="text-decoration: none; color: inherit; display: flex; flex-direction: column; gap: 0.6rem;
border-left: 4px solid var(--warning);">
{% if p.hat_cover %}
<img src="{{ url_for('api.projekt_cover', projekt_id=p.id) }}" alt=""
style="width: 100%; height: 160px; object-fit: cover; border-radius: 6px;">
{% else %}
<div style="height: 160px; background: linear-gradient(135deg, #ECF0F1, #D5DBDB); border-radius: 6px;
display: flex; align-items: center; justify-content: center; font-size: 2.5rem; color: var(--text-light);">
🎯
</div>
{% endif %}
<h3 style="margin: 0; color: var(--primary-dark);">{{ p.titel }}</h3>
<div style="font-size: 0.9rem;">
{{ tier_emoji(p.tier or '') }} <strong>{{ p.vorname or '—' }}</strong>
</div>
<div style="display: flex; gap: 0.3rem; flex-wrap: wrap; font-size: 0.85rem;">
<span class="badge badge-fortgeschritten">🔴 gelernt</span>
{% if p.selbsteinschaetzung_planung == 'schwierig' %}<span class="badge badge-fortgeschritten">Planung 🔴</span>
{% elif p.selbsteinschaetzung_planung == 'ok' %}<span class="badge badge-mittel">Planung 🟡</span>
{% elif p.selbsteinschaetzung_planung == 'gut' %}<span class="badge badge-anfaenger">Planung 🟢</span>{% endif %}
{% if p.selbsteinschaetzung_ausfuehrung == 'schwierig' %}<span class="badge badge-fortgeschritten">Ausf. 🔴</span>
{% elif p.selbsteinschaetzung_ausfuehrung == 'ok' %}<span class="badge badge-mittel">Ausf. 🟡</span>
{% elif p.selbsteinschaetzung_ausfuehrung == 'gut' %}<span class="badge badge-anfaenger">Ausf. 🟢</span>{% endif %}
</div>
{% if p.was_klappte_nicht %}
<div style="font-size: 0.85rem; color: var(--text-light); font-style: italic;
max-height: 3em; overflow: hidden;">
"{{ p.was_klappte_nicht }}"
</div>
{% endif %}
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: auto;">
abgeschlossen {{ p.abgeschlossen_am[:10] if p.abgeschlossen_am else '' }}
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="card">
<p style="color: var(--text-light);">Keine offenen Reviews. 👍</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,152 @@
{% extends "base.html" %}
{% block title %}Review: {{ p.titel }}{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Review: {{ p.titel }}</h1>
<a href="{{ url_for('admin.projekt_freigabe') }}" class="btn btn-secondary">&larr; Review-Queue</a>
</div>
<div style="display: grid; grid-template-columns: 280px 1fr; gap: 1rem;">
{# ============== Cover ============== #}
<div class="card" style="text-align: center;">
{% if p.hat_cover %}
<img src="{{ url_for('api.projekt_cover', projekt_id=p.id) }}" alt=""
style="width: 100%; max-height: 240px; object-fit: cover; border-radius: 6px;">
{% else %}
<div style="height: 200px; background: linear-gradient(135deg, #ECF0F1, #D5DBDB); border-radius: 6px;
display: flex; align-items: center; justify-content: center; font-size: 3rem; color: var(--text-light);">
🎯
</div>
{% endif %}
{% if profil %}
<div style="margin-top: 0.8rem;">
<div style="font-size: 1.4rem;">{{ tier_emoji(profil.tier) }}</div>
<strong>{{ profil.vorname }}</strong>
</div>
{% endif %}
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.4rem;">
abgeschlossen {{ p.abgeschlossen_am[:16].replace('T', ' ') if p.abgeschlossen_am else '—' }}
</div>
</div>
<div>
{# ============== Beschreibung ============== #}
<div class="card">
<h2>Beschreibung</h2>
{% if p.beschreibung_md %}
<div>{{ p.beschreibung_md|markdown }}</div>
{% else %}
<p style="color: var(--text-light);"><em>Keine Beschreibung.</em></p>
{% endif %}
</div>
{# ============== Selbstbewertung ============== #}
<div class="card">
<h2>Selbstbewertung</h2>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.6rem; margin-bottom: 1rem;">
<div style="text-align: center; padding: 0.6rem; background: #F4F6F7; border-radius: 6px;">
<div style="font-size: 0.75rem; color: var(--text-light);">Gesamt</div>
<div style="font-size: 1.4rem; margin-top: 0.2rem;">
{% if p.selbsteinschaetzung == 'zufrieden' %}🟢
{% elif p.selbsteinschaetzung == 'mittel' %}🟡
{% elif p.selbsteinschaetzung == 'gelernt' %}🔴{% endif %}
</div>
<div style="font-size: 0.85rem; margin-top: 0.2rem;">{{ p.selbsteinschaetzung or '—' }}</div>
</div>
<div style="text-align: center; padding: 0.6rem; background: #F4F6F7; border-radius: 6px;">
<div style="font-size: 0.75rem; color: var(--text-light);">Planung</div>
<div style="font-size: 1.4rem; margin-top: 0.2rem;">
{% if p.selbsteinschaetzung_planung == 'gut' %}🟢
{% elif p.selbsteinschaetzung_planung == 'ok' %}🟡
{% elif p.selbsteinschaetzung_planung == 'schwierig' %}🔴
{% else %}—{% endif %}
</div>
<div style="font-size: 0.85rem; margin-top: 0.2rem;">{{ p.selbsteinschaetzung_planung or '—' }}</div>
</div>
<div style="text-align: center; padding: 0.6rem; background: #F4F6F7; border-radius: 6px;">
<div style="font-size: 0.75rem; color: var(--text-light);">Ausfuehrung</div>
<div style="font-size: 1.4rem; margin-top: 0.2rem;">
{% if p.selbsteinschaetzung_ausfuehrung == 'gut' %}🟢
{% elif p.selbsteinschaetzung_ausfuehrung == 'ok' %}🟡
{% elif p.selbsteinschaetzung_ausfuehrung == 'schwierig' %}🔴
{% else %}—{% endif %}
</div>
<div style="font-size: 0.85rem; margin-top: 0.2rem;">{{ p.selbsteinschaetzung_ausfuehrung or '—' }}</div>
</div>
</div>
{% if p.was_gelernt %}
<div style="margin-bottom: 0.6rem;">
<strong>Was gelernt:</strong>
<div style="margin-top: 0.2rem;">{{ p.was_gelernt }}</div>
</div>
{% endif %}
{% if p.was_klappte_nicht %}
<div>
<strong>Was klappte nicht:</strong>
<div style="margin-top: 0.2rem;">{{ p.was_klappte_nicht }}</div>
</div>
{% endif %}
</div>
{# ============== Rueckfragen-Historie ============== #}
{% if rueckfragen %}
<div class="card">
<h2>Rueckfragen-Historie</h2>
{% for r in rueckfragen %}
<div style="border-left: 3px solid {% if r.status == 'offen' %}var(--warning){% else %}var(--success){% endif %};
padding: 0.5rem 0.8rem; margin-bottom: 0.5rem; background: #F4F6F7; border-radius: 4px;">
<div style="font-size: 0.85rem; color: var(--text-light); margin-bottom: 0.2rem;">
{{ r.erstellt_am[:16].replace('T', ' ') }}{% if r.status == 'erledigt' %} · ✓ erledigt{% endif %}
</div>
<div>{{ r.text }}</div>
</div>
{% endfor %}
</div>
{% endif %}
{# ============== Aktionen ============== #}
<div class="card" style="border-top: 4px solid var(--primary);">
<h2>Deine Entscheidung</h2>
<form method="post" action="{{ url_for('admin.projekt_freigeben', projekt_id=p.id) }}"
style="display: inline-block; margin-right: 0.5rem;">
<button class="btn btn-primary" style="padding: 0.7rem 1.5rem;">
✅ Freigeben — SuS darf neues anfangen
</button>
</form>
<details style="margin-top: 1rem;">
<summary style="cursor: pointer; color: var(--primary); font-weight: 600;">
❓ Rueckfrage stellen
</summary>
<form method="post" action="{{ url_for('admin.projekt_rueckfrage', projekt_id=p.id) }}"
style="margin-top: 0.5rem; display: grid; gap: 0.5rem;">
<textarea name="text" required rows="3" maxlength="500"
placeholder="z.B. 'Schreib mir bitte 2-3 Saetze dazu, was beim Slicen schief lief.'"
style="padding: 0.6rem; border: 1px solid var(--border); border-radius: 4px; font-family: inherit;"></textarea>
<div>
<button class="btn btn-secondary">Rueckfrage abschicken</button>
</div>
</form>
</details>
<details style="margin-top: 1rem;">
<summary style="cursor: pointer; color: var(--danger); font-weight: 600;">
🗑️ Hart loeschen
</summary>
<p style="font-size: 0.85rem; color: var(--text-light); margin-top: 0.5rem;">
Nur fuer Spam, Penismodelle, Copyright-Verletzungen. Wird im aenderung_log dokumentiert.
</p>
<form method="post" action="{{ url_for('admin.projekt_admin_loeschen', projekt_id=p.id) }}"
onsubmit="return confirm('Projekt {{ p.titel }} wirklich UNWIDERRUFLICH loeschen?')"
style="margin-top: 0.5rem;">
<button class="btn btn-secondary" style="color: var(--danger);">Endgueltig loeschen</button>
</form>
</details>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% block title %}Projekte &mdash; Admin{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Projekte</h1>
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">&larr; Dashboard</a>
</div>
{# ============== Filter-Buttons ============== #}
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem;">
{% set filter_links = [
('alle', 'Alle', zaehler.alle),
('wartet', '⏳ wartet auf Freigabe', zaehler.wartet),
('rueckfrage', '❓ Rueckfrage', zaehler.rueckfrage),
('in_arbeit', '🔧 in Arbeit', zaehler.in_arbeit),
('abgeschlossen', '✅ abgeschlossen', zaehler.abgeschlossen),
] %}
{% for key, label, anzahl in filter_links %}
<a href="{{ url_for('admin.projekte_uebersicht', filter=key) }}"
class="btn {% if aktueller_filter == key %}btn-primary{% else %}btn-secondary{% endif %}"
style="font-size: 0.85rem;">
{{ label }} <span style="opacity: 0.7;">({{ anzahl }})</span>
</a>
{% endfor %}
</div>
{% if projekte %}
<div class="grid" style="grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));">
{% for p in projekte %}
<a href="{{ url_for('admin.projekt_review', projekt_id=p.id) }}" class="card"
style="text-decoration: none; color: inherit; display: flex; flex-direction: column; gap: 0.5rem;
{% if p.freigabe_status == 'wartet_auf_freigabe' %}border-left: 4px solid var(--warning);
{% elif p.freigabe_status == 'rueckfrage' %}border-left: 4px solid var(--danger);
{% elif p.status_lebenszyklus == 'in_arbeit' %}border-left: 4px solid var(--primary);
{% endif %}">
{% if p.hat_cover %}
<img src="{{ url_for('api.projekt_cover', projekt_id=p.id) }}" alt=""
style="width: 100%; height: 140px; object-fit: cover; border-radius: 6px;">
{% else %}
<div style="height: 140px; background: linear-gradient(135deg, #ECF0F1, #D5DBDB); border-radius: 6px;
display: flex; align-items: center; justify-content: center; font-size: 2rem; color: var(--text-light);">
🎯
</div>
{% endif %}
<h3 style="margin: 0; color: var(--primary-dark);">{{ p.titel }}</h3>
<div style="font-size: 0.85rem;">
{{ tier_emoji(p.tier or '') }} <strong>{{ p.vorname or '—' }}</strong>
</div>
<div style="display: flex; gap: 0.3rem; flex-wrap: wrap; font-size: 0.8rem;">
{% if p.status_lebenszyklus == 'in_arbeit' %}
<span class="badge badge-mittel">🔧 in Arbeit</span>
{% else %}
<span class="badge badge-anfaenger">✅ abgeschlossen</span>
{% endif %}
{% if p.freigabe_status == 'wartet_auf_freigabe' %}
<span class="badge badge-mittel">⏳ wartet</span>
{% elif p.freigabe_status == 'rueckfrage' %}
<span class="badge badge-fortgeschritten">❓ Rueckfrage</span>
{% elif p.freigabe_status == 'freigegeben' %}
<span class="badge badge-anfaenger">✓ frei</span>
{% endif %}
{% if p.selbsteinschaetzung == 'zufrieden' %}<span class="badge badge-anfaenger">🟢</span>
{% elif p.selbsteinschaetzung == 'mittel' %}<span class="badge badge-mittel">🟡</span>
{% elif p.selbsteinschaetzung == 'gelernt' %}<span class="badge badge-fortgeschritten">🔴</span>{% endif %}
</div>
<div style="font-size: 0.75rem; color: var(--text-light); margin-top: auto;">
{% if p.abgeschlossen_am %}abgeschlossen {{ p.abgeschlossen_am[:10] }}
{% else %}geaendert {{ p.geaendert_am[:10] }}{% endif %}
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="card">
<p style="color: var(--text-light);">
Keine Projekte
{% if aktueller_filter != 'alle' %} mit Filter "{{ aktueller_filter }}"
<a href="{{ url_for('admin.projekte_uebersicht', filter='alle') }}">alle anzeigen</a>{% endif %}.
</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,98 @@
{% extends "base.html" %}
{% block title %}Slot fuer SuS anlegen &mdash; Admin{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Slot fuer einen SuS anlegen</h1>
<a href="{{ url_for('admin.slot_uebersicht') }}" class="btn btn-secondary">&larr; Slot-Uebersicht</a>
</div>
{% if not profile %}
<div class="card" style="background:#FADBD8; border-left: 4px solid var(--danger);">
<strong>Keine aktiven SuS-Profile.</strong>
<a href="{{ url_for('admin.profile_liste') }}">Erst ein Profil anlegen.</a>
</div>
{% elif not drucker_liste %}
<div class="card" style="background:#FADBD8; border-left: 4px solid var(--danger);">
<strong>Keine Drucker eingerichtet.</strong>
</div>
{% else %}
<div class="card">
<form method="post" style="display: grid; gap: 1rem;">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
SuS-Profil *
</label>
<select name="profil_id" required
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 1rem;">
<option value="">— waehlen —</option>
{% for p in profile %}
<option value="{{ p.id }}" {% if vor_profil == p.id %}selected{% endif %}>
{{ tier_emoji(p.tier) }} {{ p.vorname }}
</option>
{% endfor %}
</select>
</div>
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
Drucker *
</label>
<select name="drucker_id" required
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 1rem;">
<option value="">— waehlen —</option>
{% for d in drucker_liste %}
<option value="{{ d.id }}" {% if vor_drucker == d.id %}selected{% endif %}>
{{ d.name }} ({{ d.modell }}){% if not d.freigegeben %} 🔒 gesperrt{% endif %}
</option>
{% endfor %}
</select>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
Start *
</label>
<input type="datetime-local" name="start_zeit" required step="900"
value="{{ default_start.strftime('%Y-%m-%dT%H:%M') }}"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 1rem;">
</div>
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
Ende *
</label>
<input type="datetime-local" name="ende_zeit" required step="900"
value="{{ default_ende.strftime('%Y-%m-%dT%H:%M') }}"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 1rem;">
</div>
</div>
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
Notiz (optional)
</label>
<input type="text" name="notiz" maxlength="200"
placeholder="z.B. 'Druck ueber Nacht', 'Lina ist heute krank, ich starte fuer sie'"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 0.95rem;">
</div>
<div style="background: #EBF5FB; padding: 0.6rem 0.8rem; border-radius: 4px; font-size: 0.85rem; color: var(--text);">
<strong>Hinweis:</strong> Du kannst auch fuer SuS reservieren, die noch nicht freigeschaltet
sind, und auch fuer gesperrte Drucker (du bekommst dann eine Warnung).
</div>
<div style="display: flex; justify-content: space-between;">
<a href="{{ url_for('admin.slot_uebersicht') }}" class="btn btn-secondary">Abbrechen</a>
<button class="btn btn-primary">Slot anlegen</button>
</div>
</form>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Slots &mdash; Admin{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Slot-Uebersicht</h1>
<div>
<a href="{{ url_for('admin.slot_neu_lehrer') }}" class="btn btn-primary">+ Neuer Slot fuer SuS</a>
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">&larr; Dashboard</a>
</div>
</div>
<p style="color: var(--text-light);">
Alle Reservierungen, deren Ende noch nicht laenger als 2 Stunden her ist. Sortiert nach Start-Zeit.
</p>
{% if slots %}
<div class="card">
<table>
<thead>
<tr>
<th>Start</th>
<th>Ende</th>
<th>Dauer</th>
<th>Drucker</th>
<th>SuS</th>
<th>Status</th>
<th>Notiz</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in slots %}
<tr {% if s.status == 'storniert' %}style="opacity: 0.5;"{% endif %}>
<td><strong>{{ s.start_zeit|iso_format('%d.%m. %H:%M') }}</strong></td>
<td>{{ s.ende_zeit|iso_format('%H:%M') }}</td>
<td>{{ s.start_zeit|dauer_min(s.ende_zeit) }} min</td>
<td>
{{ s.drucker_name or '—' }}
{% if s.drucker_name and not s.freigegeben %}
<div style="font-size: 0.75rem; color: var(--danger);">🔒 Drucker gesperrt</div>
{% endif %}
</td>
<td>
{% if s.profil_id %}
{{ tier_emoji(s.tier or '') }} <strong>{{ s.vorname or '—' }}</strong>
{% else %}
<em style="color: var(--text-light);">unbekannt</em>
{% endif %}
</td>
<td>
{% if s.status == 'reserviert' %}<span class="badge badge-mittel">reserviert</span>
{% elif s.status == 'aktiv' %}<span class="badge badge-anfaenger">aktiv</span>
{% elif s.status == 'beendet' %}<span class="badge">beendet</span>
{% elif s.status == 'storniert' %}<span class="badge">storniert</span>
{% else %}<span class="badge">{{ s.status }}</span>{% endif %}
</td>
<td style="font-size: 0.85rem; color: var(--text-light);">{{ s.notiz or '' }}</td>
<td>
<form method="post" action="{{ url_for('admin.slot_loeschen', slot_id=s.id) }}" style="display:inline;"
onsubmit="return confirm('Slot von {{ s.vorname or s.id }} wirklich loeschen?')">
<button class="btn btn-secondary" style="padding: 0.3rem 0.6rem; font-size: 0.8rem; color: var(--danger);">loeschen</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card">
<p style="color: var(--text-light);">Keine bevorstehenden Reservierungen.
<a href="{{ url_for('admin.slot_neu_lehrer') }}">Eine fuer einen SuS anlegen.</a>
</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,178 @@
{% extends "base.html" %}
{% block title %}Software &mdash; Admin{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Software-Karten</h1>
<div>
<a href="{{ url_for('oeffentlich.software') }}" class="btn btn-secondary">Oeffentliche Seite ansehen</a>
<a href="{{ url_for('admin.admin_logout') }}" class="btn btn-secondary">Admin-Logout</a>
</div>
</div>
<!-- Liste -->
<div class="card">
<h2>Alle Karten</h2>
{% if karten %}
<table>
<thead>
<tr>
<th></th>
<th>Titel</th>
<th>Slug</th>
<th>Kategorie</th>
<th>Sort.</th>
<th>Plattform</th>
<th>Hauptlink</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for k in karten %}
<tr style="{% if not k.aktiv %}opacity: 0.5;{% endif %}">
<td style="font-size: 1.4rem;">
{%- if k.kategorie == 'app' -%}📱
{%- elif k.kategorie == 'generator' -%}🧩
{%- else -%}🖥️
{%- endif -%}
</td>
<td><strong>{{ k.titel }}</strong>
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.2rem;">{{ k.kurzbeschreibung }}</div>
</td>
<td><code style="font-size: 0.8rem;">{{ k.slug }}</code></td>
<td>{{ k.kategorie }}</td>
<td>{{ k.sortierung }}</td>
<td style="font-size: 0.8rem; color: var(--text-light);">{{ k.plattform or '—' }}</td>
<td style="font-size: 0.8rem;">
{% if k.url %}<a href="{{ k.url }}" target="_blank" rel="noopener noreferrer"></a>{% endif %}
{% if k.tutorial_url %} <a href="{{ k.tutorial_url }}" target="_blank" rel="noopener noreferrer" title="Tutorial">T↗</a>{% endif %}
{% if k.app_store_url %} <a href="{{ k.app_store_url }}" target="_blank" rel="noopener noreferrer" title="App Store"></a>{% endif %}
{% if k.play_store_url %} <a href="{{ k.play_store_url }}" target="_blank" rel="noopener noreferrer" title="Google Play"></a>{% endif %}
</td>
<td>
{% if k.aktiv %}<span class="badge badge-anfaenger">aktiv</span>
{% else %}<span class="badge">inaktiv</span>{% endif %}
</td>
<td>
<a href="{{ url_for('admin.software_bearbeiten', karte_id=k.id) }}"
class="btn btn-primary" style="padding: 0.3rem 0.6rem; font-size: 0.8rem;">bearbeiten</a>
<form method="post" action="{{ url_for('admin.software_aktiv_toggle', karte_id=k.id) }}" style="display:inline;">
<button class="btn btn-secondary" style="padding: 0.3rem 0.6rem; font-size: 0.8rem;">
{% if k.aktiv %}aus{% else %}an{% endif %}
</button>
</form>
<form method="post" action="{{ url_for('admin.software_loeschen', karte_id=k.id) }}" style="display:inline;"
onsubmit="return confirm('Karte {{ k.titel }} wirklich loeschen? Das kann nicht rueckgaengig gemacht werden.')">
<button class="btn btn-secondary" style="padding: 0.3rem 0.6rem; font-size: 0.8rem; color: var(--danger);">loeschen</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color: var(--text-light);">Noch keine Karten. Leg unten die erste an, oder fuehre die Initial-Migration aus
(<code>migrations/2026-05-25_software_sektion.py</code>).</p>
{% endif %}
</div>
<!-- Neue Karte -->
<div class="card">
<h2>Neue Karte anlegen</h2>
<form method="post" action="{{ url_for('admin.software_neu') }}" style="display: grid; gap: 0.8rem;">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.8rem;">
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Slug (URL-Identifier, klein, mit Bindestrichen) *</label>
<input type="text" name="slug" required pattern="[a-z0-9-]+"
placeholder="z.B. fusion360"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Titel *</label>
<input type="text" name="titel" required
placeholder="z.B. Fusion 360"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Kategorie *</label>
<select name="kategorie" required
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
{% for cat in kategorien %}
<option value="{{ cat }}">{{ cat }}</option>
{% endfor %}
</select>
</div>
</div>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Kurzbeschreibung (1-2 Saetze, erscheint auf der Karte) *</label>
<input type="text" name="kurzbeschreibung" required maxlength="200"
placeholder="z.B. Profi-CAD-Software, fuer Schueler kostenlos."
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.8rem;">
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Hauptlink (Startseite / Download) *</label>
<input type="url" name="url" required
placeholder="https://..."
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Tutorial-Link (optional)</label>
<input type="url" name="tutorial_url"
placeholder="https://..."
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 0.8rem;">
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Plattform (Klartext)</label>
<input type="text" name="plattform"
placeholder="z.B. Browser oder Windows / macOS / Linux"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Sortierung (kleiner = weiter oben)</label>
<input type="number" name="sortierung" value="100" min="0" max="9999"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
</div>
<fieldset style="border: 1px dashed var(--border); padding: 0.8rem; border-radius: 4px;">
<legend style="font-size: 0.85rem; color: var(--text-light); padding: 0 0.4rem;">Nur bei Apps</legend>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.8rem;">
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">App Store URL</label>
<input type="url" name="app_store_url"
placeholder="https://apps.apple.com/..."
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Google Play URL</label>
<input type="url" name="play_store_url"
placeholder="https://play.google.com/..."
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
</div>
</fieldset>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Bild-Pfad (relativ zu <code>static/img/software/</code>, optional)</label>
<input type="text" name="bild_pfad"
placeholder="z.B. fusion360.png"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
<p style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.3rem;">
Erst das Bild ueber FTP / SCP nach <code>static/img/software/</code> kopieren, dann den Dateinamen hier eintragen.
</p>
</div>
<div style="display: flex; justify-content: flex-end;">
<button class="btn btn-primary">Karte anlegen</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,354 @@
{% extends "base.html" %}
{% block title %}Bild: {{ karte.titel }} &mdash; Admin{% endblock %}
{% block content %}
<p><a href="{{ url_for('admin.software_bearbeiten', karte_id=karte.id) }}">&larr; Zurueck zur Karte</a></p>
<div class="card">
<h1>Bild fuer: {{ karte.titel }}</h1>
<p style="color: var(--text-light); font-size: 0.9rem;">
Slug: <code>{{ karte.slug }}</code> &middot; Kategorie: {{ karte.kategorie }}
</p>
</div>
<!-- Paste-Bereich -->
<div class="card" id="paste-bereich">
<h2>1. Screenshot einfuegen</h2>
<p style="color: var(--text-light); margin-bottom: 1rem;">
Kopiere ein Bild in die Zwischenablage (z.B. Screenshot mit Snipping Tool)
und druecke <kbd style="background:#eee;padding:0.2rem 0.5rem;border-radius:4px;border:1px solid #ccc;">Strg+V</kbd>
hier auf der Seite. Oder waehle eine Datei aus.
</p>
<div id="drop-zone" style="border: 3px dashed var(--border); border-radius: 12px; padding: 3rem;
text-align: center; cursor: pointer; transition: border-color 0.2s; background: #fafafa;">
<p style="font-size: 1.2rem; color: var(--text-light);">Strg+V zum Einfuegen</p>
<p style="font-size: 0.85rem; color: var(--text-light);">oder klicken um Datei auszuwaehlen</p>
<input type="file" id="datei-input" accept="image/*" style="display: none;">
</div>
</div>
<!-- Crop-Bereich (anfangs versteckt) -->
<div class="card" id="crop-bereich" style="display: none;">
<h2>2. Ausschnitt waehlen</h2>
<p style="color: var(--text-light); margin-bottom: 1rem;">
Ziehe das Quadrat auf den gewuenschten Bildausschnitt. Ecken zum Vergroessern/Verkleinern ziehen.
</p>
<div style="display: flex; gap: 2rem; flex-wrap: wrap;">
<!-- Quellbild mit Crop-Overlay -->
<div style="flex: 1; min-width: 300px;">
<div id="crop-container" style="position: relative; display: inline-block; max-width: 100%; cursor: crosshair;">
<canvas id="quell-canvas" style="max-width: 100%; display: block;"></canvas>
<div id="crop-box" style="position: absolute; border: 3px solid var(--primary);
background: rgba(0,151,157,0.1); cursor: move; display: none;">
<div class="resize-handle" data-pos="tl" style="position:absolute;top:-6px;left:-6px;width:12px;height:12px;background:var(--primary);border-radius:50%;cursor:nw-resize;"></div>
<div class="resize-handle" data-pos="tr" style="position:absolute;top:-6px;right:-6px;width:12px;height:12px;background:var(--primary);border-radius:50%;cursor:ne-resize;"></div>
<div class="resize-handle" data-pos="bl" style="position:absolute;bottom:-6px;left:-6px;width:12px;height:12px;background:var(--primary);border-radius:50%;cursor:sw-resize;"></div>
<div class="resize-handle" data-pos="br" style="position:absolute;bottom:-6px;right:-6px;width:12px;height:12px;background:var(--primary);border-radius:50%;cursor:se-resize;"></div>
</div>
</div>
</div>
<!-- Vorschau -->
<div style="min-width: 200px; text-align: center;">
<h3>Vorschau</h3>
<canvas id="vorschau-canvas" width="200" height="200"
style="border: 1px solid var(--border); border-radius: 8px; background: #f0f0f0;"></canvas>
</div>
</div>
<div style="margin-top: 1.5rem; display: flex; gap: 1rem;">
<button onclick="speichern()" class="btn btn-primary" id="speichern-btn"
style="padding: 0.8rem 2rem; font-size: 1rem;">Bild speichern</button>
<button onclick="zuruecksetzen()" class="btn btn-secondary">Neues Bild</button>
</div>
</div>
<!-- Erfolg -->
<div class="card" id="erfolg-bereich" style="display: none; border-left: 4px solid var(--success);">
<h2 style="color: var(--success);">Bild gespeichert!</h2>
<p>Das Bild fuer <strong>{{ karte.titel }}</strong> wurde erfolgreich gespeichert.</p>
<div style="margin-top: 1rem;">
<a href="{{ url_for('admin.software_bearbeiten', karte_id=karte.id) }}" class="btn btn-primary">Zurueck zur Karte</a>
<a href="{{ url_for('admin.software_liste') }}" class="btn btn-secondary">Alle Karten</a>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
var quellCanvas = document.getElementById('quell-canvas');
var quellCtx = quellCanvas.getContext('2d');
var vorschauCanvas = document.getElementById('vorschau-canvas');
var vorschauCtx = vorschauCanvas.getContext('2d');
var cropBox = document.getElementById('crop-box');
var cropContainer = document.getElementById('crop-container');
var originalBild = null;
var skalierung = 1;
var crop = { x: 0, y: 0, size: 100 };
var dragging = false;
var resizing = false;
var resizePos = '';
var startX, startY, startCropX, startCropY, startCropSize;
function bildLaden(quelle) {
var img = new Image();
img.onload = function() {
originalBild = img;
var maxBreite = 600;
skalierung = Math.min(1, maxBreite / img.width);
quellCanvas.width = img.width * skalierung;
quellCanvas.height = img.height * skalierung;
quellCtx.drawImage(img, 0, 0, quellCanvas.width, quellCanvas.height);
var minSeite = Math.min(quellCanvas.width, quellCanvas.height);
crop.size = Math.round(minSeite * 0.6);
crop.x = Math.round((quellCanvas.width - crop.size) / 2);
crop.y = Math.round((quellCanvas.height - crop.size) / 2);
cropBoxAktualisieren();
cropBox.style.display = 'block';
vorschauAktualisieren();
document.getElementById('paste-bereich').style.display = 'none';
document.getElementById('crop-bereich').style.display = 'block';
};
img.src = quelle;
}
document.addEventListener('paste', function(e) {
if (originalBild) return;
var items = e.clipboardData.items;
for (var i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') >= 0) {
var blob = items[i].getAsFile();
var reader = new FileReader();
reader.onload = function(ev) { bildLaden(ev.target.result); };
reader.readAsDataURL(blob);
e.preventDefault();
return;
}
}
});
var dropZone = document.getElementById('drop-zone');
var dateiInput = document.getElementById('datei-input');
dropZone.addEventListener('click', function() { dateiInput.click(); });
dateiInput.addEventListener('change', function() {
if (this.files[0]) {
var reader = new FileReader();
reader.onload = function(ev) { bildLaden(ev.target.result); };
reader.readAsDataURL(this.files[0]);
}
});
dropZone.addEventListener('dragover', function(e) {
e.preventDefault();
this.style.borderColor = 'var(--primary)';
this.style.background = '#EBF5FB';
});
dropZone.addEventListener('dragleave', function() {
this.style.borderColor = 'var(--border)';
this.style.background = '#fafafa';
});
dropZone.addEventListener('drop', function(e) {
e.preventDefault();
this.style.borderColor = 'var(--border)';
this.style.background = '#fafafa';
if (e.dataTransfer.files[0] && e.dataTransfer.files[0].type.indexOf('image') >= 0) {
var reader = new FileReader();
reader.onload = function(ev) { bildLaden(ev.target.result); };
reader.readAsDataURL(e.dataTransfer.files[0]);
}
});
function cropBoxAktualisieren() {
cropBox.style.left = crop.x + 'px';
cropBox.style.top = crop.y + 'px';
cropBox.style.width = crop.size + 'px';
cropBox.style.height = crop.size + 'px';
}
function vorschauAktualisieren() {
if (!originalBild) return;
var sx = crop.x / skalierung;
var sy = crop.y / skalierung;
var ss = crop.size / skalierung;
vorschauCtx.clearRect(0, 0, 200, 200);
vorschauCtx.drawImage(originalBild, sx, sy, ss, ss, 0, 0, 200, 200);
}
function begrenzen() {
var maxX = quellCanvas.width - crop.size;
var maxY = quellCanvas.height - crop.size;
crop.x = Math.max(0, Math.min(crop.x, maxX));
crop.y = Math.max(0, Math.min(crop.y, maxY));
crop.size = Math.max(30, Math.min(crop.size,
Math.min(quellCanvas.width - crop.x, quellCanvas.height - crop.y)));
}
cropContainer.addEventListener('mousedown', function(e) {
if (!originalBild) return;
var rect = quellCanvas.getBoundingClientRect();
var mx = e.clientX - rect.left;
var my = e.clientY - rect.top;
var handle = e.target.closest('.resize-handle');
if (handle) {
resizing = true;
resizePos = handle.dataset.pos;
startX = e.clientX;
startY = e.clientY;
startCropX = crop.x;
startCropY = crop.y;
startCropSize = crop.size;
e.preventDefault();
return;
}
if (mx >= crop.x && mx <= crop.x + crop.size &&
my >= crop.y && my <= crop.y + crop.size) {
dragging = true;
startX = e.clientX;
startY = e.clientY;
startCropX = crop.x;
startCropY = crop.y;
e.preventDefault();
}
});
document.addEventListener('mousemove', function(e) {
if (dragging) {
var dx = e.clientX - startX;
var dy = e.clientY - startY;
crop.x = startCropX + dx;
crop.y = startCropY + dy;
begrenzen();
cropBoxAktualisieren();
vorschauAktualisieren();
}
if (resizing) {
var dx = e.clientX - startX;
var dy = e.clientY - startY;
if (resizePos === 'br') {
var delta = Math.max(dx, dy);
crop.size = Math.max(30, startCropSize + delta);
} else if (resizePos === 'tl') {
var delta = Math.min(dx, dy);
crop.x = startCropX + delta;
crop.y = startCropY + delta;
crop.size = startCropSize - delta;
} else if (resizePos === 'tr') {
var delta = Math.max(dx, -dy);
crop.y = startCropY - delta;
crop.size = startCropSize + delta;
} else if (resizePos === 'bl') {
var delta = Math.max(-dx, dy);
crop.x = startCropX - delta;
crop.size = startCropSize + delta;
}
crop.size = Math.max(30, crop.size);
begrenzen();
cropBoxAktualisieren();
vorschauAktualisieren();
}
});
document.addEventListener('mouseup', function() {
dragging = false;
resizing = false;
});
// Touch-Events fuer mobile/Tablet
cropContainer.addEventListener('touchstart', function(e) {
if (!originalBild) return;
var touch = e.touches[0];
var rect = quellCanvas.getBoundingClientRect();
var mx = touch.clientX - rect.left;
var my = touch.clientY - rect.top;
if (mx >= crop.x && mx <= crop.x + crop.size &&
my >= crop.y && my <= crop.y + crop.size) {
dragging = true;
startX = touch.clientX;
startY = touch.clientY;
startCropX = crop.x;
startCropY = crop.y;
e.preventDefault();
}
}, {passive: false});
document.addEventListener('touchmove', function(e) {
if (dragging) {
var touch = e.touches[0];
var dx = touch.clientX - startX;
var dy = touch.clientY - startY;
crop.x = startCropX + dx;
crop.y = startCropY + dy;
begrenzen();
cropBoxAktualisieren();
vorschauAktualisieren();
e.preventDefault();
}
}, {passive: false});
document.addEventListener('touchend', function() {
dragging = false;
});
function speichern() {
if (!originalBild) return;
var btn = document.getElementById('speichern-btn');
btn.disabled = true;
btn.textContent = 'Speichern...';
// 400x400 quadratisch, JPEG 85% Qualitaet
var exportCanvas = document.createElement('canvas');
exportCanvas.width = 400;
exportCanvas.height = 400;
var ctx = exportCanvas.getContext('2d');
var sx = crop.x / skalierung;
var sy = crop.y / skalierung;
var ss = crop.size / skalierung;
ctx.drawImage(originalBild, sx, sy, ss, ss, 0, 0, 400, 400);
var dataUrl = exportCanvas.toDataURL('image/jpeg', 0.85);
fetch('{{ url_for("admin.software_bild", karte_id=karte.id) }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({bild_base64: dataUrl})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
alert('Fehler: ' + data.error);
btn.disabled = false;
btn.textContent = 'Bild speichern';
} else {
document.getElementById('crop-bereich').style.display = 'none';
document.getElementById('erfolg-bereich').style.display = 'block';
}
})
.catch(function() {
alert('Verbindungsfehler');
btn.disabled = false;
btn.textContent = 'Bild speichern';
});
}
function zuruecksetzen() {
originalBild = null;
cropBox.style.display = 'none';
vorschauCtx.clearRect(0, 0, 200, 200);
document.getElementById('crop-bereich').style.display = 'none';
document.getElementById('paste-bereich').style.display = 'block';
}
</script>
{% endblock %}

View File

@@ -0,0 +1,138 @@
{% extends "base.html" %}
{% block title %}{{ karte.titel }} bearbeiten &mdash; Admin{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Karte bearbeiten: {{ karte.titel }}</h1>
<div>
<a href="{{ url_for('admin.software_liste') }}" class="btn btn-secondary">&larr; Alle Karten</a>
</div>
</div>
{# ---- Bild-Sektion ---- #}
<div class="card">
<h2>Bild</h2>
<div style="display: flex; gap: 1.5rem; align-items: flex-start; flex-wrap: wrap;">
<div style="flex: 0 0 200px; text-align: center;">
{% if karte.hat_bild_blob %}
<img src="{{ url_for('api.software_bild', karte_id=karte.id) }}?v={{ karte.id }}"
style="max-width: 200px; max-height: 200px; border-radius: 8px; border: 1px solid var(--border); background: #fff;">
{% elif karte.bild_pfad %}
<img src="{{ url_for('static', filename='img/software/' ~ karte.bild_pfad) }}"
style="max-width: 200px; max-height: 200px; border-radius: 8px; border: 1px solid var(--border); background: #fff;">
{% else %}
<div style="width:200px; height:200px; background:#ECF0F1; border-radius: 8px; display:flex; align-items:center; justify-content:center; font-size:3rem; color: var(--text-light);">
{%- if karte.kategorie == 'app' -%}📱
{%- elif karte.kategorie == 'generator' -%}🧩
{%- else -%}🖥️
{%- endif -%}
</div>
{% endif %}
</div>
<div style="flex: 1; min-width: 250px;">
<p style="margin-bottom: 0.8rem; color: var(--text-light);">
Logo / Screenshot fuer die Karte. Wird auf der oeffentlichen Software-Seite angezeigt.
</p>
<a href="{{ url_for('admin.software_bild', karte_id=karte.id) }}" class="btn btn-primary">
Bild einfuegen / aendern
</a>
{% if karte.hat_bild_blob %}
<form method="post" action="{{ url_for('admin.software_bild_loeschen', karte_id=karte.id) }}" style="display:inline;"
onsubmit="return confirm('Bild wirklich loeschen?')">
<button class="btn btn-secondary" style="color: var(--danger);">Bild loeschen</button>
</form>
{% endif %}
</div>
</div>
</div>
{# ---- Daten-Sektion ---- #}
<div class="card">
<h2>Inhalt</h2>
<form method="post" style="display: grid; gap: 0.8rem;">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.8rem;">
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Slug (unveraenderbar)</label>
<input type="text" value="{{ karte.slug }}" disabled
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; background: #f5f5f5;">
</div>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Titel *</label>
<input type="text" name="titel" required value="{{ karte.titel }}"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Kategorie *</label>
<select name="kategorie" required
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
{% for cat in kategorien %}
<option value="{{ cat }}" {% if cat == karte.kategorie %}selected{% endif %}>{{ cat }}</option>
{% endfor %}
</select>
</div>
</div>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Kurzbeschreibung (1-2 Saetze, erscheint auf der Karte) *</label>
<input type="text" name="kurzbeschreibung" required maxlength="200" value="{{ karte.kurzbeschreibung }}"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.8rem;">
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Hauptlink (Startseite / Download) *</label>
<input type="url" name="url" required value="{{ karte.url }}"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Tutorial-Link (optional)</label>
<input type="url" name="tutorial_url" value="{{ karte.tutorial_url or '' }}"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 0.8rem;">
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Plattform (Klartext)</label>
<input type="text" name="plattform" value="{{ karte.plattform or '' }}"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Sortierung (kleiner = weiter oben)</label>
<input type="number" name="sortierung" value="{{ karte.sortierung }}" min="0" max="9999"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
</div>
<fieldset style="border: 1px dashed var(--border); padding: 0.8rem; border-radius: 4px;">
<legend style="font-size: 0.85rem; color: var(--text-light); padding: 0 0.4rem;">Nur bei Apps</legend>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.8rem;">
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">App Store URL</label>
<input type="url" name="app_store_url" value="{{ karte.app_store_url or '' }}"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">Google Play URL</label>
<input type="url" name="play_store_url" value="{{ karte.play_store_url or '' }}"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
</div>
</fieldset>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light);">
Bild-Pfad (Fallback, relativ zu <code>static/img/software/</code>) — meist leer lassen, weil das Crop-Bild oben Vorrang hat
</label>
<input type="text" name="bild_pfad" value="{{ karte.bild_pfad or '' }}"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 0.5rem;">
<a href="{{ url_for('admin.software_liste') }}" class="btn btn-secondary">Abbrechen</a>
<button class="btn btn-primary">Aenderungen speichern</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -99,7 +99,7 @@
</form>
</div>
{% else %}
<div class="card" style="background: #EAFBF1; border-left: 4px solid var(--success);">
<div class="card" style="background: #EAFBF1; border-left: 4px solid var(--success); display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap;">
<p style="margin: 0; font-size: 0.9rem;">
<strong>Heute eingecheckt:</strong>
{% if status.check_in_heute.modus == 'weiter_wie_bisher' %}Arbeite weiter
@@ -110,6 +110,85 @@
{% else %}{{ status.check_in_heute.modus }}{% endif %}
{% if status.check_in_heute.freitext %} &mdash; „{{ status.check_in_heute.freitext }}"{% endif %}
</p>
<form method="post" action="{{ url_for('profil.check_in_zuruecksetzen') }}" style="margin: 0;">
<button class="btn btn-secondary" style="padding: 0.3rem 0.7rem; font-size: 0.8rem;">
Modus aendern
</button>
</form>
</div>
{% endif %}
{# ====== PROJEKT-KARTE — ganz oben, damit "Hier weitermachen" sofort sichtbar ist ====== #}
{% if status.hat_projekte or not status.ist_neu or status.projekt.letztes %}
<div class="card" style="margin-top: 1rem;
{% if status.projekt.aktiv %}border-left: 4px solid var(--success);{% endif %}">
<h2>{% if status.paarung %}Euer Projekt{% else %}Dein Projekt{% endif %}</h2>
{% if status.projekt.aktiv %}
{# === Aktives Projekt: Weitermachen-Karte === #}
<div style="display: flex; gap: 1rem; align-items: center; margin-top: 0.5rem; flex-wrap: wrap;">
<div style="flex: 0 0 120px;">
{% if status.projekt.aktiv.hat_cover %}
<img src="{{ url_for('api.projekt_cover', projekt_id=status.projekt.aktiv.id) }}" alt=""
style="width: 120px; height: 120px; object-fit: cover; border-radius: 8px;">
{% else %}
<div style="width: 120px; height: 120px; background: linear-gradient(135deg, #ECF0F1, #D5DBDB);
border-radius: 8px; display: flex; align-items: center; justify-content: center;
font-size: 2.5rem; color: var(--text-light);">🎯</div>
{% endif %}
</div>
<div style="flex: 1; min-width: 250px;">
<h3 style="margin: 0 0 0.4rem 0;">{{ status.projekt.aktiv.titel }}</h3>
{% if status.projekt.aktiv.beschreibung_md %}
<div style="font-size: 0.9rem; color: var(--text); max-height: 4.5em; overflow: hidden;">
{{ status.projekt.aktiv.beschreibung_md|markdown }}
</div>
{% endif %}
<div style="margin-top: 0.8rem; display: flex; gap: 0.5rem; flex-wrap: wrap;">
<a href="{{ url_for('profil.projekt_detail', projekt_id=status.projekt.aktiv.id) }}"
class="btn btn-primary">Hier weitermachen →</a>
<a href="{{ url_for('profil.projekt_abschluss', projekt_id=status.projekt.aktiv.id) }}"
class="btn btn-secondary" style="font-size: 0.85rem;">Abschliessen</a>
</div>
</div>
</div>
{% elif status.projekt.letztes and status.projekt.letztes.freigabe_status == 'wartet_auf_freigabe' %}
<div style="background: #FEF9E7; padding: 0.8rem; border-radius: 6px; border-left: 4px solid var(--warning);">
<strong>⏳ Wartet auf Freigabe:</strong>
<a href="{{ url_for('profil.projekt_detail', projekt_id=status.projekt.letztes.id) }}">{{ status.projekt.letztes.titel }}</a>
<div style="font-size: 0.85rem; margin-top: 0.3rem; color: var(--text-light);">
Der Lehrer schaut drueber. Sobald freigegeben, kannst du ein neues Projekt anfangen.
</div>
</div>
{% elif status.projekt.letztes and status.projekt.letztes.freigabe_status == 'rueckfrage' %}
<div style="background: #FADBD8; padding: 0.8rem; border-radius: 6px; border-left: 4px solid var(--danger);">
<strong>❓ Rueckfrage offen zu:</strong>
<a href="{{ url_for('profil.projekt_detail', projekt_id=status.projekt.letztes.id) }}">{{ status.projekt.letztes.titel }}</a>
<div style="font-size: 0.85rem; margin-top: 0.3rem;">
Siehe Rueckfrage-Banner oben, beantworte sie — dann kann der Lehrer freigeben.
</div>
</div>
{% else %}
<p style="color: var(--text-light); margin-bottom: 0.8rem;">
Du hast gerade kein laufendes Projekt. Bereit fuer was Neues?
</p>
{% if status.projekt.darf_neues %}
<a href="{{ url_for('profil.projekt_neu') }}" class="btn btn-primary">+ Neues Projekt anfangen</a>
{% else %}
<div style="background: #FEF9E7; padding: 0.6rem 0.8rem; border-radius: 6px; font-size: 0.9rem;">
{{ status.projekt.sperr_grund }}
</div>
{% endif %}
{% endif %}
{% if status.projekt.aktiv or status.projekt.letztes %}
<div style="margin-top: 0.8rem; font-size: 0.85rem;">
<a href="{{ url_for('profil.projekte_liste') }}" style="color: var(--text-light);">Alle meine Projekte ansehen →</a>
</div>
{% endif %}
</div>
{% endif %}
@@ -163,30 +242,6 @@
</div>
{% endif %}
{# ====== PROJEKT-KARTE (V1: nur wenn Projekte oder Onboarding fertig) ====== #}
{% if status.hat_projekte or not status.ist_neu %}
<div class="card">
<h2>{% if status.paarung %}Euer Projekt{% else %}Dein Projekt{% endif %}</h2>
{% if status.aktive_projekte %}
{% for p in status.aktive_projekte %}
<div style="margin-bottom: 0.8rem;">
<strong>{{ p.titel }}</strong>
{% if p.beschreibung_md %}
<div style="margin-top: 0.3rem; font-size: 0.9rem;">{{ p.beschreibung_md|markdown }}</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<p style="color: var(--text-light);">
Du hast noch kein laufendes Projekt.
</p>
<p style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-light);">
<em>Projekt-Anlage kommt mit Etappe 6.</em>
</p>
{% endif %}
</div>
{% endif %}
{# ====== PAARUNG / TEAM-KARTE — immer wenn Paarung aktiv, sonst nur fuer Erfahrene ====== #}
{% if status.paarung or not status.ist_neu %}
<div class="card">

163
templates/drucker.html Normal file
View File

@@ -0,0 +1,163 @@
{% extends "base.html" %}
{% block title %}Drucker{% endblock %}
{% block content %}
<style>
.druckerkachel-gross {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1.2rem;
}
.druckerkachel-gross h2 { margin: 0; color: var(--primary-dark); }
.druckerkachel-gross .modell { font-size: 0.85rem; color: var(--text-light); }
.druckerkachel-gross .status-zeile { font-size: 1rem; font-weight: 600; margin-top: 0.3rem; }
.druckerkachel-gross .progress {
font-size: 0.85rem; color: var(--text-light); margin-top: 0.2rem;
}
.druckerkachel-gross .sperre-banner {
background: #FADBD8; color: #943126; border: 1px solid #F5B7B1;
border-radius: 6px; padding: 0.6rem 0.8rem; font-size: 0.9rem;
}
.druckerkachel-gross .api-hinweis {
font-size: 0.8rem; color: var(--text-light); font-style: italic;
}
.druckerkachel-gross .links {
display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: auto; padding-top: 0.5rem;
}
.druckerkachel-gross .links .btn { font-size: 0.8rem; padding: 0.35rem 0.7rem; }
.druckerkachel-gross.gesperrt { border-left: 4px solid var(--danger); }
</style>
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Drucker</h1>
<a href="{{ url_for('profil.slots') }}" class="btn btn-secondary">Meine Reservierungen</a>
</div>
<p style="color: var(--text-light);">
Aktueller Stand unserer 4 Bambu-Lab-Drucker. <strong>Kein Drucken ohne Reservierung</strong> — es
muss immer klar sein, wer gerade an welchem Drucker druckt.
</p>
{% if not darf_reservieren %}
<div class="card" style="background:#FEF9E7; border-left: 4px solid var(--warning);">
<strong>Reservierung noch nicht freigeschaltet.</strong>
Du kannst die Drucker bisher nur ansehen. Lass dich beim Lehrer freischalten,
dann kannst du selbst Slots reservieren.
</div>
{% endif %}
{% set gesperrte = drucker_liste | selectattr('freigegeben', 'equalto', 0) | list %}
{% if gesperrte %}
<div class="card" style="background:#FADBD8; border-left: 4px solid var(--danger);">
<strong>⚠ {{ gesperrte|length }} Drucker derzeit gesperrt:</strong>
{{ gesperrte | map(attribute='name') | join(', ') }}
— Details auf den jeweiligen Kacheln.
</div>
{% endif %}
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));">
{% for d in drucker_liste %}
<div class="card druckerkachel-gross drucker-{{ d.status or 'idle' }} {% if not d.freigegeben %}gesperrt{% endif %}">
<div>
<h2>{{ d.name }}</h2>
<div class="modell">
{{ d.modell }}{% if not d.geschlossenes_gehaeuse %} · <span style="color: var(--danger);">offen</span>{% endif %}
</div>
</div>
{# Sperre-Banner hat Vorrang vor allem anderen #}
{% if not d.freigegeben %}
<div class="sperre-banner">
<strong>Gesperrt</strong>{% if d.gesperrt_grund %} — {{ d.gesperrt_grund }}{% endif %}
<div style="font-size: 0.8rem; margin-top: 0.3rem;">Reservierung derzeit nicht moeglich.</div>
</div>
{% endif %}
{# Status-Zeile #}
<div class="status-zeile">
{% if d.status == 'printing' %}🟢 druckt
{% elif d.status == 'idle' %}⚪ frei
{% elif d.status == 'paused' %}🟡 pausiert
{% elif d.status == 'finished' %}✅ fertig — Druck abholen
{% elif d.status == 'failed' %}🔴 Fehler
{% elif d.status == 'offline' %}⚫ offline
{% else %}⚪ kein Status
{% endif %}
</div>
{# Job-Details wenn aktiv #}
{% if d.job_name %}<div class="progress">{{ d.job_name }}</div>{% endif %}
{% if d.progress_pct %}
<div class="progress">
{{ d.progress_pct|round|int }} %{% if d.restzeit_min %} · noch {{ d.restzeit_min }} min{% endif %}
</div>
{% endif %}
{% if d.fehlermeldung %}
<div style="color: var(--danger); font-size: 0.9rem;">{{ d.fehlermeldung }}</div>
{% endif %}
{# Notiz vom Lehrer (auch wenn nicht gesperrt) #}
{% if d.notiz and d.freigegeben %}
<div style="font-size: 0.85rem; color: var(--text-light);"> {{ d.notiz }}</div>
{% endif %}
{# Hinweis wenn noch keine API-Daten #}
{% if not d.status %}
<div class="api-hinweis">
(Live-Status via Bambu-API kommt mit Etappe 5. Bis dahin pflegt der Lehrer den Status manuell.)
</div>
{% elif d.manuell_gesetzt %}
<div class="api-hinweis">vom Lehrer gesetzt</div>
{% endif %}
{# Belegung: aktuell + naechste 3 Slots #}
<div style="background:#F4F6F7; border-radius: 6px; padding: 0.6rem 0.8rem; font-size: 0.85rem;">
{% if d.belegung %}
<div><strong>🔖 Jetzt belegt:</strong>
{{ tier_emoji(d.belegung.tier or '') }} {{ d.belegung.vorname or '—' }}
bis {{ d.belegung.ende_zeit|iso_format('%H:%M') }} Uhr
</div>
{% else %}
<div><strong>🔖 Aktuell frei.</strong></div>
{% endif %}
{% if d.kommend %}
<div style="margin-top: 0.3rem; color: var(--text-light);">
Naechste:
{% for s in d.kommend %}
{{ s.start_zeit|iso_format('%d.%m. %H:%M') }}{{ s.ende_zeit|iso_format('%H:%M') }}
({{ s.vorname or '—' }}){% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
</div>
{# Reservieren-Button #}
{% if d.freigegeben and darf_reservieren %}
<a href="{{ url_for('profil.slot_neu', drucker_id=d.id) }}" class="btn btn-primary"
style="text-align: center;">+ Diesen Drucker reservieren</a>
{% elif d.freigegeben and not darf_reservieren %}
<div style="font-size: 0.8rem; color: var(--text-light); font-style: italic;">
Reservierung ist fuer dich noch nicht freigeschaltet.
</div>
{% endif %}
{# Manual + Troubleshooting #}
<div class="links">
{% if d.manual %}
<a href="{{ d.manual }}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary">📖 Manual ↗</a>
{% endif %}
{% if d.troubleshooting %}
<a href="{{ d.troubleshooting }}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary">🔧 Hilfe ↗</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% if not drucker_liste %}
<div class="card">
<p style="color: var(--text-light);">Noch keine Drucker eingerichtet.</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}Drucker reservieren{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Drucker reservieren</h1>
<a href="{{ url_for('profil.slots') }}" class="btn btn-secondary">Meine Slots</a>
</div>
{% if not drucker_liste %}
<div class="card" style="background:#FADBD8; border-left: 4px solid var(--danger);">
<strong>Kein Drucker frei.</strong> Alle Drucker sind derzeit gesperrt (Wartung / Reparatur).
Frag den Lehrer, wann wieder reserviert werden kann.
</div>
{% else %}
<div class="card">
<form method="post" style="display: grid; gap: 1rem;">
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
Drucker *
</label>
<select name="drucker_id" required
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 1rem;">
<option value="">— bitte waehlen —</option>
{% for d in drucker_liste %}
<option value="{{ d.id }}" {% if vorgewaehlt_drucker == d.id %}selected{% endif %}>
{{ d.name }} ({{ d.modell }})
</option>
{% endfor %}
</select>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
Start *
</label>
<input type="datetime-local" name="start_zeit" required step="900"
value="{{ eingabe_start or default_start.strftime('%Y-%m-%dT%H:%M') }}"
min="{{ default_start.strftime('%Y-%m-%dT%H:%M') }}"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 1rem;">
</div>
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
Ende *
</label>
<input type="datetime-local" name="ende_zeit" required step="900"
value="{{ eingabe_ende or default_ende.strftime('%Y-%m-%dT%H:%M') }}"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 1rem;">
</div>
</div>
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
Notiz (optional)
</label>
<input type="text" name="notiz" maxlength="200"
value="{{ eingabe_notiz or '' }}"
placeholder="z.B. 'Schluesselanhaenger fuer Mama' oder '6h-Druck, lasse ueber Nacht'"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 0.95rem;">
</div>
<div style="background: #EBF5FB; padding: 0.6rem 0.8rem; border-radius: 4px; font-size: 0.85rem; color: var(--text);">
<strong>Regeln:</strong> Mindest-Dauer 15 Minuten, Maximum 24 Stunden.
Achte darauf, dass dein Druck in deinem Slot fertig wird — sonst kann der naechste SuS nicht starten.
</div>
<div style="display: flex; justify-content: space-between;">
<a href="{{ url_for('oeffentlich.drucker') }}" class="btn btn-secondary">Abbrechen</a>
<button class="btn btn-primary">Reservieren</button>
</div>
</form>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}Meine Slots{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Meine Drucker-Reservierungen</h1>
<div>
{% if darf_reservieren %}
<a href="{{ url_for('profil.slot_neu') }}" class="btn btn-primary">+ Neue Reservierung</a>
{% endif %}
<a href="{{ url_for('oeffentlich.drucker') }}" class="btn btn-secondary">Drucker-Uebersicht</a>
</div>
</div>
{% if not darf_reservieren %}
<div class="card" style="background:#FEF9E7; border-left: 4px solid var(--warning);">
<strong>Reservierung noch nicht freigeschaltet.</strong>
Bitte den Lehrer kurz darum — er muss dich einmalig freischalten, danach kannst du selbst reservieren.
</div>
{% endif %}
{% if slots %}
<div class="card">
<table>
<thead>
<tr>
<th>Drucker</th>
<th>Start</th>
<th>Ende</th>
<th>Dauer</th>
<th>Status</th>
<th>Notiz</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in slots %}
<tr>
<td><strong>{{ s.drucker_name }}</strong>
<div style="font-size: 0.8rem; color: var(--text-light);">{{ s.modell }}</div>
</td>
<td>{{ s.start_zeit|iso_format('%d.%m. %H:%M') }}</td>
<td>{{ s.ende_zeit|iso_format('%d.%m. %H:%M') }}</td>
<td>{{ s.start_zeit|dauer_min(s.ende_zeit) }} min</td>
<td>
{% if s.status == 'reserviert' %}<span class="badge badge-mittel">reserviert</span>
{% elif s.status == 'aktiv' %}<span class="badge badge-anfaenger">aktiv</span>
{% else %}<span class="badge">{{ s.status }}</span>{% endif %}
</td>
<td style="font-size: 0.85rem; color: var(--text-light);">{{ s.notiz or '' }}</td>
<td>
<form method="post" action="{{ url_for('profil.slot_stornieren', slot_id=s.id) }}"
onsubmit="return confirm('Slot wirklich stornieren?')" style="display: inline;">
<button class="btn btn-secondary" style="padding: 0.3rem 0.7rem; font-size: 0.85rem; color: var(--danger);">
stornieren
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card">
<p style="color: var(--text-light);">
Du hast aktuell keine bevorstehenden Reservierungen.
{% if darf_reservieren %}
<a href="{{ url_for('profil.slot_neu') }}">Jetzt eine Reservierung anlegen.</a>
{% endif %}
</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,129 @@
{% extends "base.html" %}
{% block title %}{{ p.titel }} abschliessen{% endblock %}
{% block content %}
<style>
.eval-gruppe { display: grid; gap: 0.5rem; }
.eval-titel { font-size: 0.95rem; font-weight: 600; color: var(--text); margin-bottom: 0.3rem; }
.eval-untertitel { font-size: 0.8rem; color: var(--text-light); margin-bottom: 0.6rem; font-weight: normal; }
.eval-optionen { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; }
.eval-option {
cursor: pointer; padding: 0.7rem 0.5rem; border-radius: 6px; text-align: center;
background: #F4F6F7; border: 2px solid transparent; transition: all 0.15s;
}
.eval-option:hover { background: #ECF0F1; }
.eval-option input[type=radio] { display: none; }
.eval-option input[type=radio]:checked + span { font-weight: 600; }
.eval-option:has(input[type=radio]:checked) {
background: #EBF5FB; border-color: var(--primary);
}
.eval-emoji { font-size: 1.4rem; display: block; margin-bottom: 0.2rem; }
</style>
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>"{{ p.titel }}" abschliessen</h1>
<a href="{{ url_for('profil.projekt_detail', projekt_id=p.id) }}" class="btn btn-secondary">Abbrechen</a>
</div>
<div class="card" style="background:#EBF5FB; border-left: 4px solid var(--primary);">
<strong>Ehrlich bleiben.</strong> Niemand wird benotet — die Bewertung ist fuer dich selbst,
damit du beim naechsten Projekt besser einschaetzen kannst, was du planen musst.
</div>
<div class="card">
<form method="post" style="display: grid; gap: 1.5rem;">
{# ============== Gesamt ============== #}
<div class="eval-gruppe">
<div class="eval-titel">Wie ist es insgesamt gelaufen? *</div>
<div class="eval-untertitel">
"Gelernt" heisst: hat nicht so geklappt, aber ich hab dabei was verstanden.
Bei "Gelernt" schaut der Lehrer kurz drueber, bevor du was Neues anfaengst.
</div>
<div class="eval-optionen">
<label class="eval-option">
<input type="radio" name="selbsteinschaetzung" value="zufrieden" required>
<span><span class="eval-emoji">🟢</span>Zufrieden</span>
</label>
<label class="eval-option">
<input type="radio" name="selbsteinschaetzung" value="mittel">
<span><span class="eval-emoji">🟡</span>Mittel</span>
</label>
<label class="eval-option">
<input type="radio" name="selbsteinschaetzung" value="gelernt">
<span><span class="eval-emoji">🔴</span>Gelernt</span>
</label>
</div>
</div>
{# ============== Planung ============== #}
<div class="eval-gruppe">
<div class="eval-titel">Wie gut war deine Planung im Vorhinein?</div>
<div class="eval-untertitel">Hattest du vorher klar, was du bauen willst und wie?</div>
<div class="eval-optionen">
<label class="eval-option">
<input type="radio" name="selbsteinschaetzung_planung" value="gut">
<span><span class="eval-emoji">🟢</span>Gut</span>
</label>
<label class="eval-option">
<input type="radio" name="selbsteinschaetzung_planung" value="ok">
<span><span class="eval-emoji">🟡</span>OK</span>
</label>
<label class="eval-option">
<input type="radio" name="selbsteinschaetzung_planung" value="schwierig">
<span><span class="eval-emoji">🔴</span>Schwierig</span>
</label>
</div>
</div>
{# ============== Ausfuehrung ============== #}
<div class="eval-gruppe">
<div class="eval-titel">Wie gut hat die Ausfuehrung geklappt?</div>
<div class="eval-untertitel">Modellieren, Slicen, Drucken — wie ist das gelaufen?</div>
<div class="eval-optionen">
<label class="eval-option">
<input type="radio" name="selbsteinschaetzung_ausfuehrung" value="gut">
<span><span class="eval-emoji">🟢</span>Gut</span>
</label>
<label class="eval-option">
<input type="radio" name="selbsteinschaetzung_ausfuehrung" value="ok">
<span><span class="eval-emoji">🟡</span>OK</span>
</label>
<label class="eval-option">
<input type="radio" name="selbsteinschaetzung_ausfuehrung" value="schwierig">
<span><span class="eval-emoji">🔴</span>Schwierig</span>
</label>
</div>
</div>
{# ============== Freitexte ============== #}
<div>
<label style="display:block; font-size: 0.95rem; font-weight: 600; margin-bottom: 0.3rem;">
Was hast du gelernt?
</label>
<div style="font-size: 0.8rem; color: var(--text-light); margin-bottom: 0.4rem;">
1-2 Saetze reichen. Was nimmst du mit fuer's naechste Mal?
</div>
<textarea name="was_gelernt" rows="3" maxlength="500"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 0.95rem; font-family: inherit;"></textarea>
</div>
<div>
<label style="display:block; font-size: 0.95rem; font-weight: 600; margin-bottom: 0.3rem;">
Was hat nicht so geklappt? <span style="font-weight: normal; color: var(--text-light);">(optional)</span>
</label>
<div style="font-size: 0.8rem; color: var(--text-light); margin-bottom: 0.4rem;">
Nur wenn was schief lief. Hilft dem Lehrer beim Drueberschauen.
</div>
<textarea name="was_klappte_nicht" rows="3" maxlength="500"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 0.95rem; font-family: inherit;"></textarea>
</div>
<div style="display: flex; justify-content: space-between;">
<a href="{{ url_for('profil.projekt_detail', projekt_id=p.id) }}" class="btn btn-secondary">Abbrechen</a>
<button class="btn btn-primary">Projekt abschliessen</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}{{ p.titel }} bearbeiten{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Projekt bearbeiten</h1>
<a href="{{ url_for('profil.projekt_detail', projekt_id=p.id) }}" class="btn btn-secondary">&larr; Zurueck</a>
</div>
<div class="card">
<form method="post" style="display: grid; gap: 1rem;">
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">Titel *</label>
<input type="text" name="titel" required maxlength="100" value="{{ p.titel }}"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 1rem;">
</div>
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">Beschreibung</label>
<textarea name="beschreibung_md" rows="10" maxlength="2000"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 0.95rem; font-family: inherit;">{{ p.beschreibung_md or '' }}</textarea>
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.3rem;">
Markdown: **fett**, *kursiv*, Listen mit -, Ueberschriften mit #.
</div>
</div>
<div style="display: flex; justify-content: space-between;">
<a href="{{ url_for('profil.projekt_detail', projekt_id=p.id) }}" class="btn btn-secondary">Abbrechen</a>
<button class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,290 @@
{% extends "base.html" %}
{% block title %}Cover-Bild: {{ p.titel }}{% endblock %}
{% block content %}
<p><a href="{{ url_for('profil.projekt_detail', projekt_id=p.id) }}">&larr; Zurueck zum Projekt</a></p>
<div class="card">
<h1>Cover-Bild fuer: {{ p.titel }}</h1>
<p style="color: var(--text-light); font-size: 0.9rem;">
Ein Foto vom Druck oder ein Screenshot vom Modell — wird im Cockpit und spaeter im Katalog gezeigt.
</p>
</div>
<!-- Paste-Bereich -->
<div class="card" id="paste-bereich">
<h2>1. Bild einfuegen</h2>
<p style="color: var(--text-light); margin-bottom: 1rem;">
Kopiere ein Bild in die Zwischenablage (Snipping Tool, Handy-Foto) und druecke
<kbd style="background:#eee;padding:0.2rem 0.5rem;border-radius:4px;border:1px solid #ccc;">Strg+V</kbd>
hier auf der Seite. Oder waehle eine Datei aus.
</p>
<div id="drop-zone" style="border: 3px dashed var(--border); border-radius: 12px; padding: 3rem;
text-align: center; cursor: pointer; transition: border-color 0.2s; background: #fafafa;">
<p style="font-size: 1.2rem; color: var(--text-light);">Strg+V zum Einfuegen</p>
<p style="font-size: 0.85rem; color: var(--text-light);">oder klicken um Datei auszuwaehlen</p>
<input type="file" id="datei-input" accept="image/*" style="display: none;">
</div>
</div>
<!-- Crop-Bereich (anfangs versteckt) -->
<div class="card" id="crop-bereich" style="display: none;">
<h2>2. Ausschnitt waehlen</h2>
<p style="color: var(--text-light); margin-bottom: 1rem;">
Ziehe das Quadrat auf den gewuenschten Bildausschnitt. Ecken zum Vergroessern/Verkleinern ziehen.
</p>
<div style="display: flex; gap: 2rem; flex-wrap: wrap;">
<div style="flex: 1; min-width: 300px;">
<div id="crop-container" style="position: relative; display: inline-block; max-width: 100%; cursor: crosshair;">
<canvas id="quell-canvas" style="max-width: 100%; display: block;"></canvas>
<div id="crop-box" style="position: absolute; border: 3px solid var(--primary);
background: rgba(0,151,157,0.1); cursor: move; display: none;">
<div class="resize-handle" data-pos="tl" style="position:absolute;top:-6px;left:-6px;width:12px;height:12px;background:var(--primary);border-radius:50%;cursor:nw-resize;"></div>
<div class="resize-handle" data-pos="tr" style="position:absolute;top:-6px;right:-6px;width:12px;height:12px;background:var(--primary);border-radius:50%;cursor:ne-resize;"></div>
<div class="resize-handle" data-pos="bl" style="position:absolute;bottom:-6px;left:-6px;width:12px;height:12px;background:var(--primary);border-radius:50%;cursor:sw-resize;"></div>
<div class="resize-handle" data-pos="br" style="position:absolute;bottom:-6px;right:-6px;width:12px;height:12px;background:var(--primary);border-radius:50%;cursor:se-resize;"></div>
</div>
</div>
</div>
<div style="min-width: 200px; text-align: center;">
<h3>Vorschau</h3>
<canvas id="vorschau-canvas" width="200" height="200"
style="border: 1px solid var(--border); border-radius: 8px; background: #f0f0f0;"></canvas>
</div>
</div>
<div style="margin-top: 1.5rem; display: flex; gap: 1rem;">
<button onclick="speichern()" class="btn btn-primary" id="speichern-btn"
style="padding: 0.8rem 2rem; font-size: 1rem;">Bild speichern</button>
<button onclick="zuruecksetzen()" class="btn btn-secondary">Neues Bild</button>
</div>
</div>
<!-- Erfolg -->
<div class="card" id="erfolg-bereich" style="display: none; border-left: 4px solid var(--success);">
<h2 style="color: var(--success);">Bild gespeichert!</h2>
<p>Das Cover fuer <strong>{{ p.titel }}</strong> wurde erfolgreich gespeichert.</p>
<div style="margin-top: 1rem;">
<a href="{{ url_for('profil.projekt_detail', projekt_id=p.id) }}" class="btn btn-primary">Zurueck zum Projekt</a>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
var quellCanvas = document.getElementById('quell-canvas');
var quellCtx = quellCanvas.getContext('2d');
var vorschauCanvas = document.getElementById('vorschau-canvas');
var vorschauCtx = vorschauCanvas.getContext('2d');
var cropBox = document.getElementById('crop-box');
var cropContainer = document.getElementById('crop-container');
var originalBild = null;
var skalierung = 1;
var crop = { x: 0, y: 0, size: 100 };
var dragging = false, resizing = false, resizePos = '';
var startX, startY, startCropX, startCropY, startCropSize;
function bildLaden(quelle) {
var img = new Image();
img.onload = function() {
originalBild = img;
var maxBreite = 600;
skalierung = Math.min(1, maxBreite / img.width);
quellCanvas.width = img.width * skalierung;
quellCanvas.height = img.height * skalierung;
quellCtx.drawImage(img, 0, 0, quellCanvas.width, quellCanvas.height);
var minSeite = Math.min(quellCanvas.width, quellCanvas.height);
crop.size = Math.round(minSeite * 0.6);
crop.x = Math.round((quellCanvas.width - crop.size) / 2);
crop.y = Math.round((quellCanvas.height - crop.size) / 2);
cropBoxAktualisieren();
cropBox.style.display = 'block';
vorschauAktualisieren();
document.getElementById('paste-bereich').style.display = 'none';
document.getElementById('crop-bereich').style.display = 'block';
};
img.src = quelle;
}
document.addEventListener('paste', function(e) {
if (originalBild) return;
var items = e.clipboardData.items;
for (var i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') >= 0) {
var blob = items[i].getAsFile();
var reader = new FileReader();
reader.onload = function(ev) { bildLaden(ev.target.result); };
reader.readAsDataURL(blob);
e.preventDefault();
return;
}
}
});
var dropZone = document.getElementById('drop-zone');
var dateiInput = document.getElementById('datei-input');
dropZone.addEventListener('click', function() { dateiInput.click(); });
dateiInput.addEventListener('change', function() {
if (this.files[0]) {
var reader = new FileReader();
reader.onload = function(ev) { bildLaden(ev.target.result); };
reader.readAsDataURL(this.files[0]);
}
});
dropZone.addEventListener('dragover', function(e) {
e.preventDefault(); this.style.borderColor = 'var(--primary)'; this.style.background = '#EBF5FB';
});
dropZone.addEventListener('dragleave', function() {
this.style.borderColor = 'var(--border)'; this.style.background = '#fafafa';
});
dropZone.addEventListener('drop', function(e) {
e.preventDefault();
this.style.borderColor = 'var(--border)'; this.style.background = '#fafafa';
if (e.dataTransfer.files[0] && e.dataTransfer.files[0].type.indexOf('image') >= 0) {
var reader = new FileReader();
reader.onload = function(ev) { bildLaden(ev.target.result); };
reader.readAsDataURL(e.dataTransfer.files[0]);
}
});
function cropBoxAktualisieren() {
cropBox.style.left = crop.x + 'px';
cropBox.style.top = crop.y + 'px';
cropBox.style.width = crop.size + 'px';
cropBox.style.height = crop.size + 'px';
}
function vorschauAktualisieren() {
if (!originalBild) return;
var sx = crop.x / skalierung, sy = crop.y / skalierung, ss = crop.size / skalierung;
vorschauCtx.clearRect(0, 0, 200, 200);
vorschauCtx.drawImage(originalBild, sx, sy, ss, ss, 0, 0, 200, 200);
}
function begrenzen() {
var maxX = quellCanvas.width - crop.size, maxY = quellCanvas.height - crop.size;
crop.x = Math.max(0, Math.min(crop.x, maxX));
crop.y = Math.max(0, Math.min(crop.y, maxY));
crop.size = Math.max(30, Math.min(crop.size,
Math.min(quellCanvas.width - crop.x, quellCanvas.height - crop.y)));
}
cropContainer.addEventListener('mousedown', function(e) {
if (!originalBild) return;
var rect = quellCanvas.getBoundingClientRect();
var mx = e.clientX - rect.left, my = e.clientY - rect.top;
var handle = e.target.closest('.resize-handle');
if (handle) {
resizing = true; resizePos = handle.dataset.pos;
startX = e.clientX; startY = e.clientY;
startCropX = crop.x; startCropY = crop.y; startCropSize = crop.size;
e.preventDefault(); return;
}
if (mx >= crop.x && mx <= crop.x + crop.size && my >= crop.y && my <= crop.y + crop.size) {
dragging = true;
startX = e.clientX; startY = e.clientY;
startCropX = crop.x; startCropY = crop.y;
e.preventDefault();
}
});
document.addEventListener('mousemove', function(e) {
if (dragging) {
crop.x = startCropX + (e.clientX - startX);
crop.y = startCropY + (e.clientY - startY);
begrenzen(); cropBoxAktualisieren(); vorschauAktualisieren();
}
if (resizing) {
var dx = e.clientX - startX, dy = e.clientY - startY;
if (resizePos === 'br') crop.size = Math.max(30, startCropSize + Math.max(dx, dy));
else if (resizePos === 'tl') {
var delta = Math.min(dx, dy);
crop.x = startCropX + delta; crop.y = startCropY + delta;
crop.size = startCropSize - delta;
} else if (resizePos === 'tr') {
var delta = Math.max(dx, -dy);
crop.y = startCropY - delta; crop.size = startCropSize + delta;
} else if (resizePos === 'bl') {
var delta = Math.max(-dx, dy);
crop.x = startCropX - delta; crop.size = startCropSize + delta;
}
crop.size = Math.max(30, crop.size);
begrenzen(); cropBoxAktualisieren(); vorschauAktualisieren();
}
});
document.addEventListener('mouseup', function() { dragging = false; resizing = false; });
cropContainer.addEventListener('touchstart', function(e) {
if (!originalBild) return;
var t = e.touches[0], rect = quellCanvas.getBoundingClientRect();
var mx = t.clientX - rect.left, my = t.clientY - rect.top;
if (mx >= crop.x && mx <= crop.x + crop.size && my >= crop.y && my <= crop.y + crop.size) {
dragging = true;
startX = t.clientX; startY = t.clientY;
startCropX = crop.x; startCropY = crop.y;
e.preventDefault();
}
}, {passive: false});
document.addEventListener('touchmove', function(e) {
if (dragging) {
var t = e.touches[0];
crop.x = startCropX + (t.clientX - startX);
crop.y = startCropY + (t.clientY - startY);
begrenzen(); cropBoxAktualisieren(); vorschauAktualisieren();
e.preventDefault();
}
}, {passive: false});
document.addEventListener('touchend', function() { dragging = false; });
function speichern() {
if (!originalBild) return;
var btn = document.getElementById('speichern-btn');
btn.disabled = true; btn.textContent = 'Speichern...';
var exportCanvas = document.createElement('canvas');
exportCanvas.width = 400; exportCanvas.height = 400;
var ctx = exportCanvas.getContext('2d');
var sx = crop.x / skalierung, sy = crop.y / skalierung, ss = crop.size / skalierung;
ctx.drawImage(originalBild, sx, sy, ss, ss, 0, 0, 400, 400);
var dataUrl = exportCanvas.toDataURL('image/jpeg', 0.85);
fetch('{{ url_for("profil.projekt_cover", projekt_id=p.id) }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({bild_base64: dataUrl})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
alert('Fehler: ' + data.error);
btn.disabled = false; btn.textContent = 'Bild speichern';
} else {
document.getElementById('crop-bereich').style.display = 'none';
document.getElementById('erfolg-bereich').style.display = 'block';
}
})
.catch(function() {
alert('Verbindungsfehler');
btn.disabled = false; btn.textContent = 'Bild speichern';
});
}
function zuruecksetzen() {
originalBild = null;
cropBox.style.display = 'none';
vorschauCtx.clearRect(0, 0, 200, 200);
document.getElementById('crop-bereich').style.display = 'none';
document.getElementById('paste-bereich').style.display = 'block';
}
</script>
{% endblock %}

View File

@@ -0,0 +1,405 @@
{% extends "base.html" %}
{% block title %}{{ p.titel }}{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>{{ p.titel }}</h1>
<div>
<a href="{{ url_for('profil.projekte_liste') }}" class="btn btn-secondary">Meine Projekte</a>
<a href="{{ url_for('profil.cockpit') }}" class="btn btn-secondary">Cockpit</a>
</div>
</div>
{# ============== Lineage-Banner: Vorlage + Wiederholungen ============== #}
{% if vorlage %}
<div class="card" style="background: #EBF5FB; border-left: 4px solid var(--primary); margin-bottom: 0.8rem;">
<strong>🔗 Vorlage:</strong>
<a href="{{ url_for('profil.projekt_detail', projekt_id=vorlage.id) }}">{{ vorlage.titel }}</a>
von {{ tier_emoji(vorlage.tier or '') }} {{ vorlage.vorname or '—' }}
<div style="font-size: 0.85rem; margin-top: 0.3rem; color: var(--text-light);">
Du machst dein eigenes Projekt — die Vorlage ist nur Inspiration. Eigener Twist erwuenscht!
</div>
</div>
{% endif %}
{% if wiederholungen %}
<div class="card" style="background: #EAFBF1; border-left: 4px solid var(--success); margin-bottom: 0.8rem;">
<strong>🔁 Wurde wiederholt von:</strong>
{% for w in wiederholungen %}
{{ tier_emoji(w.tier or '') }}
<a href="{{ url_for('profil.projekt_detail', projekt_id=w.id) }}">{{ w.vorname or '—' }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
{# ============== Status-Zeile ============== #}
<div style="margin-bottom: 1rem;">
{% if p.status_lebenszyklus == 'in_arbeit' %}
<span class="badge badge-mittel">🔧 in Arbeit</span>
{% else %}
<span class="badge badge-anfaenger">✅ abgeschlossen</span>
{% if p.selbsteinschaetzung == 'zufrieden' %}<span class="badge badge-anfaenger">🟢 zufrieden</span>
{% elif p.selbsteinschaetzung == 'mittel' %}<span class="badge badge-mittel">🟡 mittel</span>
{% elif p.selbsteinschaetzung == 'gelernt' %}<span class="badge badge-fortgeschritten">🔴 gelernt</span>
{% endif %}
{% if p.freigabe_status == 'wartet_auf_freigabe' %}<span class="badge badge-mittel">⏳ wartet auf Freigabe</span>
{% elif p.freigabe_status == 'rueckfrage' %}<span class="badge badge-fortgeschritten">❓ Rueckfrage offen</span>
{% elif p.freigabe_status == 'freigegeben' %}<span class="badge badge-anfaenger">✓ freigegeben</span>
{% endif %}
{% endif %}
</div>
<div style="display: grid; grid-template-columns: 280px 1fr; gap: 1rem;">
{# ============== Cover-Bild ============== #}
<div class="card" style="text-align: center;">
{% if p.hat_cover %}
<img src="{{ url_for('api.projekt_cover', projekt_id=p.id) }}" alt=""
style="width: 100%; max-height: 240px; object-fit: cover; border-radius: 6px;">
{% else %}
<div style="height: 200px; background: linear-gradient(135deg, #ECF0F1, #D5DBDB); border-radius: 6px;
display: flex; align-items: center; justify-content: center; font-size: 3rem; color: var(--text-light);">
🎯
</div>
{% endif %}
<div style="margin-top: 0.6rem; display: flex; gap: 0.4rem; justify-content: center; flex-wrap: wrap;">
<a href="{{ url_for('profil.projekt_cover', projekt_id=p.id) }}" class="btn btn-secondary" style="font-size: 0.85rem;">
{% if p.hat_cover %}Bild aendern{% else %}Bild hinzufuegen{% endif %}
</a>
{% if p.hat_cover %}
<form method="post" action="{{ url_for('profil.projekt_cover_loeschen', projekt_id=p.id) }}" style="display:inline;"
onsubmit="return confirm('Cover-Bild loeschen?')">
<button class="btn btn-secondary" style="font-size: 0.85rem; color: var(--danger);">loeschen</button>
</form>
{% endif %}
</div>
</div>
{# ============== Beschreibung + Eval ============== #}
<div>
<div class="card">
<h2>Beschreibung</h2>
{% if p.beschreibung_md %}
<div>{{ p.beschreibung_md|markdown }}</div>
{% else %}
<p style="color: var(--text-light);"><em>Noch keine Beschreibung.</em></p>
{% endif %}
{% if p.status_lebenszyklus == 'in_arbeit' %}
<div style="margin-top: 1rem;">
<a href="{{ url_for('profil.projekt_bearbeiten', projekt_id=p.id) }}" class="btn btn-secondary" style="font-size: 0.85rem;">
bearbeiten
</a>
</div>
{% endif %}
</div>
{% if p.status_lebenszyklus == 'abgeschlossen' %}
<div class="card">
<h2>Deine Bewertung</h2>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.6rem; margin-bottom: 1rem;">
<div style="text-align: center; padding: 0.6rem; background: #F4F6F7; border-radius: 6px;">
<div style="font-size: 0.75rem; color: var(--text-light);">Gesamt</div>
<div style="font-size: 1.4rem; margin-top: 0.2rem;">
{% if p.selbsteinschaetzung == 'zufrieden' %}🟢
{% elif p.selbsteinschaetzung == 'mittel' %}🟡
{% elif p.selbsteinschaetzung == 'gelernt' %}🔴
{% endif %}
</div>
<div style="font-size: 0.85rem; margin-top: 0.2rem;">{{ p.selbsteinschaetzung or '—' }}</div>
</div>
<div style="text-align: center; padding: 0.6rem; background: #F4F6F7; border-radius: 6px;">
<div style="font-size: 0.75rem; color: var(--text-light);">Planung</div>
<div style="font-size: 1.4rem; margin-top: 0.2rem;">
{% if p.selbsteinschaetzung_planung == 'gut' %}🟢
{% elif p.selbsteinschaetzung_planung == 'ok' %}🟡
{% elif p.selbsteinschaetzung_planung == 'schwierig' %}🔴
{% else %}—{% endif %}
</div>
<div style="font-size: 0.85rem; margin-top: 0.2rem;">{{ p.selbsteinschaetzung_planung or '—' }}</div>
</div>
<div style="text-align: center; padding: 0.6rem; background: #F4F6F7; border-radius: 6px;">
<div style="font-size: 0.75rem; color: var(--text-light);">Ausfuehrung</div>
<div style="font-size: 1.4rem; margin-top: 0.2rem;">
{% if p.selbsteinschaetzung_ausfuehrung == 'gut' %}🟢
{% elif p.selbsteinschaetzung_ausfuehrung == 'ok' %}🟡
{% elif p.selbsteinschaetzung_ausfuehrung == 'schwierig' %}🔴
{% else %}—{% endif %}
</div>
<div style="font-size: 0.85rem; margin-top: 0.2rem;">{{ p.selbsteinschaetzung_ausfuehrung or '—' }}</div>
</div>
</div>
{% if p.was_gelernt %}
<div style="margin-bottom: 0.6rem;">
<strong>Was gelernt:</strong>
<div style="margin-top: 0.2rem;">{{ p.was_gelernt }}</div>
</div>
{% endif %}
{% if p.was_klappte_nicht %}
<div>
<strong>Was nicht klappte:</strong>
<div style="margin-top: 0.2rem;">{{ p.was_klappte_nicht }}</div>
</div>
{% endif %}
</div>
{% endif %}
{# ============== Werkstatt (nur waehrend in_arbeit) ============== #}
{% if p.status_lebenszyklus == 'in_arbeit' %}
<div class="card" style="border-top: 4px solid var(--primary);">
<h2>🔧 Werkstatt</h2>
<p style="color: var(--text-light); font-size: 0.9rem;">
Hier alles zum Projekt — Beschreibung, Software, Drucker, MakerWorld-Link und Dateien.
</p>
<form method="post" action="{{ url_for('profil.projekt_werkstatt_speichern', projekt_id=p.id) }}"
style="display: grid; gap: 0.8rem;">
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light); margin-bottom: 0.2rem;">Titel *</label>
<input type="text" name="titel" required maxlength="100" value="{{ p.titel }}"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light); margin-bottom: 0.2rem;">Beschreibung</label>
<textarea name="beschreibung_md" rows="5" maxlength="2000"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-family: inherit;">{{ p.beschreibung_md or '' }}</textarea>
<div style="font-size: 0.75rem; color: var(--text-light); margin-top: 0.2rem;">
Markdown: **fett**, *kursiv*, Listen mit -.
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.8rem;">
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light); margin-bottom: 0.2rem;">
Hauptsoftware
</label>
<select name="modellier_software_slug"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
<option value="">— noch nicht festgelegt —</option>
{% for s in software_liste %}
<option value="{{ s.slug }}" {% if p.modellier_software_slug == s.slug %}selected{% endif %}>
{{ s.titel }}
</option>
{% endfor %}
</select>
</div>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light); margin-bottom: 0.2rem;">
Drucker
</label>
<select name="drucker_id_used"
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
<option value="">— noch nicht festgelegt —</option>
{% for d in drucker_liste %}
<option value="{{ d.id }}" {% if p.drucker_id_used == d.id %}selected{% endif %}>
{{ d.name }} ({{ d.modell }})
</option>
{% endfor %}
</select>
</div>
</div>
<div>
<label style="display:block; font-size: 0.85rem; color: var(--text-light); margin-bottom: 0.2rem;">
MakerWorld-Link <span style="font-weight: normal;">(optional, wenn du dein Projekt dort veroeffentlicht hast)</span>
</label>
<input type="url" name="makerworld_url" value="{{ p.makerworld_url or '' }}"
placeholder="https://makerworld.com/en/models/..."
style="padding: 0.5rem; width: 100%; border: 1px solid var(--border); border-radius: 4px;">
</div>
<div style="display: flex; justify-content: flex-end;">
<button class="btn btn-primary">Werkstatt speichern</button>
</div>
</form>
{# ============== Dateien-Bereich ============== #}
<h3 style="margin-top: 1.5rem;">Dateien</h3>
<p style="color: var(--text-light); font-size: 0.85rem;">
STL, 3MF, Fotos. Mindestens eine Datei + ein Cover-Bild sind noetig, um das Projekt abzuschliessen.
</p>
{% if dateien %}
<table style="margin-top: 0.5rem;">
<thead>
<tr><th>Datei</th><th>Typ</th><th>Groesse</th><th></th></tr>
</thead>
<tbody id="dateien-tbody">
{% for d in dateien %}
<tr>
<td>
<a href="{{ url_for('api.projekt_datei_download', projekt_id=p.id, datei_id=d.id) }}">{{ d.dateiname }}</a>
</td>
<td><span class="badge">{{ d.art }}</span></td>
<td style="font-size: 0.85rem; color: var(--text-light);">
{{ (d.groesse_bytes // 1024) if d.groesse_bytes else 0 }} KB
</td>
<td>
<form method="post" action="{{ url_for('profil.projekt_datei_loeschen', projekt_id=p.id, datei_id=d.id) }}" style="display:inline;"
onsubmit="return confirm('Datei {{ d.dateiname }} loeschen?')">
<button class="btn btn-secondary" style="padding: 0.2rem 0.5rem; font-size: 0.8rem; color: var(--danger);">loeschen</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color: var(--text-light); margin-top: 0.5rem;">
<em>Noch keine Dateien.</em>
</p>
{% endif %}
{# Upload-Zone #}
<div id="upload-zone" style="margin-top: 1rem; border: 3px dashed var(--border); border-radius: 8px;
padding: 1.5rem; text-align: center; cursor: pointer; background: #fafafa;">
<div style="font-size: 1.1rem; color: var(--text-light);">📁 Dateien hier ablegen</div>
<div style="font-size: 0.85rem; color: var(--text-light); margin-top: 0.3rem;">
oder klicken — STL, 3MF, JPG, PNG · max. 30 MB pro Datei
</div>
<input type="file" id="datei-input" multiple
accept=".stl,.3mf,image/jpeg,image/png,image/webp"
style="display: none;">
<div id="upload-status" style="margin-top: 0.5rem; font-size: 0.85rem;"></div>
</div>
</div>
{% endif %}
{# ============== Aktionen ============== #}
<div class="card">
{% if p.status_lebenszyklus == 'in_arbeit' %}
<h2>Naechster Schritt</h2>
<div style="display: flex; gap: 0.6rem; flex-wrap: wrap;">
<a href="{{ url_for('profil.projekt_cover', projekt_id=p.id) }}" class="btn btn-secondary">
📷 {% if p.hat_cover %}Cover aendern{% else %}Cover-Bild hinzufuegen{% endif %}
</a>
{% if darf_abschliessen %}
<a href="{{ url_for('profil.projekt_abschluss', projekt_id=p.id) }}" class="btn btn-primary">
✅ Projekt abschliessen
</a>
{% else %}
<button class="btn btn-secondary" disabled title="{{ abschluss_fehlende|join(' · ') }}"
style="opacity: 0.5; cursor: not-allowed;">
✅ Projekt abschliessen
</button>
{% endif %}
</div>
{% if not darf_abschliessen %}
<div style="margin-top: 0.6rem; background: #FEF9E7; padding: 0.6rem 0.8rem; border-radius: 6px; font-size: 0.85rem;">
<strong>Noch nicht abschliessbar:</strong>
<ul style="margin: 0.3rem 0 0 1.2rem; padding: 0;">
{% for f in abschluss_fehlende %}<li>{{ f }}</li>{% endfor %}
</ul>
</div>
{% endif %}
{% elif p.freigabe_status == 'wartet_auf_freigabe' %}
<div style="background: #FEF9E7; padding: 0.8rem; border-radius: 6px; border-left: 4px solid var(--warning);">
<strong>Wartet auf Freigabe vom Lehrer.</strong>
Sobald er drueber geschaut hat, kannst du ein neues Projekt anfangen.
</div>
{% elif p.freigabe_status == 'rueckfrage' %}
<div style="background: #FADBD8; padding: 0.8rem; border-radius: 6px; border-left: 4px solid var(--danger);">
<strong>Lehrer hat eine Rueckfrage.</strong>
Schau ins Cockpit fuer die Details und beantworte sie.
</div>
{% else %}
<div style="background: #EAFBF1; padding: 0.8rem; border-radius: 6px; border-left: 4px solid var(--success);">
<strong>Freigegeben.</strong>
{% if p.freigegeben_am %}Am {{ p.freigegeben_am[:10] }}.{% endif %}
Du kannst jetzt ein neues Projekt anfangen.
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{# Upload-JS nur wenn Werkstatt sichtbar ist (also in_arbeit). #}
{% if p.status_lebenszyklus == 'in_arbeit' %}
<script>
(function() {
var zone = document.getElementById('upload-zone');
var input = document.getElementById('datei-input');
var status = document.getElementById('upload-status');
if (!zone || !input) return;
var MAX_BYTES = 30 * 1024 * 1024;
zone.addEventListener('click', function(e) {
if (e.target === input) return;
input.click();
});
zone.addEventListener('dragover', function(e) {
e.preventDefault();
zone.style.borderColor = 'var(--primary)';
zone.style.background = '#EBF5FB';
});
zone.addEventListener('dragleave', function() {
zone.style.borderColor = 'var(--border)';
zone.style.background = '#fafafa';
});
zone.addEventListener('drop', function(e) {
e.preventDefault();
zone.style.borderColor = 'var(--border)';
zone.style.background = '#fafafa';
if (e.dataTransfer.files && e.dataTransfer.files.length) {
dateienHochladen(e.dataTransfer.files);
}
});
input.addEventListener('change', function() {
if (this.files && this.files.length) dateienHochladen(this.files);
this.value = '';
});
async function dateienHochladen(fileList) {
var files = Array.from(fileList);
var fehler = 0;
for (var i = 0; i < files.length; i++) {
var f = files[i];
status.textContent = 'Lade ' + (i+1) + '/' + files.length + ': ' + f.name + ' ...';
status.style.color = 'var(--text-light)';
if (f.size > MAX_BYTES) {
status.textContent = 'Zu gross: ' + f.name + ' (' + Math.round(f.size/1024/1024) + ' MB)';
status.style.color = 'var(--danger)';
fehler++;
continue;
}
try {
var dataUrl = await readAsDataUrl(f);
var resp = await fetch('{{ url_for("profil.projekt_datei_upload", projekt_id=p.id) }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
dateiname: f.name,
mime: f.type || 'application/octet-stream',
base64: dataUrl
})
});
var json = await resp.json();
if (!json.ok) {
status.textContent = 'Fehler bei ' + f.name + ': ' + (json.error || 'unbekannt');
status.style.color = 'var(--danger)';
fehler++;
}
} catch (err) {
status.textContent = 'Fehler bei ' + f.name + ': ' + err;
status.style.color = 'var(--danger)';
fehler++;
}
}
if (fehler === 0) {
status.textContent = files.length + ' Datei(en) hochgeladen — Seite wird neu geladen ...';
status.style.color = 'var(--success)';
setTimeout(function() { location.reload(); }, 600);
}
}
function readAsDataUrl(file) {
return new Promise(function(resolve, reject) {
var reader = new FileReader();
reader.onload = function() { resolve(reader.result); };
reader.onerror = function() { reject(reader.error); };
reader.readAsDataURL(file);
});
}
})();
</script>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,66 @@
{% extends "base.html" %}
{% block title %}Meine Projekte{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Meine Projekte</h1>
<div>
{% if darf_neues %}
<a href="{{ url_for('profil.projekt_neu') }}" class="btn btn-primary">+ Neues Projekt</a>
{% endif %}
<a href="{{ url_for('profil.cockpit') }}" class="btn btn-secondary">Cockpit</a>
</div>
</div>
{% if not darf_neues and sperr_grund %}
<div class="card" style="background:#FEF9E7; border-left: 4px solid var(--warning);">
{{ sperr_grund }}
</div>
{% endif %}
{% if projekte %}
<div class="grid" style="grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));">
{% for p in projekte %}
<a href="{{ url_for('profil.projekt_detail', projekt_id=p.id) }}" class="card"
style="text-decoration: none; color: inherit; display: flex; flex-direction: column; gap: 0.6rem;">
{% if p.hat_cover %}
<img src="{{ url_for('api.projekt_cover', projekt_id=p.id) }}" alt=""
style="width: 100%; height: 160px; object-fit: cover; border-radius: 6px; background: #fff;">
{% else %}
<div style="height: 160px; background: linear-gradient(135deg, #ECF0F1, #D5DBDB); border-radius: 6px;
display: flex; align-items: center; justify-content: center; font-size: 2.5rem; color: var(--text-light);">
🎯
</div>
{% endif %}
<h3 style="margin: 0; color: var(--primary-dark);">{{ p.titel }}</h3>
<div style="font-size: 0.85rem;">
{% if p.status_lebenszyklus == 'in_arbeit' %}
<span class="badge badge-mittel">in Arbeit</span>
{% else %}
{% if p.selbsteinschaetzung == 'zufrieden' %}<span class="badge badge-anfaenger">🟢 zufrieden</span>
{% elif p.selbsteinschaetzung == 'mittel' %}<span class="badge badge-mittel">🟡 mittel</span>
{% elif p.selbsteinschaetzung == 'gelernt' %}<span class="badge badge-fortgeschritten">🔴 gelernt</span>
{% endif %}
{% if p.freigabe_status == 'wartet_auf_freigabe' %}<span class="badge badge-mittel">⏳ wartet auf Freigabe</span>
{% elif p.freigabe_status == 'rueckfrage' %}<span class="badge badge-fortgeschritten">❓ Rueckfrage</span>
{% endif %}
{% endif %}
</div>
{% if p.abgeschlossen_am %}
<div style="font-size: 0.8rem; color: var(--text-light);">
abgeschlossen {{ p.abgeschlossen_am[:10] }}
</div>
{% endif %}
</a>
{% endfor %}
</div>
{% else %}
<div class="card">
<p style="color: var(--text-light);">
Du hast noch keine Projekte.
{% if darf_neues %}<a href="{{ url_for('profil.projekt_neu') }}">Leg dein erstes an.</a>{% endif %}
</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}Neues Projekt{% endblock %}
{% block content %}
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center;">
<h1>Neues Projekt anlegen</h1>
<a href="{{ url_for('profil.cockpit') }}" class="btn btn-secondary">Abbrechen</a>
</div>
<div class="card" style="background:#EBF5FB; border-left: 4px solid var(--primary);">
<strong>Commitment.</strong> Mit dem Anlegen entscheidest du dich, dieses Projekt durchzuziehen.
Du kannst es spaeter abschliessen und selbst bewerten — und erst dann wieder ein neues anfangen.
</div>
<div class="card">
<form method="post" style="display: grid; gap: 1rem;">
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
Titel * <span style="font-weight: normal;">(was willst du bauen?)</span>
</label>
<input type="text" name="titel" required maxlength="100" autofocus
value="{{ eingabe_titel or '' }}"
placeholder="z.B. 'Stiftehalter mit meinem Lieblings-Tier'"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 1rem;">
</div>
<div>
<label style="display:block; font-size: 0.9rem; color: var(--text-light); margin-bottom: 0.3rem;">
Beschreibung <span style="font-weight: normal;">(optional, kannst du spaeter ergaenzen)</span>
</label>
<textarea name="beschreibung_md" rows="6" maxlength="2000"
placeholder="Was willst du genau bauen? Hast du schon eine Idee, mit welcher Software (TinkerCAD, MakerWorld, ...)? Welches Material?"
style="padding: 0.6rem; width: 100%; border: 1px solid var(--border); border-radius: 4px; font-size: 0.95rem; font-family: inherit;">{{ eingabe_beschreibung or '' }}</textarea>
<div style="font-size: 0.8rem; color: var(--text-light); margin-top: 0.3rem;">
Markdown geht: **fett**, *kursiv*, Listen mit -.
</div>
</div>
<div style="display: flex; justify-content: space-between;">
<a href="{{ url_for('profil.cockpit') }}" class="btn btn-secondary">Abbrechen</a>
<button class="btn btn-primary">Projekt anlegen</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,279 @@
{% extends "base.html" %}
{% block title %}Projekte{% endblock %}
{% block content %}
<style>
.kat-tabelle { width: 100%; border-collapse: collapse; }
.kat-tabelle th {
background: #F2F3F4; padding: 0.6rem 0.5rem;
text-align: left; font-size: 0.85rem; cursor: pointer;
user-select: none; white-space: nowrap;
}
.kat-tabelle th:hover { background: #E5E7E8; }
.kat-tabelle th .sort-pfeil {
display: inline-block; margin-left: 0.3rem; opacity: 0.3; font-size: 0.7rem;
}
.kat-tabelle th.sort-aktiv .sort-pfeil { opacity: 1; color: var(--primary); }
.kat-tabelle td {
padding: 0.5rem; border-bottom: 1px solid var(--border);
vertical-align: middle; font-size: 0.9rem;
}
.kat-zeile { cursor: pointer; }
.kat-zeile:hover { background: #F8F9FA; }
.kat-zeile.offen { background: #EBF5FB; }
.kat-thumb {
width: 40px; height: 40px; border-radius: 6px; object-fit: cover;
background: linear-gradient(135deg, #ECF0F1, #D5DBDB);
display: flex; align-items: center; justify-content: center;
font-size: 1.2rem; color: var(--text-light);
}
.kat-thumb img { width: 100%; height: 100%; object-fit: cover; border-radius: 6px; }
.kat-detail { display: none; background: #F8F9FA; }
.kat-detail.offen { display: table-row; }
.kat-detail-inner {
display: flex; gap: 1.5rem; padding: 1rem 0.5rem; flex-wrap: wrap;
}
.kat-detail-bild img {
max-width: 260px; max-height: 260px; border-radius: 8px;
border: 1px solid var(--border); background: #fff;
}
.kat-detail-bild-platzhalter {
width: 260px; height: 200px; border-radius: 8px;
background: linear-gradient(135deg, #ECF0F1, #D5DBDB);
display: flex; align-items: center; justify-content: center;
font-size: 3rem; color: var(--text-light);
}
.kat-detail-info { flex: 1; min-width: 260px; }
.kat-detail-info h3 { margin-top: 0; }
.kat-detail-info .meta {
font-size: 0.85rem; color: var(--text-light); margin-bottom: 0.6rem;
}
.kat-dateien { margin-top: 0.5rem; }
.kat-dateien a {
display: inline-block; margin: 0.2rem 0.4rem 0.2rem 0;
padding: 0.2rem 0.6rem; background: #fff; border: 1px solid var(--border);
border-radius: 4px; text-decoration: none; font-size: 0.85rem; color: var(--text);
}
.kat-dateien a:hover { background: #ECF0F1; }
</style>
<h1>Projekte</h1>
<p style="color: var(--text-light);">
Was die AG gerade macht — laufende und fertige Projekte. Klick auf eine Zeile fuer Details.
Spalten-Header klicken zum Sortieren.
</p>
{% if projekte %}
<div class="card" style="padding: 0;">
<table class="kat-tabelle" id="kat-tabelle">
<thead>
<tr>
<th></th>
<th data-sort="titel">Titel<span class="sort-pfeil">▲▼</span></th>
<th data-sort="person">Wer<span class="sort-pfeil">▲▼</span></th>
<th data-sort="status">Status<span class="sort-pfeil">▲▼</span></th>
<th data-sort="erfolg">Erfolg<span class="sort-pfeil">▲▼</span></th>
<th data-sort="datum">Datum<span class="sort-pfeil">▲▼</span></th>
<th title="MakerWorld">MW</th>
</tr>
</thead>
<tbody>
{% for p in projekte %}
{# ====== Kompakte Zeile ====== #}
<tr class="kat-zeile" data-projekt="{{ p.id }}"
data-titel="{{ p.titel|lower }}"
data-person="{{ (p.vorname or '')|lower }}"
data-status="{{ p.status_lebenszyklus }}"
data-erfolg="{% if p.selbsteinschaetzung == 'zufrieden' %}1{% elif p.selbsteinschaetzung == 'mittel' %}2{% elif p.selbsteinschaetzung == 'gelernt' %}3{% else %}9{% endif %}"
data-datum="{{ p.abgeschlossen_am or p.geaendert_am or '' }}">
<td>
<div class="kat-thumb">
{% if p.hat_cover %}
<img src="{{ url_for('api.projekt_cover', projekt_id=p.id) }}" alt="">
{% else %}🎯{% endif %}
</div>
</td>
<td><strong>{{ p.titel }}</strong></td>
<td>{{ tier_emoji(p.tier or '') }} {{ p.vorname or '—' }}</td>
<td>
{% if p.status_lebenszyklus == 'in_arbeit' %}<span class="badge badge-mittel">🔧 laeuft</span>
{% else %}<span class="badge badge-anfaenger">✅ fertig</span>{% endif %}
</td>
<td>
{% if p.selbsteinschaetzung == 'zufrieden' %}🟢
{% elif p.selbsteinschaetzung == 'mittel' %}🟡
{% elif p.selbsteinschaetzung == 'gelernt' %}🔴
{% else %}—{% endif %}
</td>
<td style="font-size: 0.8rem; color: var(--text-light); white-space: nowrap;">
{{ (p.abgeschlossen_am or p.geaendert_am or '')|iso_format('%d.%m.%y') }}
</td>
<td style="text-align: center;">
{% if p.makerworld_url %}
<a href="{{ p.makerworld_url }}" target="_blank" rel="noopener noreferrer"
onclick="event.stopPropagation();" title="Auf MakerWorld" style="text-decoration: none;">🌐</a>
{% endif %}
</td>
</tr>
{# ====== Detail-Zeile (versteckt bis Klick) ====== #}
<tr class="kat-detail" data-fuer="{{ p.id }}">
<td colspan="7">
<div class="kat-detail-inner">
<div class="kat-detail-bild">
{% if p.hat_cover %}
<img src="{{ url_for('api.projekt_cover', projekt_id=p.id) }}" alt="">
{% else %}
<div class="kat-detail-bild-platzhalter">🎯</div>
{% endif %}
</div>
<div class="kat-detail-info">
<h3>{{ p.titel }}</h3>
<div class="meta">
{{ tier_emoji(p.tier or '') }} <strong>{{ p.vorname or '—' }}</strong>
{% if p.software_titel %} · 🖥️ {{ p.software_titel }}{% endif %}
{% if p.drucker_name %} · 🖨️ {{ p.drucker_name }}{% endif %}
{% if p.abgeschlossen_am %} · abgeschlossen {{ p.abgeschlossen_am|iso_format('%d.%m.%Y') }}{% endif %}
</div>
{% if p.beschreibung_md %}
<div style="margin-bottom: 0.8rem;">{{ p.beschreibung_md|markdown }}</div>
{% else %}
<p style="color: var(--text-light);"><em>Keine Beschreibung.</em></p>
{% endif %}
{% if p.dateien %}
<div class="kat-dateien">
<strong style="font-size: 0.85rem;">Dateien:</strong>
{% for d in p.dateien %}
<a href="{{ url_for('api.projekt_datei_download', projekt_id=p.id, datei_id=d.id) }}">
📎 {{ d.dateiname }}
<span style="color: var(--text-light); font-size: 0.75rem;">
({{ d.art }}, {{ (d.groesse_bytes // 1024) if d.groesse_bytes else 0 }} KB)
</span>
</a>
{% endfor %}
</div>
{% endif %}
{% if p.makerworld_url %}
<div style="margin-top: 0.8rem;">
<a href="{{ p.makerworld_url }}" target="_blank" rel="noopener noreferrer"
class="btn btn-secondary" style="font-size: 0.85rem;">
🌐 Auf MakerWorld ansehen ↗
</a>
</div>
{% endif %}
{# ====== "Sowas mache ich auch"-Button ====== #}
<div style="margin-top: 1rem; padding-top: 0.8rem; border-top: 1px solid var(--border);">
{% if p.profil_id == eigene_profil_id %}
<span style="font-size: 0.85rem; color: var(--text-light); font-style: italic;">
Dein eigenes Projekt.
</span>
{% elif darf_wiederholen %}
<form method="post"
action="{{ url_for('profil.projekt_aus_vorlage', vorlage_id=p.id) }}"
onclick="event.stopPropagation();"
onsubmit="return confirm('Neues Projekt mit Vorlage \'{{ p.titel }}\' anlegen?')">
<button class="btn btn-primary" style="font-size: 0.9rem;">
+ Sowas mache ich auch
</button>
</form>
{% else %}
<span style="font-size: 0.85rem; color: var(--text-light);">
{{ wiederhol_sperr_grund }}
</span>
{% endif %}
</div>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card">
<p style="color: var(--text-light);">Noch keine Projekte im Katalog.</p>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
(function() {
var tabelle = document.getElementById('kat-tabelle');
if (!tabelle) return;
var tbody = tabelle.querySelector('tbody');
// ===== Aufklappen =====
tabelle.querySelectorAll('.kat-zeile').forEach(function(zeile) {
zeile.addEventListener('click', function(e) {
if (e.target.tagName === 'A') return; // MakerWorld-Link nicht abfangen
var pid = zeile.dataset.projekt;
var detail = tabelle.querySelector('.kat-detail[data-fuer="' + pid + '"]');
if (!detail) return;
var offen = detail.classList.toggle('offen');
zeile.classList.toggle('offen', offen);
});
});
// ===== Sortieren =====
var sortRichtung = {}; // key -> 'asc' | 'desc'
function sortiereNach(key) {
var richtung = sortRichtung[key] === 'asc' ? 'desc' : 'asc';
sortRichtung = {}; // nur eine Sortierung aktiv
sortRichtung[key] = richtung;
// Zeilen-Paare einsammeln: [kat-zeile, kat-detail]
var paare = [];
tabelle.querySelectorAll('.kat-zeile').forEach(function(zeile) {
var pid = zeile.dataset.projekt;
var detail = tabelle.querySelector('.kat-detail[data-fuer="' + pid + '"]');
paare.push({ zeile: zeile, detail: detail, key: zeile.dataset[key] || '' });
});
// Sortier-Helper: erfolg + status sind numerisch/string-codiert, titel/person/datum string-lexikografisch
paare.sort(function(a, b) {
var va = a.key, vb = b.key;
if (key === 'erfolg') { va = parseInt(va) || 9; vb = parseInt(vb) || 9; }
if (va < vb) return richtung === 'asc' ? -1 : 1;
if (va > vb) return richtung === 'asc' ? 1 : -1;
return 0;
});
// Neu in tbody einhaengen (paar fuer paar — Detail folgt direkt)
paare.forEach(function(p) {
tbody.appendChild(p.zeile);
if (p.detail) tbody.appendChild(p.detail);
});
// Sortier-Indikator
tabelle.querySelectorAll('th[data-sort]').forEach(function(th) {
th.classList.remove('sort-aktiv');
var pfeil = th.querySelector('.sort-pfeil');
if (pfeil) pfeil.textContent = '▲▼';
});
var aktivTh = tabelle.querySelector('th[data-sort="' + key + '"]');
if (aktivTh) {
aktivTh.classList.add('sort-aktiv');
aktivTh.querySelector('.sort-pfeil').textContent = richtung === 'asc' ? '▲' : '▼';
}
}
tabelle.querySelectorAll('th[data-sort]').forEach(function(th) {
th.addEventListener('click', function() {
sortiereNach(th.dataset.sort);
});
});
// Initial: nach Datum absteigend (neueste oben)
sortiereNach('datum');
sortiereNach('datum'); // zweimal -> desc
})();
</script>
{% endblock %}

167
templates/software.html Normal file
View File

@@ -0,0 +1,167 @@
{% extends "base.html" %}
{% block title %}Software{% endblock %}
{% block content %}
{# Kleine, lokale CSS-Erganzungen fuer die Software-Karten.
Bewusst inline, damit alle Stil-Anpassungen auf einen Blick sichtbar sind.
Wenn die Site mehr eigene Stile bekommt, ziehen wir sie nach static/. #}
<style>
.sw-karte {
display: flex;
flex-direction: column;
gap: 0.6rem;
text-decoration: none;
color: inherit;
}
.sw-karte .bild-platzhalter {
height: 110px;
background: linear-gradient(135deg, #ECF0F1, #D5DBDB);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
color: var(--text-light);
}
.sw-karte img.logo {
height: 110px;
width: 100%;
object-fit: contain;
background: #fff;
border-radius: 6px;
padding: 0.5rem;
border: 1px solid var(--border);
}
.sw-karte h3 { margin: 0; color: var(--primary-dark); }
.sw-karte .plattform { font-size: 0.8rem; color: var(--text-light); margin-top: -0.2rem; }
.sw-karte .kurz { font-size: 0.95rem; line-height: 1.45; }
.sw-karte .btn-reihe {
margin-top: auto;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
padding-top: 0.4rem;
}
.sw-karte .btn-reihe .btn { font-size: 0.8rem; padding: 0.35rem 0.7rem; }
.sw-store {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
padding: 0.35rem 0.7rem;
border-radius: 6px;
background: #2C3E50;
color: #fff;
text-decoration: none;
}
.sw-store:hover { background: #1B2631; }
.sektion-kopf { margin: 2rem 0 0.8rem; }
.sektion-kopf:first-of-type { margin-top: 0.5rem; }
.sektion-kopf h2 { color: var(--text); }
.sektion-kopf p { color: var(--text-light); font-size: 0.9rem; }
</style>
{# ===========================================================================
Karten-Makro: wird unten in jeder Sektion verwendet.
Muss VOR den Aufrufen stehen — Jinja2 mit extends ist da streng.
=========================================================================== #}
{% macro karte(k) %}
<div class="card sw-karte">
{# Bild: Crop-Upload (BLOB) > static-Fallback > Emoji-Platzhalter #}
{% if k.hat_bild_blob %}
<img src="{{ url_for('api.software_bild', karte_id=k.id) }}" alt="{{ k.titel }}" class="logo">
{% elif k.bild_pfad %}
<img src="{{ url_for('static', filename='img/software/' ~ k.bild_pfad) }}" alt="{{ k.titel }}" class="logo">
{% else %}
<div class="bild-platzhalter">
{%- if k.kategorie == 'app' -%}📱
{%- elif k.kategorie == 'generator' -%}🧩
{%- else -%}🖥️
{%- endif -%}
</div>
{% endif %}
<h3>{{ k.titel }}</h3>
{% if k.plattform %}<div class="plattform">{{ k.plattform }}</div>{% endif %}
<div class="kurz">{{ k.kurzbeschreibung }}</div>
<div class="btn-reihe">
{% if k.url %}
<a href="{{ k.url }}" target="_blank" rel="noopener noreferrer" class="btn btn-primary">
{% if k.kategorie == 'generator' %}Zum Generator ↗{% else %}Zur Software ↗{% endif %}
</a>
{% endif %}
{% if k.tutorial_url %}
<a href="{{ k.tutorial_url }}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary">
Tutorial ↗
</a>
{% endif %}
{% if k.app_store_url %}
<a href="{{ k.app_store_url }}" target="_blank" rel="noopener noreferrer" class="sw-store">
App Store
</a>
{% endif %}
{% if k.play_store_url %}
<a href="{{ k.play_store_url }}" target="_blank" rel="noopener noreferrer" class="sw-store">
▶ Google Play
</a>
{% endif %}
</div>
</div>
{% endmacro %}
<h1>Software</h1>
<p style="color: var(--text-light);">
Diese Programme nutzen wir in der AG. Den Umgang lernst du in den
<a href="{{ url_for('oeffentlich.lektionen') }}">Lektionen</a> — hier ist nur die Uebersicht.
</p>
{# ---- Sektion: Software ---- #}
{% if gruppen.software %}
<div class="sektion-kopf">
<h2>Programme</h2>
<p>Auf Schul-PCs installiert oder im Browser nutzbar.</p>
</div>
<div class="grid">
{% for k in gruppen.software %}
{{ karte(k) }}
{% endfor %}
</div>
{% endif %}
{# ---- Sektion: Apps ---- #}
{% if gruppen.app %}
<div class="sektion-kopf">
<h2>Handy-Apps</h2>
<p>Aufs eigene Smartphone laden — QR-Codes zum Scannen folgen.</p>
</div>
<div class="grid">
{% for k in gruppen.app %}
{{ karte(k) }}
{% endfor %}
</div>
{% endif %}
{# ---- Sektion: Generatoren ---- #}
{% if gruppen.generator %}
<div class="sektion-kopf">
<h2>Online-Generatoren</h2>
<p>Webseiten, auf denen du fertige 3D-Modelle nach deinen Wuenschen erzeugen kannst — kein CAD noetig.</p>
</div>
<div class="grid">
{% for k in gruppen.generator %}
{{ karte(k) }}
{% endfor %}
</div>
{% endif %}
{# Wenn gar nichts da ist (Migration noch nicht gelaufen?) #}
{% if not gruppen.software and not gruppen.app and not gruppen.generator %}
<div class="card">
<p>Noch keine Software eingetragen.
{% if session.admin %}<a href="{{ url_for('admin.software_liste') }}">Im Admin anlegen.</a>{% endif %}
</p>
</div>
{% endif %}
{% endblock %}