Files
3d.lehrstun.de/templates/projekte_katalog.html
Hans Puettmann 8cbd803328 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>
2026-05-25 19:04:18 +02:00

280 lines
13 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 %}