diff --git a/README.md b/README.md
index b2044ed..8ca2644 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,30 @@ Wechsel automatisch dem Seitenverhältnis der gewählten Aufnahme.
> Der eingetippte Tag beschriftet deshalb vorerst nur den Slide-Knopf im
> Bedienfeld.
+### Eigene Bilder importieren
+
+Unter **Eigene Bilder** wählst du Dateien aus dem Finder, oder du ziehst sie
+direkt auf eine Slide. Beim Ablegen wird das Bild dort zum **zweiten Bild** und
+lässt sich im Bearbeiten-Modus frei schieben und in der Größe ändern; über den
+Knopf importierst du nur und ordnest danach unter *Inhalt* zu.
+
+Die Datei geht **byteweise unverändert** an den Server und liegt danach unter
+`assets/eigene/`. Nichts wird neu kodiert, skaliert oder umbenannt — nur
+Zeichen, die in einem Dateinamen nichts zu suchen haben, weichen einem
+Bindestrich.
+
+```
+assets/eigene/ importierte Bilder, gehören mit in den Commit
+```
+
+> Der Umweg über den Server ist Absicht. Ein Foto als Base64 im Projektstand
+> wäre größer als der gesamte übrige Stand und machte jeden Diff unlesbar. So
+> steht dort nur der Pfad.
+
+Die Auswahllisten unter *Inhalt* holt der Generator beim Start vom Server
+(`/bilder`), importierte Bilder stehen deshalb sofort darin — mit `★`
+gekennzeichnet.
+
**Mitgelieferte Slides bleiben Vorlagen.** Der Stand speichert für sie nur
einen Verweis und die Felder, die du überschrieben hast — eine Änderung an der
Vorlage im Code kommt dadurch weiterhin bei allen an, die sie nicht angefasst
@@ -107,10 +131,11 @@ assets/
├── partner/ DATEV, KWP, Google Calendar, Google Drive, MS Outlook, MS Teams
├── mockups/ Geräte-Mockups (Displays ausgeknockt, Screenshot liegt dahinter)
├── screens/ Software-Screenshots
+├── eigene/ selbst importierte Bilder
└── fonts/ Manrope 400–800, lokal
vendor/ html-to-image, JSZip
state/ Projektstände, gehören ins Repo
-serve.py Statischer Server + /save- und /state-Endpunkt
+serve.py Statischer Server + /save, /state, /upload, /bilder
rules.md Gestaltungsregeln, Farbwerte, Export-Wissen
```
diff --git a/post1.html b/post1.html
index ea43f1d..3f9144a 100644
--- a/post1.html
+++ b/post1.html
@@ -49,6 +49,13 @@
}
#slide-ops button { flex: none; padding: 8px 10px; }
+ /* Zielmarke beim Ablegen einer Datei. */
+ .holder.ablegen::before {
+ content: ""; position: absolute; inset: 0; z-index: 5; pointer-events: none;
+ outline: 12px dashed var(--accent-yellow); outline-offset: -6px;
+ background: rgba(241,183,41,.10);
+ }
+
.saved {
font-size: 11px; color: #6FCF97; opacity: 0; transition: opacity .2s;
margin-left: 6px;
@@ -803,6 +810,15 @@
+
+ Eigene Bilder
+
+
+
Oder eine Datei auf eine Slide ziehen — sie wird
+ dort das zweite Bild und lässt sich im Bearbeiten-Modus frei schieben.
+ Die Datei landet unverändert unter assets/eigene/ und gehört mit in den Commit.
+
+
Variante
@@ -1090,7 +1106,10 @@
/* Alle Screens im Ordner. Eine selbst angelegte Slide kann jeden davon
wählen, deshalb werden sie vollständig geladen und nicht nur die, die
eine Vorlage zufällig gerade benutzt. */
- const SCREENS = [
+ /* Ausgangsliste. Der Server liefert beim Start die tatsaechlich vorhandenen
+ Dateien nach, damit importierte Bilder sofort auftauchen und die Liste
+ nicht im Code gepflegt werden muss. */
+ let BILDER = [
{ pfad: "./assets/screens/pipelines.png", name: "Pipelines · Board" },
{ pfad: "./assets/screens/pipelines-liste.png", name: "Pipelines · Liste" },
{ pfad: "./assets/screens/pipelines-detail.png", name: "Pipelines · Detail" },
@@ -1117,6 +1136,9 @@
const basis = eintrag.ref ? VORLAGEN.find(v => v.key === eintrag.ref) : null;
const s = kopie(basis || NEUE_SLIDE);
Object.assign(s, eintrag.werte || {});
+ // null heisst "weg", nicht "leeres Objekt": nur so laesst sich ein zweites
+ // Bild, das die Vorlage mitbringt, wieder abwaehlen.
+ if (s.media2 === null) delete s.media2;
// Der Schlüssel landet im Dateinamen und muss eindeutig bleiben.
s.key = (basis ? basis.key : (eintrag.key || "eigen")) + (eintrag.ref ? "" : "-" + (i + 1));
return s;
@@ -1893,7 +1915,7 @@
/* Alle Screens, nicht nur die gerade benutzten: eine selbst angelegte
Slide kann jeden davon waehlen, und ein Bild, das erst beim Auswaehlen
geladen wuerde, fehlt im Export. */
- SCREENS.forEach(sc => paths.push(sc.pfad));
+ BILDER.forEach(sc => paths.push(sc.pfad));
// Optional: the layout falls back to a hint if the file is not there yet.
const optional = new Set(["./assets/mockups/laptop.png", "./assets/mockups/macbook-fels.png"]);
paths.push("./assets/mockups/laptop.png", "./assets/mockups/macbook-fels.png");
@@ -2443,6 +2465,148 @@
Geschrieben wird in slideListe[i].werte, nicht in SLIDES: SLIDES entsteht
aus der Vorlage plus diesen Ueberschreibungen. Wer nichts anfasst, bekommt
Aenderungen an der Vorlage im Code weiterhin mit. */
+ /* ─── Eigene Bilder ───────────────────────────────────────────────────────
+ Die Datei geht unveraendert an den Server und liegt danach unter
+ assets/eigene/. Der Umweg ist Absicht: als Base64 im Projektstand waere
+ ein einziges Foto groesser als der gesamte uebrige Stand und jeder Diff
+ unbrauchbar. So steht dort nur der Pfad. */
+ function bildOptionen() {
+ return BILDER.map(b =>
+ '').join("");
+ }
+
+ async function bilderNachladen() {
+ try {
+ const res = await fetch("/bilder", { cache: "no-store" });
+ if (!res.ok) return;
+ const liste = await res.json();
+ if (Array.isArray(liste) && liste.length) BILDER = liste;
+ } catch (_) { /* ohne Server bleibt die eingebaute Liste */ }
+ }
+
+ // Eine Datei einlesen, damit sie sofort im Canvas und im Export vorliegt.
+ async function bildBereitstellen(pfad) {
+ if (assets[pfad]) return true;
+ const res = await fetch(pfad, { cache: "no-store" }).catch(() => null);
+ if (!res || !res.ok) return false;
+ const blob = await res.blob();
+ assets[pfad] = await new Promise((ok, fail) => {
+ const fr = new FileReader();
+ fr.onload = () => ok(fr.result);
+ fr.onerror = fail;
+ fr.readAsDataURL(blob);
+ });
+ return true;
+ }
+
+ async function bildImportieren(datei) {
+ const res = await fetch("/upload", {
+ method: "POST",
+ // Kodiert, damit Umlaute und Leerzeichen den Header nicht sprengen.
+ headers: { "X-Filename": encodeURIComponent(datei.name) },
+ body: datei,
+ });
+ if (!res.ok) {
+ const w = await res.json().catch(() => ({}));
+ throw new Error(w.error || ("HTTP " + res.status));
+ }
+ const { pfad } = await res.json();
+ await bilderNachladen();
+ await bildBereitstellen(pfad);
+ return pfad;
+ }
+
+ // Masse aus dem Bild selbst: ein Hochformat soll nicht in einem
+ // Querformat-Rahmen stehen.
+ function seitenverhaeltnis(pfad) {
+ return new Promise(ok => {
+ const im = new Image();
+ im.onload = () => ok(im.naturalWidth / im.naturalHeight);
+ im.onerror = () => ok(1.6);
+ im.src = assets[pfad] || pfad;
+ });
+ }
+
+ async function alsZweitesBild(i, pfad) {
+ const ar = await seitenverhaeltnis(pfad);
+ const w = werteVon(i);
+ const alt = w.media2 || SLIDES[i].media2 || {};
+ w.media2 = {
+ img: pfad, w: alt.w || 400, ar: +ar.toFixed(4),
+ left: alt.left != null ? alt.left : 500,
+ bottom: alt.bottom != null ? alt.bottom : 60,
+ z: alt.z || 2, rot: alt.rot || 0,
+ };
+ slidesNeuAufbauen();
+ deckNeuBauen();
+ show(i);
+ saveState();
+ }
+
+ async function dateienAufnehmen(dateien, zielSlide) {
+ const bilder = [...dateien].filter(f => /^image\//.test(f.type));
+ if (!bilder.length) { setStatus("Keine Bilddatei dabei", "err"); return; }
+ setStatus("Importiere " + bilder.length + " Bild(er) …");
+ const pfade = [];
+ try {
+ for (const f of bilder) pfade.push(await bildImportieren(f));
+ } catch (err) {
+ setStatus("Import fehlgeschlagen: " + err.message, "err");
+ return;
+ }
+ // Beim Ablegen auf einer Slide wandert das erste Bild gleich dorthin. Ueber
+ // den Knopf importiert man nur; zugeordnet wird dann in den Feldern.
+ if (typeof zielSlide === "number") {
+ await alsZweitesBild(zielSlide, pfade[0]);
+ setStatus(pfade.length + " Bild(er) importiert, erstes auf Slide " +
+ (zielSlide + 1) + " gelegt", "ok");
+ } else {
+ syncInhalt();
+ setStatus(pfade.length + " Bild(er) importiert · unter Inhalt zuweisen", "ok");
+ }
+ }
+
+ document.getElementById("bild-waehlen").addEventListener("click",
+ () => document.getElementById("bild-datei").click());
+ document.getElementById("bild-datei").addEventListener("change", ev => {
+ const f = ev.target.files;
+ if (f && f.length) dateienAufnehmen(f);
+ ev.target.value = ""; // dieselbe Datei soll erneut waehlbar bleiben
+ });
+
+ /* Ablegen auf einer Slide. Der Browser oeffnet eine fallengelassene Datei
+ sonst einfach im Tab und der Projektstand waere verloren. */
+ (function ablegen() {
+ const sc = document.getElementById("scroller");
+ const zielAus = e => {
+ const h = e.target.closest && e.target.closest(".holder");
+ return h ? Number(h.dataset.n) : null;
+ };
+ ["dragenter", "dragover"].forEach(art => sc.addEventListener(art, e => {
+ if (!e.dataTransfer || [...e.dataTransfer.types].indexOf("Files") < 0) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "copy";
+ const n = zielAus(e);
+ deck.querySelectorAll(".holder").forEach((h, i) => h.classList.toggle("ablegen", i === n));
+ }));
+ sc.addEventListener("dragleave", e => {
+ if (e.relatedTarget && sc.contains(e.relatedTarget)) return;
+ deck.querySelectorAll(".holder").forEach(h => h.classList.remove("ablegen"));
+ });
+ sc.addEventListener("drop", e => {
+ if (!e.dataTransfer || !e.dataTransfer.files.length) return;
+ e.preventDefault();
+ const n = zielAus(e);
+ deck.querySelectorAll(".holder").forEach(h => h.classList.remove("ablegen"));
+ dateienAufnehmen(e.dataTransfer.files, n == null ? undefined : n);
+ });
+ // Ausserhalb der Flaeche nichts oeffnen, sondern still ablehnen.
+ ["dragover", "drop"].forEach(art => window.addEventListener(art, e => {
+ if (!sc.contains(e.target)) e.preventDefault();
+ }));
+ })();
+
function werteVon(i) {
const e = slideListe[i];
return (e.werte = e.werte || {});
@@ -2466,9 +2630,10 @@
).join("") +
(s.type === "feature"
? '' +
- ''
+ '' +
+ '' +
+ ''
: "") +
'
*Wort* setzt den Akzent, ein Zeilenumbruch bricht die Zeile um.' +
(s.type === "title" ? ' Beim Titel wirkt das nur im Layout „Klassisch“.' : '') + '
';
@@ -2476,7 +2641,9 @@
box.querySelectorAll("[data-feld]").forEach(el => {
if (document.activeElement === el) return;
const k = el.dataset.feld;
- el.value = k === "img" ? (s.img || "") : htmlZuText(s[k]);
+ if (k === "img") el.value = s.img || "";
+ else if (k === "media2") el.value = (s.media2 && s.media2.img) || "";
+ else el.value = htmlZuText(s[k]);
});
}
@@ -2485,6 +2652,16 @@
if (!el) return;
const k = el.dataset.feld;
const w = werteVon(current);
+ if (k === "media2") {
+ if (!el.value) {
+ // null statt weglassen: sonst kaeme das Bild der Vorlage zurueck.
+ w.media2 = null;
+ slidesNeuAufbauen(); deckNeuBauen(); show(current); saveState();
+ } else {
+ alsZweitesBild(current, el.value);
+ }
+ return;
+ }
if (k === "img") {
w.img = el.value;
// Das Rahmenformat folgt dem Bild, sonst steht ein Hochformat in einem
@@ -3157,6 +3334,7 @@
(async function boot() {
try {
setStatus("Lade Screens ...");
+ await bilderNachladen();
await preloadAssets();
const restored = await loadState();
buildDeck();
diff --git a/serve.py b/serve.py
index 353c74b..e07988a 100755
--- a/serve.py
+++ b/serve.py
@@ -11,6 +11,12 @@ Der Projektstand liegt nicht mehr nur im localStorage des Browsers, sondern
unter state/.json im Repo. Nur so ueberlebt er einen Rechnerwechsel:
gepusht und gezogen hat die andere Maschine denselben Stand. Der localStorage
bleibt als Zwischenspeicher, die Datei ist die Wahrheit.
+
+Eigene Bilder kommen ueber /upload herein und landen unverfaelscht als Datei
+unter assets/eigene/. Der Weg ueber den Projektstand waere der falsche: ein
+2-MB-PNG als Base64 blaeht state/post1.json auf und macht jeden Diff
+unlesbar. So steht im Stand nur der Pfad, und das Bild liegt mit im Repo --
+dasselbe Prinzip wie bei allen anderen Assets.
"""
import http.server
@@ -25,9 +31,15 @@ PORT = 4321
ROOT = Path(__file__).resolve().parent
TARGET = Path.home() / "Downloads"
STATE_DIR = ROOT / "state"
+EIGENE_DIR = ROOT / "assets" / "eigene"
SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]{1,120}$")
SAFE_PROJECT = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$")
MAX_STATE = 4 * 1024 * 1024
+MAX_BILD = 40 * 1024 * 1024
+# Nur verlustfreie oder unveraenderte Rasterbilder. Die Bytes werden nicht
+# angefasst, deshalb bleibt ein PNG genau das PNG, das der Finder geliefert hat.
+BILD_ENDUNGEN = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif"}
+BILD_ORDNER = ("assets/screens", "assets/eigene")
class Handler(http.server.SimpleHTTPRequestHandler):
@@ -90,11 +102,70 @@ class Handler(http.server.SimpleHTTPRequestHandler):
tmp.replace(p)
self._json(200, '{"ok":true}')
+ def do_GET(self):
+ """Vor der Dateiauslieferung nur die Bilderliste abfangen."""
+ if urllib.parse.urlparse(self.path).path == "/bilder":
+ self._liste_bilder()
+ return
+ super().do_GET()
+
+ def _liste_bilder(self):
+ """Alle verwendbaren Bilder, damit die Auswahl im Generator nicht im
+ Code gepflegt werden muss und importierte sofort auftauchen."""
+ raus = []
+ for ordner in BILD_ORDNER:
+ verz = ROOT / ordner
+ if not verz.is_dir():
+ continue
+ for f in sorted(verz.iterdir()):
+ if f.is_file() and f.suffix.lower() in BILD_ENDUNGEN:
+ raus.append({"pfad": "./" + ordner + "/" + f.name,
+ "name": f.stem, "ordner": ordner.split("/")[-1]})
+ self._json(200, json.dumps(raus, ensure_ascii=False))
+
+ def _upload(self):
+ """Ein eigenes Bild aufnehmen. Die Bytes gehen unveraendert auf die
+ Platte: nicht neu kodiert, nicht skaliert, nicht umbenannt ausser um
+ gefaehrliche Zeichen. Verlustfrei heisst hier woertlich unangetastet."""
+ name = os.path.basename(urllib.parse.unquote(self.headers.get("X-Filename", "")))
+ stamm, endung = os.path.splitext(name)
+ endung = endung.lower()
+ if endung not in BILD_ENDUNGEN:
+ self._json(400, '{"error":"kein Bildformat"}')
+ return
+ # Aus dem Stamm wird ein unverfaenglicher Dateiname, der Rest faellt weg.
+ stamm = re.sub(r"[^A-Za-z0-9._-]+", "-", stamm).strip("-.") or "bild"
+ stamm = stamm[:80]
+
+ length = int(self.headers.get("Content-Length", 0))
+ if length <= 0 or length > MAX_BILD:
+ self._json(400, '{"error":"ungueltige Groesse"}')
+ return
+ daten = self.rfile.read(length)
+
+ EIGENE_DIR.mkdir(parents=True, exist_ok=True)
+ ziel = (EIGENE_DIR / (stamm + endung)).resolve()
+ if ziel.parent != EIGENE_DIR.resolve():
+ self._json(400, '{"error":"ungueltiger Pfad"}')
+ return
+ # Gleicher Inhalt, gleicher Name: nicht doppelt ablegen.
+ n = 1
+ while ziel.exists() and ziel.read_bytes() != daten:
+ ziel = EIGENE_DIR / (stamm + "-" + str(n) + endung)
+ n += 1
+ ziel.write_bytes(daten)
+ pfad = "./assets/eigene/" + ziel.name
+ print(f"importiert: {ziel.name} ({len(daten)/1024:.0f} KB)")
+ self._json(200, json.dumps({"pfad": pfad, "name": ziel.stem}))
+
def do_POST(self):
path = urllib.parse.urlparse(self.path).path
if path == "/state":
self._put_state()
return
+ if path == "/upload":
+ self._upload()
+ return
if path != "/save":
self.send_error(404)
return