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:
@@ -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 & 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">
|
||||
|
||||
157
templates/admin_drucker.html
Normal file
157
templates/admin_drucker.html
Normal file
@@ -0,0 +1,157 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Drucker — 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">← 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> — 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 & 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 %}
|
||||
@@ -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;"
|
||||
|
||||
61
templates/admin_projekt_queue.html
Normal file
61
templates/admin_projekt_queue.html
Normal file
@@ -0,0 +1,61 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Projekt-Freigabe — 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">← 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 %}
|
||||
152
templates/admin_projekt_review.html
Normal file
152
templates/admin_projekt_review.html
Normal 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">← 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 %}
|
||||
90
templates/admin_projekte.html
Normal file
90
templates/admin_projekte.html
Normal file
@@ -0,0 +1,90 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Projekte — 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">← 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 %}
|
||||
98
templates/admin_slot_neu.html
Normal file
98
templates/admin_slot_neu.html
Normal file
@@ -0,0 +1,98 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Slot fuer SuS anlegen — 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">← 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 %}
|
||||
78
templates/admin_slots.html
Normal file
78
templates/admin_slots.html
Normal file
@@ -0,0 +1,78 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Slots — 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">← 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 %}
|
||||
178
templates/admin_software.html
Normal file
178
templates/admin_software.html
Normal file
@@ -0,0 +1,178 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Software — 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 %}
|
||||
354
templates/admin_software_bild.html
Normal file
354
templates/admin_software_bild.html
Normal file
@@ -0,0 +1,354 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Bild: {{ karte.titel }} — Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p><a href="{{ url_for('admin.software_bearbeiten', karte_id=karte.id) }}">← 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> · 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 %}
|
||||
138
templates/admin_software_edit.html
Normal file
138
templates/admin_software_edit.html
Normal file
@@ -0,0 +1,138 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ karte.titel }} bearbeiten — 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">← 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 %}
|
||||
@@ -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 %} — „{{ 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
163
templates/drucker.html
Normal 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 %}
|
||||
79
templates/profil_slot_neu.html
Normal file
79
templates/profil_slot_neu.html
Normal 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 %}
|
||||
75
templates/profil_slots.html
Normal file
75
templates/profil_slots.html
Normal 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 %}
|
||||
129
templates/projekt_abschluss.html
Normal file
129
templates/projekt_abschluss.html
Normal 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 %}
|
||||
32
templates/projekt_bearbeiten.html
Normal file
32
templates/projekt_bearbeiten.html
Normal 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">← 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 %}
|
||||
290
templates/projekt_cover.html
Normal file
290
templates/projekt_cover.html
Normal 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) }}">← 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 %}
|
||||
405
templates/projekt_detail.html
Normal file
405
templates/projekt_detail.html
Normal 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 %}
|
||||
66
templates/projekt_liste.html
Normal file
66
templates/projekt_liste.html
Normal 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 %}
|
||||
44
templates/projekt_neu.html
Normal file
44
templates/projekt_neu.html
Normal 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 %}
|
||||
279
templates/projekte_katalog.html
Normal file
279
templates/projekte_katalog.html
Normal 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
167
templates/software.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user