PageSourceSearch

https://energyexperts.ai/wp-content/plugins/energyExperts/assets/js/ee-press-manager.js?ver=2.25.616

js energyexperts.ai collected 2026-10-05 12:12:18 UTC 98,856 bytes, 1,931 lines download raw bytes

1/**
2 * Presse-Pflege (AP-B3 Portal, AP-B2 Projekt-Popup) — EINE Logik, zwei Scopes.
3 *
4 * Spricht mit /energyExperts/v1/press-admin/… (Session + X-WP-Nonce).
5 * Sub-Tabs clientseitig; Uploads per XMLHttpRequest (Fortschritt, Druck-
6 * bilder bis 50 MB). Kein Framework, keine Abhaengigkeiten.
7 *
8 * Parametrisierung (AP-B2):
9 *   EEPressManager.init(rootEl, {
10 *     apiBase:   'https://…/wp-json/energyExperts/v1/press-admin',
11 *     nonce:     '…',
12 *     scope:     'global' | 'project',
13 *     projectId: 2312,                 // nur project
14 *     basePath:  '/global' | '/projects/2312',   // Sammlungen
15 *     headUrl:   'https://…/showcase/project/2312', // nur project: Kopfdaten-PUT
16 *     slug:      'agri-pv-…',          // nur project: Grant fuer Presse-Links
17 *     theme:     'light' | 'dark'      // Klassen der gerenderten Listen
18 *   })
19 *
20 * - Portal-Seite (#eePressManager, scope=global, hell): initialisiert sich
21 *   selbst aus window.eePressManager (wp_localize_script).
22 * - Projekt-Popup (#projectSettingsPanePresse, scope=project, dunkel):
23 *   EEPressManager.initProjectPane(projectId) — Aufruf aus
24 *   ee-project-showcase.js beim Popup-Open; laedt erst beim ersten Klick
25 *   auf das Segment "Presse" im Studio-Pane (lazy, AP-B4).
26 *
27 * Pressebilder (KONZEPT-PRESSE-PROJEKTFREIGABE Kap. 4, AP7): KEINE Maske
28 * mehr. Die Dropzone [data-dropzone="media"] laedt jede Datei sofort und
29 * nacheinander hoch (Platzhalter-Kachel mit Fortschritt, Titel-Vorschlag
30 * aus dem Dateinamen, Server leitet die Art ab, Start "geschuetzt").
31 * Danach wird an der Kachel gepflegt: Titel (Klick -> Feld, Enter/Blur
32 * speichert, Esc bricht ab), oeffentlich/geschuetzt (Umschalter), Personen
33 * (Schalter, koppelt auf geschuetzt), Bildnachweis (vorbelegt mit dem
34 * Standard, Chip "Standard"), Bestand mit Sperrfrist -> "aufheben".
35 * Gespeichert wird per change/Enter, nicht nur per focusout (Safari, B17).
36 *
37 * Alle DOM-Zugriffe haengen an data-Attributen (data-editor, data-list,
38 * data-action, data-press-head, data-inline, …) — NICHT an Klassen. Klassen kommen aus
39 * THEMES (light = ee-press-*, dark = .b2b-doc-row/.ee-b2b-badge/.ee-thumb).
40 */
41(function () {
42    'use strict';
43
44    // ------------------------------------------------------------------
45    // Reine Hilfen (auch fuer tests/js exportiert)
46    // ------------------------------------------------------------------
47
48    function esc(s) {
49        return String(s == null ? '' : s)
50            .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
51            .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
52    }
53
54    // R3/S2: icons from the shared module assets/js/ee-icons.js (window.eeIcons,
55    // monochrome SVG, currentColor); missing module -> no icon, never a crash.
56    function ico(name, size) {
57        return (typeof window !== 'undefined' && window.eeIcons) ? window.eeIcons.svg(name, size) : '';
58    }
59
60    function bytes(n) {
61        n = Number(n || 0);
62        if (n >= 1048576) { return (n / 1048576).toFixed(n >= 10485760 ? 0 : 1) + ' MB'; }
63        if (n >= 1024) { return Math.round(n / 1024) + ' KB'; }
64        return n + ' B';
65    }
66
67    function dateDe(iso) {
68        if (!iso) { return ''; }
69        var m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/);
70        return m ? m[3] + '.' + m[2] + '.' + m[1] : String(iso);
71    }
72
73    /** 'YYYY-MM-DDTHH:MM' (Ortszeit, embargo_until_local) -> 'DD.MM.YYYY HH:MM'. */
74    function dateTimeDe(local) {
75        if (!local) { return ''; }
76        var m = String(local).match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/);
77        return m ? m[3] + '.' + m[2] + '.' + m[1] + ' ' + m[4] + ':' + m[5] : String(local);
78    }
79
80    /** UTC 'YYYY-MM-DD HH:MM:SS' -> Browser-Ortszeit de-DE. */
81    function utcToLocalDe(utc) {
82        if (!utc) { return ''; }
83        var d = new Date(String(utc).replace(' ', 'T') + 'Z');
84        if (isNaN(d.getTime())) { return String(utc); }
85        return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
86    }
87
88    /** Text des Sichtbarkeits-Badges. */
89    function badgeText(item) {
90        var s = (item && item.effective_state) || 'public';
91        if (s === 'embargo') { return 'Sperrfrist bis ' + dateTimeDe(item.embargo_until_local); }
92        if (s === 'protected') { return 'geschützt'; }
93        return 'öffentlich';
94    }
95
96    /**
97     * Basis-Pfad der Sammlungen aus Scope/Projekt-ID.
98     */
99    function basePathFor(scope, projectId) {
100        return scope === 'project' ? '/projects/' + Number(projectId || 0) : '/global';
101    }
102
103    /**
104     * Persons switch / raw footage (kind=broll) force visibility=protected in
105     * the request body (mirror of the server rule in PressFields; the server
106     * enforces it as well).
107     */
108    function enforceVisibility(fields) {
109        var out = {};
110        Object.keys(fields || {}
110).forEach(function (k) { out[k] = fields[k]; });
111        if (String(out.has_persons) === '1' || out.kind === 'broll') {
112            out.visibility = 'protected';
113        }
114        return out;
115    }
116
117    /**
118     * Titel-Vorschlag aus dem Dateinamen (Spiegel von
119     * PressFields::title_from_filename): Endung weg, '_'/'-' -> Leerzeichen.
120     */
121    function titleFromFilename(name) {
122        var base = String(name == null ? '' : name).split(/[\\/]/).pop();
123        var dot = base.lastIndexOf('.');
124        if (dot > 0) { base = base.slice(0, dot); }
125        return base.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 190);
126    }
127
128    /** Erlaubte Upload-Endungen der Pressebilder (Bild + Video). */
129    var MEDIA_EXT = /\.(jpe?g|png|mp4|mov)$/i;
130    var MEDIA_REJECTED_EXT = /\.(tif|tiff|webp|heic|heif|gif|bmp)$/i;
131
132    /**
133     * Client-Vorpruefung einer Mediendatei (der Server prueft per Inhalt
134     * noch einmal). Liefert die Fehlermeldung oder null.
135     */
136    function mediaFileError(file, limit) {
137        if (!file) { return 'Keine Datei.'; }
138        var name = String(file.name || '');
139        if (MEDIA_REJECTED_EXT.test(name)) {
140            return 'TIFF/WebP/HEIC werden nicht unterstützt – der Bildnachweis kann nur in JPG und PNG gestempelt werden. Bitte als JPG (300 dpi) oder PNG exportieren.';
141        }
142        if (!MEDIA_EXT.test(name)) {
143            return 'Nur JPG, PNG, MP4 oder MOV.';
144        }
145        if (limit && file.size > limit) {
146            return 'Datei zu gross (' + bytes(file.size) + '). Maximal ' + bytes(limit) + '.';
147        }
148        return null;
149    }
150
151    /** True, wenn eine Sperrfrist (UTC) noch in der Zukunft liegt. */
152    function embargoActive(item, nowMs) {
153        var u = item && item.embargo_until;
154        if (!u) { return false; }
155        var d = new Date(String(u).replace(' ', 'T') + 'Z');
156        return !isNaN(d.getTime()) && d.getTime() > (nowMs == null ? Date.now() : nowMs);
157    }
158
159    /**
160     * Inline-Nachweis -> Request-Wert: leer oder gleich Standard = null
161     * (Bild folgt dem Standard), sonst der getrimmte Text.
162     */
163    function creditValue(value, def) {
164        var v = String(value == null ? '' : value).trim();
165        return (v === '' || v === String(def || '').trim()) ? null : v;
166    }
167
168    /** Anzahl oeffentlicher Assets (Dokumente + Bilder + O-Toene). */
169    function publicCount(items) {
170        var n = 0;
171        ['documents', 'media', 'quotes'].forEach(function (k) {
172            (items && items[k] || []).forEach(function (it) {
173                if ((it.effective_state || 'public') === 'public') { n++; }
174            });
175        });
176        return n;
177    }
178
179    // ------------------------------------------------------------------
180    // Klassen je Theme
181    // ------------------------------------------------------------------
182
183    var THEMES = {
184        light: {
185            item: 'ee-press-item', grip: 'ee-press-grip', body: 'ee-press-item-body', title: 'ee-press-item-title',
186            meta: 'ee-press-item-meta', actions: 'ee-press-item-actions', quote: 'ee-press-item-quote',
187            btn: 'ee-press-btn ee-press-btn-xs', btnDanger: 'ee-press-btn ee-press-btn-xs ee-press-btn-danger', btnView: 'ee-press-btn ee-press-btn-xs',
188            badge: 'ee-press-badge', badgePublic: 'is-public', badgeProtected: 'is-protected', badgeEmbargo: 'is-embargo', badgeType: 'ee-press-badge is-type',
189            empty: 'ee-press-emptylist', muted: 'ee-press-muted', logo: 'ee-press-logo', dot: 'ee-press-dot',
190            thumb: 'ee-press-thumb', thumbImg: 'ee-press-thumb-img', thumbVideo: 'ee-press-thumb-video', thumbBadges: 'ee-press-thumb-badges',
191            thumbBody: 'ee-press-thumb-body', thumbTitle: 'ee-press-thumb-title', thumbMeta: 'ee-press-thumb-meta', thumbActs: 'ee-press-thumb-acts',
192            table: 'ee-b2b-doc-table', statusGreen: 'ee-b2b-badge is-green', statusMuted: 'ee-b2b-badge is-muted', statusOpen: 'ee-b2b-badge is-open'
193        },
194        dark: {
195            item: 'b2b-doc-row', grip: 'ee-press-grip', body: 'b2b-doc-info', title: 'b2b-doc-label',
196            meta: 'b2b-doc-meta', actions: 'b2b-doc-actions', quote: 'ee-press-quote',
197            btn: 'b2b-doc-replace', btnDanger: 'b2b-doc-delete', btnView: 'b2b-doc-view',
198            badge: 'ee-b2b-badge', badgePublic: 'is-green', badgeProtected: 'is-muted', badgeEmbargo: 'is-open', badgeType: 'ee-b2b-badge is-neutral',
199            empty: 'ee-list-empty', muted: 'ee-press-muted', logo: 'ee-press-logo', dot: 'ee-press-dot',
200            thumb: 'ee-press-mcard', thumbImg: 'ee-press-mcard-img', thumbVideo: 'ee-press-mcard-video', thumbBadges: 'ee-press-mcard-badges',
201            thumbBody: 'ee-press-mcard-body', thumbTitle: 'ee-press-mcard-title', thumbMeta: 'b2b-doc-meta', thumbActs: 'ee-thumb-acts',
202            table: 'ee-b2b-doc-table', statusGreen: 'ee-b2b-badge is-green', statusMuted: 'ee-b2b-badge is-muted', statusOpen: 'ee-b2b-badge is-open'
203        }
204    };
205
206    // ------------------------------------------------------------------
207    // Instanz
208    // ------------------------------------------------------------------
209
210    function create(root, opts) {
211        var cfg = {};
212        var T = THEMES.light;
213        var state = {
214            settings: null,
215            defaults: {},
216            brand: null,
217            head: null,
218            zipInfo: null,
219            items: { documents: [], media: [], quotes: [], echo: [] },
220            links: [],
221            projects: [],
222            editing: {},
223            uploads: [],
224            mediaDefaultCredit: null,
225            renderedDefaultCredit: null
226        };
227        var uploadSeq = 0;
228        var apiBase = '';
229
230        /**
231         * (Um-)Konfigurieren. Abgeleitete Werte (basePath, headUrl) werden
232         * aus den NEUEN Optionen gerechnet, damit ein Projektwechsel an
233         * derselben Wurzel nicht die alten Pfade behaelt.
234         */
235        function configure(o) {
236            o = o || {};
237            Object.keys(o).forEach(function (k) { cfg[k] = o[k]; });
238            cfg.scope     = cfg.scope === 'project' ? 'project' : 'global';
239            cfg.projectId = Number(cfg.projectId || 0);
240            cfg.basePath  = String(o.basePath || basePathFor(cfg.scope, cfg.projectId)).replace(/\/$/, '');
241            cfg.theme     = cfg.theme || (cfg.scope === 'project' ? 'dark' : 'light');
242            T = THEMES[cfg.theme] || THEMES.light;
243            apiBase = String(cfg.apiBase || '').replace(/\/$/, '');
244            if (o.headUrl) {
245                cfg.headUrl = o.headUrl;
246            } else if (cfg.scope === 'project' && cfg.showcaseBase) {
247                cfg.headUrl = String(cfg.showcaseBase).replace(/\/$/, '') + '/' + cfg.projectId;
248            } else if (cfg.scope !== 'project') {
249                cfg.headUrl = null;
250            }
251            root.setAttribute('data-press-scope', cfg.scope);
252        }
253        configure(opts);
254
255        function ownerDoc() { return root.ownerDocument || document; }
256        function $(sel, ctx) { return (ctx || root).querySelector(sel); }
257        function $$(sel, ctx) { return Array.prototype.slice.call((ctx || root).querySelectorAll(sel)); }
258        function isProject() { return cfg.scope === 'project'; }
259
260        function notice(text, isError) {
261            var box = $('[data-press-notice]');
262            if (!box) { return; }
263            box.textContent = text;
264            box.classList.toggle('is-error', !!isError);
265            box.hidden = !text;
266            if (text && !isError) {
267                clearTimeout(notice._t);
268                notice._t = setTimeout(function () { box.hidden = true; }, 4000);
269            }
270        }
271
272        function errorMessage(err, fallback) {
273            if (!err) { return fallback || 'Unbekannter Fehler'; }
274            if (typeof err === 'string') { return err; }
275            if (err.message) { return err.message; }
276            return fallback || 'Unbekannter Fehler';
277        }
278
279        function url(path) {
280            return /^https?:\/\//.test(path) ? path : apiBase + path;
281        }
282
283        function api(method, path, body) {
284            var o = {
285                method: method,
286                credentials: 'same-origin',
287                headers: { 'X-WP-Nonce': cfg.nonce, 'Accept': 'application/json' }
288            };
289            if (body !== undefined) {
290                o.headers['Content-Type'] = 'application/json';
291                o.body = JSON.stringify(body);
292            }
293            return fetch(url(path), o).then(function (res) {
294                return res.text().then(function (txt) {
295                    var data = null;
296                    try { data = txt ? JSON.parse(txt) : null; } catch (e) { data = null; }
297                    if (!res.ok) {
298                        var msg = (data && data.message) ? data.message : ('HTTP ' + res.status);
299                        var e2 = new Error(msg);
300                        e2.status = res.status;
301                        e2.data = data;
302                        throw e2;
303                    }
304                    return data;
305                });
306            });
307        }
308
309        /** Multipart-Upload mit Fortschritt (XHR — fetch meldet keinen Upload-Fortschritt). */
310        function upload(path, formData, onProgress) {
311            return new Promise(function (resolve, reject) {
312                var xhr = new XMLHttpRequest();
313                xhr.open('POST', url(path), true);
314                xhr.setRequestHeader('X-WP-Nonce', cfg.nonce);
315                xhr.setRequestHeader('Accept', 'application/json');
316                xhr.withCredentials = true;
317                if (xhr.upload && onProgress) {
318                    xhr.upload.addEventListener('progress', function (ev) {
319                        if (ev.lengthComputable) { onProgress(ev.loaded / ev.total); }
320                    });
321                }
322                xhr.onload = function () {
323                    var data = null;
324                    try { data = xhr.responseText ? JSON.parse(xhr.responseText) : null; } catch (e) { data = null; }
325                    if (xhr.status >= 200 && xhr.status < 300) {
326                        resolve(data);
327                    } else {
328                        var msg = (data && data.message) ? data.message : ('HTTP ' + xhr.status);
329                        if (xhr.status === 413 && !(data && data.message)) {
330                            msg = 'Die Datei ist zu gross fuer den Server (HTTP 413). Server-Grenze: ' + bytes(cfg.wpMaxBytes) + '.';
331                        }
332                        var err = new Error(msg);
333                        err.status = xhr.status;
334                        reject(err);
335                    }
336                };
337                xhr.onerror = function () { reject(new Error('Netzwerkfehler beim Upload.')); };
338                xhr.send(formData);
339            });
340        }
341
342        function copyText(text, btn) {
343            var done = function () {
344                if (!btn) { return; }
345                var old = btn.textContent;
346                btn.textContent = 'Kopiert';
347                setTimeout(function () { btn.textContent = old; }, 1500);
348            };
349            if (navigator.clipboard && navigator.clipboard.writeText) {
350                navigator.clipboard.writeText(text).then(done, function () { fallbackCopy(text); done(); });
351            } else {
352                fallbackCopy(text);
353                done();
354            }
355        }
356
357        function fallbackCopy(text) {
358            var ta = document.createElement('textarea');
359            ta.value = text;
360            ta.setAttribute('readonly', '');
361            ta.style.position = 'fixed';
362            ta.style.opacity = '0';
363            document.body.appendChild(ta);
364            ta.select();
365            try { document.execCommand('copy'); } catch (e) { /* egal */ }
366            document.body.removeChild(ta);
367        }
368
369        function badge(item) {
370            var s = item.effective_state || 'public';
371            var cls = s === 'embargo' ? T.badgeEmbargo : (s === 'protected' ? T.badgeProtected : T.badgePublic);
372            var txt = badgeText(item);
373            return '<span class="' + T.badge + ' ' + cls + '" title="' + esc(txt) + '">' + esc(txt) + '</span>';
374        }
375
376        function creditDot(stateName) {
377            var titles = {
378                ok: 'Bildnachweis in Datei gestempelt (IPTC/XMP)',
379                pending: 'Nachweis noch nicht gestempelt',
380                unsupported: 'Format traegt keinen Stempel (Video/Rohmaterial)',
381                failed: 'Stempeln fehlgeschlagen – erneut versuchen'
382            };
383            return '<span class="' + T.dot + ' is-' + esc(stateName) + '" title="' + esc(titles[stateName] || stateName) + '"></span>';
384        }
385
386        /**
387         * Wirksamer Standard-Nachweis: Projekt-Kopf vor globaler Einstellung;
388         * solange /settings noch nicht da ist, der Wert aus GET media
389         * (default_credit, serverseitig aufgeloest).
390         */
391        function defaultCredit() {
392            var h = state.head || {};
393            var fromHead = isProject() ? String(h.press_default_credit || '').trim() : '';
394            if (fromHead) { return fromHead; }
395            var global = state.settings ? String(state.settings.default_credit || '').trim() : String(state.mediaDefaultCredit || '').trim();
396            return global || 'DoppelErnte GmbH';
397        }
398
399        function creditLine(credit) {
400            var s = state.settings || {};
401            var link = (s.credit_link_text || '').trim() || 'doppelernte.de';
402            return 'Foto: ' + ((credit || '').trim() || defaultCredit()) + ' (' + link + ')';
403        }
404
405        function refreshCreditLine() {
406            var cl = $('[data-press-creditline]');
407            if (cl) { cl.textContent = creditLine(); }
408        }
409
410        /**
411         * R3/S9 (J): read-only line "Pressekontakt: <global>" in the project
412         * pane, fed from GET /settings (press_settings.contact_*).
413         */
414        function refreshContactLine() {
415            var box = $('[data-press-contact-global]');
416            if (!box) { return; }
417            var s = state.settings || {};
418            var name = $('[data-press-contact-name]', box);
419            var mail = $('[data-press-contact-email]', box);
420            if (name) { name.textContent = (s.contact_name || '').trim() || '—'; }
421            if (mail) {
422                var e = (s.contact_email || '').trim();
423                mail.textContent = e;
424                mail.href = e ? 'mailto:' + e : '#';
425                mail.hidden = !e;
426                var sep = mail.previousElementSibling;
427                if (sep && sep.classList.contains('ee-press-sep')) { sep.hidden = !e; }
428            }
429        }
430
431        // --------------------------------------------------------------
432        // Sub-Tabs
433        // --------------------------------------------------------------
434
435        function tabKey() { return 'eePressManagerTab:' + cfg.scope; }
436
437        function showTab(key) {
438            $$('[data-sub]').forEach(function (b) {
439                var on = b.getAttribute('data-sub') === key;
440                b.classList.toggle('on', on);
441                b.setAttribute('aria-selected', on ? 'true' : 'false');
442            });
443            $$('[data-pane]').forEach(function (p) {
444                var on = p.getAttribute('data-pane') === key;
445                p.classList.toggle('on', on);
446                p.hidden = !on;
447            });
448            try { window.localStorage.setItem(tabKey(), key); } catch (e) { /* privat */ }
449        }
450
451        // --------------------------------------------------------------
452        // Rendern
453        // --------------------------------------------------------------
454
455        function setCount(key, n) {
456            var el = $('[data-count="' + key + '"]');
457            if (el) { el.textContent = n > 0 ? String(n) : ''; }
458        }
459
460        function refreshPublicCount() {
461            var el = $('[data-press-public-count]');
462            if (!el) { return; }
463            var n = publicCount(state.items);
464            el.textContent = n + (n === 1 ? ' öffentliches Asset' : ' öffentliche Assets');
465        }
466
467        function docTypeLabel(key) {
468            var labels = cfg.docTypes || {};
469            return labels[key] || key;
470        }
471
472        function renderDocuments() {
473            var list = $('[data-list="documents"]');
474            var items = state.items.documents;
475            setCount('documents', items.length);
476            refreshPublicCount();
477            if (!list) { return; }
478            if (!items.length) {
479                list.innerHTML = '<div class="' + T.empty + '">Noch keine Dokumente. Pressemitteilungen und Factsheets hier hochladen.</div>';
480                return;
481            }
482            list.innerHTML = items.map(function (d) {
483                var meta = [];
484                meta.push(d.mime === 'application/pdf' ? 'PDF' : 'DOCX');
485                if (d.filesize) { meta.push(bytes(d.filesize)); }
486                if (d.pages) { meta.push(d.pages + ' Seiten'); }
487                if (d.published_on) { meta.push('veröffentlicht ' + dateDe(d.published_on)); }
488                var dot = cfg.theme === 'dark' ? '<span class="b2b-doc-dot' + ((d.effective_state || 'public') === 'public' ? ' is-present' : '') + '"></span>' : '';
489                return '<div class="' + T.item + '" draggable="true" data-id="' + d.id + '" data-collection="documents">'
490                    + '<span class="' + T.grip + '" title="Ziehen zum Sortieren" aria-hidden="true">' + ico('grip', 14) + '</span>' + dot
491                    + '<div class="' + T.body + '">'
492                    + '<div class="' + T.title + '">' + esc(d.title) + ' <span class="' + T.badgeType + '">' + esc(docTypeLabel(d.doc_type)) + '</span> ' + badge(d) + '</div>'
493                    + '<div class="' + T.meta + '">' + esc(meta.join(' · ')) + '</div>'
494                    + '</div>'
495                    + '<div class="' + T.actions + '">'
496                    + (d.url ? '<a class="' + T.btnView + '" href="' + esc(d.url) + '" target="_blank" rel="noopener">Ansehen</a>' : '')
497                    + '<button type="button" class="' + T.btn + '" data-action="edit" data-collection="documents" data-id="' + d.id + '">Bearbeiten / Ersetzen</button>'
498                    + '<button type="button" class="' + T.btnDanger + '" data-action="delete" data-collection="documents" data-id="' + d.id + '">Löschen</button>'
499                    + '</div></div>';
500            }).join('');
501        }
502
503        // ---- Pressebilder: Kacheln mit Inline-Pflege (AP7) -------------
504
505        function mediaLocked(m) { return !!(m && (m.has_persons || m.kind === 'broll')); }
506
507        /** Umschalter oeffentlich/geschuetzt; gesperrt (mit Grund) bei Personen/Rohmaterial. */
508        function visibilityToggle(m) {
509            var locked = mediaLocked(m);
510            var isPublic = !locked && m.visibility === 'public';
511            var why = m.has_persons ? 'Personenaufnahmen sind immer geschützt (nur mit Presse-Link).'
512                : (m.kind === 'broll' ? 'Rohmaterial ist immer geschützt (nur mit Presse-Link).'
513                    : (isPublic ? 'Öffentlich – klicken, um es nur mit Presse-Link freizugeben' : 'Geschützt (nur mit Presse-Link) – klicken, um es öffentlich zu machen'));
514            return '<button type="button" class="' + T.badge + ' ' + (isPublic ? T.badgePublic : T.badgeProtected) + ' ee-press-vis-toggle"'
515                + ' data-action="toggle-visibility" data-id="' + m.id + '" aria-pressed="' + (isPublic ? 'true' : 'false') + '"'
516                + ' title="' + esc(why) + '"' + (locked ? ' disabled' : '') + '>' + (isPublic ? 'öffentlich' : 'geschützt') + '</button>';
517        }
518
519        /** Bestand mit laufender Sperrfrist: Badge + "aufheben" (das Feld gibt es nicht mehr). */
520        function embargoBadge(m) {
521            if (!embargoActive(m)) { return ''; }
522            var when = dateTimeDe(m.embargo_until_local) || utcToLocalDe(m.embargo_until);
523            return '<span class="' + T.badge + ' ' + T.badgeEmbargo + ' ee-press-embargo" title="Sperrfrist aus dem Bestand – bis dahin nicht öffentlich">Sperrfrist bis ' + esc(when)
524                + ' <button type="button" class="ee-press-embargo-lift" data-action="lift-embargo" data-id="' + m.i
524d + '">aufheben</button></span>';
525        }
526
527        function personsSwitch(m) {
528            return '<label class="ee-press-inline-persons" title="Personen erkennbar? Dann ist das Bild immer geschützt.">'
529                + '<input type="checkbox" data-inline="has_persons" data-id="' + m.id + '"' + (m.has_persons ? ' checked' : '') + '> Personen</label>';
530        }
531
532        function titleControl(m) {
533            var label = m.title || m.file_basename || ('Bild ' + m.id);
534            return '<div class="' + T.thumbTitle + ' ee-press-inline-title">' + creditDot(m.credit_state)
535                + '<button type="button" class="ee-press-inline-titlebtn" data-inline-edit="title" data-id="' + m.id + '" title="Klicken zum Umbenennen">' + esc(label) + '</button></div>';
536        }
537
538        function creditControl(m) {
539            var def = defaultCredit();
540            var val = String(m.credit || '').trim() || def;
541            return '<div class="ee-default-wrap ee-press-inline-credit' + (val === def ? ' is-default' : '') + '">'
542                + '<input type="text" class="ee-press-inline-input" data-inline="credit" data-id="' + m.id + '" maxlength="190"'
543                + ' data-default="' + esc(def) + '" value="' + esc(val) + '" aria-label="Bildnachweis" title="Bildnachweis (leer oder gleich Standard = Standard)">'
544                + '<span class="ee-default-chip" aria-hidden="true">Standard</span></div>';
545        }
546
547        function mediaActions(m) {
548            var dark = cfg.theme === 'dark';
549            return (m.kind === 'image' && m.credit_state !== 'ok' ? '<button type="button" class="' + (dark ? '' : T.btn) + '" data-action="restamp" data-id="' + m.id + '" title="Nachweis erneut in die Datei stempeln">Stempeln</button>' : '')
550                + (m.url ? '<a class="' + (dark ? 'ee-press-mcard-link' : T.btn) + '" href="' + esc(m.url) + '" target="_blank" rel="noopener">Original</a>' : '')
551                + '<button type="button" class="' + (dark ? 'del' : T.btnDanger) + '" data-action="delete" data-collection="media" data-id="' + m.id + '">Löschen</button>';
552        }
553
554        function mediaCard(m) {
555            var meta = [];
556            if (m.kind === 'video' || m.kind === 'broll') { meta.push(m.kind === 'broll' ? 'Rohmaterial' : 'Video'); }
557            if (m.width && m.height) { meta.push(m.width + ' × ' + m.height + ' px'); }
558            if (m.dpi) { meta.push(m.dpi + ' dpi'); }
559            if (m.filesize) { meta.push(bytes(m.filesize)); }
560            var visual = m.kind === 'image'
561                ? '<img class="' + T.thumbImg + '" src="' + esc(m.preview_url || m.url) + '" alt="' + esc(m.title || '') + '" loading="lazy">'
562                : '<div class="' + T.thumbVideo + '">' + (m.kind === 'broll' ? 'Rohmaterial' : 'Video') + '</div>';
563            var badges = '<div class="' + T.thumbBadges + ' ee-press-inline-badges">' + visibilityToggle(m) + personsSwitch(m) + embargoBadge(m) + '</div>';
564            var body = titleControl(m)
565                + '<div class="' + T.thumbMeta + '">' + esc(meta.join(' · ')) + '</div>'
566                + badges
567                + creditControl(m)
568                + (m.file_basename ? '<div class="' + T.thumbMeta + ' ee-press-filename" title="Dateiname">' + esc(m.file_basename) + '</div>' : '');
569            if (cfg.theme === 'dark') {
570                return '<div class="' + T.thumb + '" draggable="true" data-id="' + m.id + '" data-collection="media">'
571                    + '<div class="ee-thumb">' + visual + '<div class="' + T.thumbActs + '">' + mediaActions(m) + '</div></div>'
572                    + '<div class="' + T.thumbBody + '">' + body + '</div></div>';
573            }
574            return '<div class="' + T.thumb + '" draggable="true" data-id="' + m.id + '" data-collection="media">'
575                + visual
576                + '<div class="' + T.thumbBody + '">' + body + '</div>'
577                + '<div class="' + T.thumbActs + '">' + mediaActions(m) + '</div></div>';
578        }
579
580        /** Platzhalter-Kachel eines laufenden oder gescheiterten Uploads. */
581        function uploadCard(u) {
582            var inner = u.error
583                ? '<div class="ee-press-upload-error" role="alert">
583' + esc(u.error) + '</div>'
584                    + '<button type="button" class="' + (cfg.theme === 'dark' ? 'del' : T.btnDanger) + '" data-action="upload-dismiss" data-upload="' + esc(u.key) + '">Entfernen</button>'
585                : '<div class="ee-press-upload-bar"><div class="ee-press-upload-fill" style="width:' + (u.pct || 0) + '%"></div></div>'
586                    + '<div class="' + T.thumbMeta + '" data-upload-text>' + esc(uploadText(u)) + '</div>';
587            return '<div class="' + T.thumb + ' ee-press-upload-card' + (u.error ? ' is-error' : '') + '" data-upload-key="' + esc(u.key) + '">'
588                + '<div class="' + T.thumbBody + '"><div class="' + T.thumbTitle + '">' + esc(u.title || u.name) + '</div>'
589                + '<div class="' + T.thumbMeta + '">' + esc(u.name) + '</div>' + inner + '</div></div>';
590        }
591
592        function uploadText(u) {
593            if (u.pct == null || u.pct <= 0) { return u.started ? '0 % hochgeladen' : 'wartet …'; }
594            return u.pct < 100 ? u.pct + ' % hochgeladen' : 'Verarbeite (Stempel, Vorschau) …';
595        }
596
597        function renderMediaSummary() {
598            var items = state.items.media;
599            setCount('media', items.length);
600            refreshPublicCount();
601            var summary = $('[data-media-summary]');
602            if (summary) {
603                var print = items.filter(function (m) { return m.kind === 'image' && m.dpi && m.dpi >= 300; }).length;
604                summary.textContent = items.length ? (items.length + (items.length === 1 ? ' Datei' : ' Dateien') + (print ? ' · ' + print + ' in Druckauflösung' : '')) : '';
605            }
606        }
607
608        function renderMedia() {
609            var grid = $('[data-list="media"]');
610            var items = state.items.media;
611            renderMediaSummary();
612            refreshCreditLine();
613            state.renderedDefaultCredit = defaultCredit();
614            if (!grid) { return; }
615            if (!items.length && !state.uploads.length) {
616                grid.innerHTML = '<div class="' + T.empty + '">Noch keine Pressebilder. Druckbilder (JPG, 300 dpi) in das Feld oben ziehen – der Bildnachweis wird beim Upload in die Datei gestempelt, neue Bilder starten geschützt.</div>';
617                return;
618            }
619            grid.innerHTML = items.map(mediaCard).join('') + state.uploads.map(uploadCard).join('');
620        }
621
622        /** Ein Element per HTML ersetzen (Kachel neu zeichnen, ohne das Raster). */
623        function swapNode(node, html) {
624            if (!node || !node.parentNode) { return null; }
625            var tmp = ownerDoc().createElement('div');
626            tmp.innerHTML = html;
627            var fresh = tmp.firstElementChild;
628            if (!fresh) { node.parentNode.removeChild(node); return null; }
629            node.parentNode.replaceChild(fresh, node);
630            return fresh;
631        }
632
633        function mediaCardNode(id) {
634            var grid = $('[data-list="media"]');
635            return grid ? grid.querySelector('[data-collection="media"][data-id="' + Number(id) + '"]') : null;
636        }
637
638        /**
639         * Nur EINE Kachel neu zeichnen; der Fokus bleibt auf demselben
640         * Inline-Feld (Tab von Titel zu Nachweis geht nicht verloren).
641         */
642        function rerenderMediaCard(id) {
643            var item = mediaItem(id);
644            var node = mediaCardNode(id);
645            if (!item || !node) { renderMedia(); return; }
646            var active = ownerDoc().activeElement;
647            var focusName = active && node.contains(active) && active.getAttribute ? active.getAttribute('data-inline') : null;
648            var fresh = swapNode(node, mediaCard(item));
649            if (fresh && focusName) {
650                var again = fresh.querySelector('[data-inline="' + focusName + '"]');
651                if (again && again.focus) { again.focus(); }
652            }
653            renderMediaSummary();
654        }
655
656        function mediaItem(id) {
657            id = Number(id);
658            return state.items.media.filter(function (x) { return Number(x.id) === id; })[0] || null;
659        }
660
661        function renderQuotes() {
662            var list = $('[data-list="quotes"]');
663            var items = state.items.quotes;
664            setCount('quotes', items.length);
665            refreshPublicCount();
666            if (!list) { return; }
667            if (!items.length) {
668                list.innerHTML = '<div class="' + T.empty + '">Noch keine O-Töne. Zitate, die Redaktionen direkt übernehmen dürfen.</div>';
669                return;
670            }
671            list.innerHTML = items.map(function (q) {
672                var who = [q.name, q.role, q.organization].filter(
672Boolean).join(', ');
673                return '<div class="' + T.item + '" draggable="true" data-id="' + q.id + '" data-collection="quotes">'
674                    + '<span class="' + T.grip + '" title="Ziehen zum Sortieren" aria-hidden="true">' + ico('grip', 14) + '</span>'
675                    + '<div class="' + T.body + '">'
676                    + '<div class="' + T.quote + '">„' + esc(q.quote_text) + '“</div>'
677                    + '<div class="' + T.meta + '">' + esc(who || '– ohne Zuordnung –') + ' ' + badge(q) + '</div>'
678                    + '</div>'
679                    + '<div class="' + T.actions + '">'
680                    + '<button type="button" class="' + T.btn + '" data-action="edit" data-collection="quotes" data-id="' + q.id + '">Bearbeiten</button>'
681                    + '<button type="button" class="' + T.btnDanger + '" data-action="delete" data-collection="quotes" data-id="' + q.id + '">Löschen</button>'
682                    + '</div></div>';
683            }).join('');
684        }
685
686        function renderEcho() {
687            var list = $('[data-list="echo"]');
688            var items = state.items.echo;
689            setCount('echo', items.length);
690            if (!list) { return; }
691            if (!items.length) {
692                list.innerHTML = '<div class="' + T.empty + '">Noch kein Presse-Echo eingetragen.</div>';
693                return;
694            }
695            list.innerHTML = items.map(function (e) {
696                var meta = [];
697                if (e.published_on) { meta.push(dateDe(e.published_on)); }
698                if (e.url) { meta.push(e.url.replace(/^https?:\/\//, '').split('/')[0]); }
699                if (e.excerpt) { meta.push(String(e.excerpt).slice(0, 120)); }
700                return '<div class="' + T.item + '" draggable="true" data-id="' + e.id + '" data-collection="echo">'
701                    + '<span class="' + T.grip + '" title="Ziehen zum Sortieren" aria-hidden="true">' + ico('grip', 14) + '</span>'
702                    + (e.logo_url ? '<img class="' + T.logo + '" src="' + esc(e.logo_url) + '" alt="">' : '')
703                    + '<div class="' + T.body + '">'
704                    + '<div class="' + T.title + '">' + esc(e.outlet) + (e.title ? ' <span class="' + T.muted + '">' + esc(e.title) + '</span>' : '') + '</div>'
705                    + '<div class="' + T.meta + '">' + esc(meta.join(' · ')) + '</div>'
706                    + '</div>'
707                    + '<div class="' + T.actions + '">'
708                    + (e.url ? '<a class="' + T.btnView + '" href="' + esc(e.url) + '" target="_blank" rel="noopener">Öffnen</a>' : '')
709                    + '<button type="button" class="' + T.btn + '" data-action="edit" data-collection="echo" data-id="' + e.id + '">Bearbeiten</button>'
710                    + '<button type="button" class="' + T.btnDanger + '" data-action="delete" data-collection="echo" data-id="' + e.id + '">Löschen</button>'
711                    + '</div></div>';
712            }).join('');
713        }
714
715        /**
716         * Press links (portal only). R3/S9 (K): the project pane has no
717         * "Presse-Links" sub-tab any more — access is granted on the B2B press
718         * partner; existing slug grants stay valid server-side and show up in
719         * the global list.
720         */
721        function renderLinks() {
722            var list = $('[data-list="links"]');
723            var items = state.links;
724            var active = items.filter(function (l) { return l.status === 'active' && l.kind === 'access'; }).length;
725            setCount('links', active);
726            if (!list) { return; }
727            if (!items.length) {
728                list.innerHTML = '<div class="' + T.empty + '">Noch keine Presse-Zugänge erteilt.</div>';
729                return;
730            }
731            var statusBadge = function (l) {
732                if (l.status === 'active') { return '<span class="' + T.statusGreen + '">Aktiv</span>'; }
733                if (l.status === 'revoked') { return '<span class="' + T.statusMuted + '">Gesperrt</span>'; }
734                return '<span class="' + T.statusOpen + '">Abgelaufen</span>';
735            };
736            var projectName = function (slug) {
737                var p = state.projects.filter(function (x) { return x.slug === slug; })[0];
738                return p ? p.name : slug;
739            };
740            // R3/S15: partner column (portal only) — org name links to the B2B
741            // popup ({b2bUrl}?org={id}), contact below; unbound rows (legacy
742            // portal links, zip:* self-service) read "ohne Partner".
743            var showPartner = !isProject();
744            var partnerCell = function (l) {
745                if (!l.org || !l.org.id) { return '<td class="ee-press-partner" data-press-partner="none"><span class="' + T.muted + '">ohne Partner</span></td>'; }
746                var name = esc(l.org.name || ('Partner #' + l.org.id));
747                var base = String(cfg.b2bUrl || '');
748                var link = base
749                    ? '<a class="ee-press-link" href="' + esc(base + (base.indexOf('?') === -1 ? '?' : '&') + 'org=' + Number(l.org.id)) + '" target="_blank" rel="noopener" title="Partner im B2B-Bereich öffnen">' + name + '</a>'
750                    : name;
751                var c = l.contact || null;
752                var contact = c ? esc([c.name, c.email].filter(Boolean).join(' · ')) : '';
753                return '<td class="ee-press-partner" data-press-partner="' + Number(l.org.id) + '">' + link + (contact ? '<br><span class="' + T.muted + '">' + contact + '</span>' : '') + '</td>';
754            };
755            list.innerHTML = '<table class="' + T.table + '"><thead><tr>'
756                + '<th>Bezeichnung</th>' + (showPartner ? '<th>Partner</th>' : '') + '<th>Freigaben</th><th>Status</th><th>Gültig bis</th><th>Zuletzt genutzt</th><th>Abrufe</th><th></th>'
757                + '</tr></thead><tbody>'
758                + items.map(function (l) {
759                    var grants = (l.grants || []).map(function (g) {
760                        if (g === 'global') { return '<span class="' + T.badgeType + '">global</span>'; }
761                        if (g.indexOf('zip:') === 0) { return '<span class="' + T.badgeType + '" title="ZIP-Download-Link (Selbstbedienung)">ZIP ' + esc(g.replace('zip:', '')) + '</span>'; }
762                        return '<span class="' + T.badgeType + '">' + esc(projectName(g)) + '</span>';
763                    }).join(' ');
764                    return '<tr>'
765                        + '<td class="ee-b2b-doc-name">' + esc(l.label || '–') + (l.kind === 'zip' ? ' <span class="' + T.muted + '">(ZIP-Anfrage)</span>' : '') + '</td>'
766                        + (showPartner ? partnerCell(l) : '')
767                        + '<td><span class="ee-press-grants-cell">' + grants + '</span></td>'
768                        + '<td>' + statusBadge(l) + '</td>'
769                        + '<td>' + (l.unlimited ? 'unbefristet' : esc(utcToLocalDe(l.expires_at))) + '</td>'
770                        + '<td>' + esc(l.last_used_at ? utcToLocalDe(l.last_used_at) : '—') + '</td>'
771                        + '<td>' + esc(l.use_count) + '</td>'
772                        + '<td>' + (l.status === 'active' ? '<button type="button" class="' + T.btnDanger + '" data-action="revoke" data-id="' + l.id + '">Sperren</button>' : '') + '</td>'
773                        + '</tr>';
774                }).join('')
775                + '</tbody></table>';
776        }
777
778        // Project pane (R3/S10): read-only status line only — the pack is rebuilt
779        // by the server (schedule_check after every write + cron + lazy on download).
780        // The portal keeps its manual rebuild button.
781        function renderZip() {
782            var el = $('[data-press-zip-state]');
783            if (!el) { return; }
784            var label = isProject() ? 'Pressepaket' : 'Komplett-ZIP';
785            var z = state.zipInfo;
786            var n = isProject() ? publicCount({ documents: state.items.documents, media: state.items.media }) : 0;
787            if (!z) {
788                el.textContent = label + ': ' + (isProject() ? n + ' Dateien, ' : '') + 'noch nicht erstellt';
789                return;
790            }
791            var txt = label + ': ' + (isProject() ? n + ' Dateien · ' : '') + (z.state === 'fresh' && !z.stale ? 'aktuell' : (z.state === 'building' ? 'wird gebaut' : 'veraltet'));
792            if (z.filesize) { txt += ' · ' + bytes(z.filesize); }
793            if (z.built_at) { txt += ' · Stand ' + utcToLocalDe(z.built_at); }
794            el.textContent = txt;
795        }
796
797        // --------------------------------------------------------------
798        // Laden
799        // --------------------------------------------------------------
800
801        function loadCollection(key) {
802            return api('GET', cfg.basePath + '/' + key).then(function (data) {
803                state.items[key] = (data && data.items) || [];
804                if (key === 'media' && data && data.default_credit) { state.mediaDefaultCredit = String(data.default_credit); }
805                ({ documents: renderDocuments, media: renderMedia, quotes: renderQuotes, echo: renderEcho })[key]();
806                if (key === 'documents' || key === 'media') { renderZip(); }
807            }).catch(function (err) {
808                var el = $('[data-list="' + key + '"]');
809                if (el) { el.innerHTML = '<div class="' + T.empty + '">' + esc(errorMessage(err, 'Laden fehlgeschlagen')) + '</div>'; }
810            });
811        }
812
813        function loadSettings() {
814            var path = '/settings' + (isProject() ? '?project_id=' + cfg.projectId : '');
815            return api('GET', path).then(function (data) {
816                state.settings = (data && data.settings) || {};
817                state.defaults = (data && data.defaults) || {};
818                state.brand = (data && data.brand) || null;
819                state.zipInfo = data ? (isProject() ? (data.project && data.project.zip) : data.zip) : null;
820                if (data && data.limits && data.limits.effective_bytes) {
821                    root.setAttribute('data-limit-bytes', String(data.limits.effective_bytes));
822                }
823                fillSettingsForm();
824                renderBrandLogos();
825                renderZip();
826                refreshCreditLine();
827                refreshContactLine();
828                // Vorbelegung der Inline-Nachweise nachziehen, aber kein
829                // Neuzeichnen mitten in einer Eingabe.
830                if (state.items.media.length && defaultCredit() !== state.renderedDefaultCredit && !inlineBusy()) { renderMedia(); }
831            }).catch(function (err) {
832                notice('Einstellungen konnten nicht geladen werden: ' + errorMessage(err), true);
833            });
834        }
835
836        /** Project only: head data (press_enabled, credit, slug, URL). Legacy contact fields are ignored (no inputs). */
837        function loadHead() {
838            if (!isProject() || !cfg.headUrl) { return Promise.resolve(null); }
839            return api('GET', cfg.headUrl).then(function (data) {
840                applyHead(data || {});
841                return data;
842            }).catch(function (err) {
843                notice('Projekt-Kopfdaten konnten nicht geladen werden: ' + errorMessage(err), true);
844                return null;
845            });
846        }
847
848        function applyHead(data) {
849            state.head = data;
850            if (data.slug) { cfg.slug = data.slug; root.setAttribute('data-press-slug', data.slug); }
851            $$('[data-press-head]').forEach(function (el) {
852                var k = el.getAttribute('data-press-head');
853                if (k === 'press_enabled') { el.checked = !!data.press_enabled; return; }
854                if (typeof el.value !== 'undefined' && Object.prototype.hasOwnProperty.call(data, k)) { el.value = data[k] == null ? '' : data[k]; }
855                applyDefaultValue(el);
856            });
857            updateLinkRow();
858            refreshCreditLine();
859            if (state.items.media.length) { renderMedia(); }
860            if (state.links.length) { renderLinks(); }
861        }
862
863        function updateLinkRow() {
864            var row = $('#pressLinkRow') || $('[data-press-link-row]');
865            if (!row) { return; }
866            var h = state.head || {};
867            var pressUrl = (h.public_url && h.public_url.press) || root.getAttribute('data-press-public-url') || '';
868            var a = $('#pressLinkPress', row) || $('a', row);
869            if (a) { a.href = pressUrl || '#'; a.textContent = pressUrl; }
870            var enabled = state.head ? !!h.press_enabled && !!h.enabled : !!(($('[data-press-head="press_enabled"]') || {}).checked);
871            row.classList.toggle('is-unpublished', !enabled || !pressUrl);
872        }
873
874        function loadLinks() {
875            // R3/S9 (K): no links UI in the project pane -> no request.
876            if (isProject()) { return Promise.resolve(); }
877            return api('GET', '/links').then(function (data) {
878                state.links = (data && data.links) || [];
879                renderLinks();
880            }).catch(function (err) {
881                var el = $('[data-list="links"]');
882                if (el) { el.innerHTML = '<div class="' + T.empty + '">' + esc(errorMessage(err, 'Laden fehlgeschlagen')) + '</div>'; }
883            });
884        }
885
886        function loadProjects() {
887            if (isProject()) { return Promise.resolve(); }
888            return api('GET', '/projects').then(function (data) {
889                state.projects = (data && data.projects) || [];
890                var box = $('[data-grant-projects]');
891                if (box) {
892                    if (!state.projects.length) {
893                        box.innerHTML = '<span class="' + T.muted + '">Keine Projektseiten vorhanden.</span>';
894                    } else {
895                        box.innerHTML = state.projects.map(function (p) {
896                            return '<label><input type="checkbox" name="grant_project" value="' + esc(p.slug) + '"> ' + esc(p.name || p.slug) + (p.press_enabled ? '' : ' <span class="' + T.muted + '">(Presse aus)</span>') + '</label>';
897                        }).join('');
898                    }
899                }
900                renderLinks();
901            }).catch(function () { /* Grants ohne Projekte weiter moeglich */ });
902        }
903
904        // --------------------------------------------------------------
905        // Einstellungen (global: Formular) / Kopfdaten (project: Auto-Save)
906        // --------------------------------------------------------------
907
908        function settingsForm() { return $('[data-press-settings]'); }
909
910        function fillSettingsForm() {
911            var form = settingsForm();
912            if (!form || !state.settings) { return; }
913            Object.keys(state.settings).forEach(function (k) {
914                var el = form.elements[k];
915                if (el && typeof el.value !== 'undefined') { el.value = state.settings[k] || ''; }
916            });
917            // Nutzungsregeln: leer -> serverseitiger Default als Vorbelegung (defaults.terms_text).
918            // Wird beim Speichern als echter Wert mitgeschickt (submitSettings liest das Feld).
919            var terms = form.elements.terms_text;
920            if (terms && !(state.settings.terms_text || '').trim() && state.defaults && state.defaults.terms_text) {
921                terms.value = state.defaults.terms_text;
922            }
923            updateCreditPreview();
924        }
925
926        function updateCreditPreview() {
927            var form = settingsForm();
928            var prev = $('[data-credit-preview]');
929            if (!prev || !form || !form.elements.default_credit) { return; }
930            var credit = (form.elements.default_credit.value || '').trim() || 'DoppelErnte GmbH';
931            var link = (form.elements.credit_link_text.value || '').trim() || 'doppelernte.de';
932            prev.textContent = 'Foto: ' + credit + ' (' + link + ')';
933        }
934
935        function submitSettings(form) {
936            var body = {};
937            ['contact_name', 'contact_role', 'contact_email', 'contact_phone', 'default_credit', 'credit_link_text', 'terms_text', 'company_profile_text'].forEach(function (k) {
938                if (form.elements[k]) { body[k] = form.elements[k].value; }
939            });
940            var st = $('[data-settings-state]');
941            if (st) { st.textContent = 'Speichere …'; }
942            api('PUT', '/settings', body).then(function (data) {
943                state.settings = (data && data.settings) || state.settings;
944                if (st) {
945                    st.textContent = 'Gespeichert';
946                    setTimeout(function () { st.textContent = ''; }, 2500);
947                }
948                refreshCreditLine();
949                renderMedia();
950            }).catch(function (err) {
951                if (st) { st.textContent = ''; }
952                notice('Speichern fehlgeschlagen: ' + errorMessage(err), true);
953            });
954        }
955
956        // --- Logo DoppelErnte (R2/S9): 3 variants x PNG/SVG on the global settings ---
957
958        /** Slot key "variant/format" -> { variant, format } or null. */
959        function brandSlotOf(key) {
960            var m = /^(e|black|white)\/(png|svg)$/.exec(String(key || ''));
961            return m ? { variant: m[1], format: m[2] } : null;
962        }
963
964        function brandLogoUrl(variant, format) {
965            var logos = state.brand && state.brand.logos;
966            return (logos && logos[variant] && logos[variant][format]) || null;
967        }
968
969        /** Fills the fixed 3x2 grid from state.brand.logos (box = image or format label, x only when set). */
970        function renderBrandLogos() {
971            var box = $('[data-brand-logos]');
972            if (!box) { return; }
973            $$('[data-brand-slot]', box).forEach(function (slotEl) {
974                var slot = brandSlotOf(slotEl.getAttribute('data-brand-slot'));
975                if (!slot) { return; }
976                var url = brandLogoUrl(slot.variant, slot.format);
977                var pick = $('[data-brand-pick]', slotEl);
978                var remove = $('[data-brand-remove]', slotEl);
979                slotEl.classList.toggle('has-logo', !!url);
980                if (pick) {
981                    pick.innerHTML = (url ? '<img src="' + esc(url) + '" alt="">' : '')
982                        + '<span class="ee-press-brand-format">' + esc(slot.format.toUpperCase()) + '</span>';
983                }
984                if (remove) { remove.hidden = !url; }
985            });
986        }
987
988        function pickBrandLogo(key) {
989            var fi = $('[data-brand-file]');
990            var slot = brandSlotOf(key);
991            if (!fi || !slot) { return; }
992            fi.setAttribute('data-brand-target', key);
993            fi.accept = slot.format === 'svg' ? '.svg,image/svg+xml' : '.png,image/png';
994            fi.value = '';
995            fi.click();
996        }
997
998        function uploadBrandLogo(key, file) {
999            var slot = brandSlotOf(key);
1000            if (!slot || !file) { return Promise.resolve(); }
1001            var slotEl = $('[data-brand-slot="' + key + '"]');
1002            if (file.size > cfg.logoMaxBytes) {
1003                notice('Logo zu gross (' + bytes(file.size) + '). Maximal ' + bytes(cfg.logoMaxBytes) + '.', true);
1004                return Promise.resolve();
1005            }
1006            var fd = new FormData();
1007            fd.append('file', file);
1008            if (slotEl) { slotEl.classList.add('is-busy'); }
1009            return upload('/settings/logo?variant=' + slot.variant + '&format=' + slot.format, fd).then(function (data) {
1010                state.brand = (data && data.brand) || state.brand;
1011                renderBrandLogos();
1012                notice('Logo gespeichert.');
1013            }).catch(function (err) {
1014                notice('Logo-Upload fehlgeschlagen: ' + errorMessage(err), true);
1015            }).then(function () {
1016                if (slotEl) { slotEl.classList.remove('is-busy'); }
1017            });
1018        }
1019
1020        function removeBrandLogo(key) {
1021            var slot = brandSlotOf(key);
1022            if (!slot) { return Promise.resolve(); }
1023            if (!window.confirm('Dieses Logo entfernen?')) { return Promise.resolve(); }
1024            return api('DELETE', '/settings/logo?variant=' + slot.variant + '&format=' + slot.format).then(function (data) {
1025                state.brand = (data && data.brand) || state.brand;
1026                renderBrandLogos();
1027            }).catch(function (err) {
1028                notice('Entfernen fehlgeschlagen: ' + errorMessage(err), true);
1029            });
1030        }
1031
1032        function setSaveHint(stateName, text) {
1033            var hint = $('#pressSaveHint') || $('[data-press-save-hint]');
1034            var txt = $('#pressSaveHintText') || $('[data-press-save-hint-text]');
1035            if (!hint || !txt) { return; }
1036            hint.classList.remove('is-saving', 'is-error');
1037            if (stateName === 'saving') { hint.classList.add('is-saving'); }
1038            if (stateName === 'error') { hint.classList.add('is-error'); }
1039            txt.textContent = text;
1040        }
1041
1042        /**
1043         * PUT head data (project scope only). R2/S2: the project-level embargo
1044         * switch is gone from the UI; press_embargo_at is still accepted by
1045         * the server (local time in, UTC stored), so the state sync stays.
1046         */
1047        function saveHead(body) {
1048            if (!isProject() || !cfg.headUrl) { return Promise.resolve(false); }
1049            setSaveHint('saving', 'Speichere …');
1050            return api('PUT', cfg.headUrl, body).then(function (data) {
1051                state.head = Object.assign({}, state.head || {}, body, data && data.public_url ? { public_url: data.public_url } : {});
1052                if (data && data.slug) { cfg.slug = data.slug; }
1053                if (Object.prototype.hasOwnProperty.call(body, 'press_embargo_at')) {
1054                    state.head.press_embargo_at_local = body.press_embargo_at || null;
1055                }
1056                setSaveHint('ok', 'Zuletzt gespeichert ' + new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }));
1057                updateLinkRow();
1058                if (Object.prototype.hasOwnProperty.call(body, 'press_default_credit')) { refreshCreditLine(); renderMedia(); }
1059                return true;
1060            }).catch(function (err) {
1061                setSaveHint('error', errorMessage(err, 'Speichern fehlgeschlagen'));
1062                notice('Speichern fehlgeschlagen: ' + errorMessage(err), true);
1063                return false;
1064            });
1065        }
1066
1067        var headTimers = {};
1068
1069        // ---- R3/S8 (H): default-backed head fields (data-default) ----------
1070
1071        function hasDefault(el) { return !!(el && el.hasAttribute && el.hasAttribute('data-default')); }
1072
1073        /** True when the field is empty or equals its global default (-> save NULL). */
1074        function isDefaultValue(el) {
1075            var v = (el.value || '').trim();
1076            return v === '' || v === (el.getAttribute('data-default') || '').trim();
1077        }
1078
1079        /** Empty field -> show the default as the value; chip follows. */
1080        function applyDefaultValue(el) {
1081            if (!hasDefault(el)) { return; }
1082            if (!(el.value || '').trim()) { el.value = el.getAttribute('data-default') || ''; }
1083            syncDefaultChip(el);
1084        }
1085
1086        /** "Standard" chip (wrapper .ee-default-wrap.is-default) while value == default. */
1087        function syncDefaultChip(el) {
1088            if (!hasDefault(el)) { return; }
1089            var wrap = el.closest('.ee-default-wrap');
1090            if (wrap) { wrap.classList.toggle('is-default', isDefaultValue(el)); }
1091        }
1092
1093        function onHeadChange(el) {
1094            var k = el.getAttribute('data-press-head');
1095            if (k === 'press_enabled') {
1096                saveHead({ press_enabled: !!el.checked }).then(function (ok) { if (!ok) { el.checked = !el.checked; } });
1097                return;
1098            }
1099            syncDefaultChip(el);
1100            clearTimeout(headTimers[k]);
1101            headTimers[k] = setTimeout(function () {
1102                var body = {};
1103                // R3/S8 (H): empty or equal to the default -> NULL (server keeps
1104                // NULL = "use the global default", so it never freezes).
1105                body[k] = hasDefault(el) && isDefaultValue(el) ? null : el.value;
1106                saveHead(body).then(function (ok) {
1107                    if (ok && body[k] === null && ownerDoc().activeElement !== el) { applyDefaultValue(el); }
1108                });
1109            }, 500);
1110        }
1111
1112        // --------------------------------------------------------------
1113        // Editoren (anlegen / bearbeiten)
1114        // --------------------------------------------------------------
1115
1116        function editorFor(collection) { return $('[data-editor="' + collection + '"]'); }
1117
1118        function openEditor(collection, item) {
1119            var form = editorFor(collection);
1120            if (!form) { return; }
1121            form.reset();
1122            $$('[data-editor-error]', form).forEach(function (e) { e.textContent = ''; });
1123            var idEl = form.elements.id;
1124            if (idEl) { idEl.value = item ? item.id : ''; }
1125            var title = $('[data-editor-title]', form);
1126            var fileCur = $('[data-file-current]', form);
1127            var fileReq = $('[data-file-required]', form);
1128            if (fileCur) { fileCur.textContent = ''; }
1129            if (fileReq) { fileReq.hidden = !!item; }
1130
1131            if (item) {
1132                Object.keys(item).forEach(function (k) {
1133                    var el = form.elements[k];
1134                    if (!el || k === 'id') { return; }
1135                    if (el.type === 'radio' || (el.length && el[0] && el[0].type === 'radio')) {
1136                        // Boolean groups (has_persons) map to '1'/'0'; string groups
1137                        // (visibility segment, R3/S3) keep their value.
1138                        var raw = item[k];
1139                        var v = (raw === true || raw === 1) ? '1' : ((raw === false || raw === 0 || raw == null) ? '0' : String(raw));
1140                        var radios = form.querySelectorAll('input[name="' + k + '"]');
1141                        Array.prototype.forEach.call(radios, function (r) { r.checked = r.value === v; });
1142                        return;
1143                    }
1144                    if (el.type === 'checkbox') { el.checked = !!item[k]; return; }
1145                    if (el.type === 'file') { return; }
1146                    if (k === 'embargo_until') { return; }
1147                    el.value = item[k] == null ? '' : item[k];
1148                });
1149                if (form.elements.embargo_until) { form.elements.embargo_until.value = item.embargo_until_local || ''; }
1150                if (fileCur && (item.url || item.logo_url)) {
1151                    fileCur.textContent = 'Aktuell: ' + (item.file_basename || (item.url || item.logo_url).split('/').pop()) + ' — neue Datei wählen zum Ersetzen.';
1152                }
1153            }
1154            if (title) {
1155                var names = { documents: 'Dokument', media: 'Pressebild', quotes: 'O-Ton', echo: 'Beitrag' };
1156                title.textContent = (item ? 'Bearbeiten: ' : 'Neu: ') + (names[collection] || '');
1157            }
1158            form.hidden = false;
1159            state.editing[collection] = item ? item.id : 0;
1160            if (form.scrollIntoView) { form.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }
1161            var first = form.querySelector('input[type="text"], textarea');
1162            if (first && item) { first.focus(); }
1163        }
1164
1165        function closeEditor(collection) {
1166            var form = editorFor(collection);
1167            if (form) {
1168                form.reset();
1169                $$('[data-editor-error]', form).forEach(function (e) { e.textContent = ''; });
1170                var fileCur = $('[data-file-current]', form);
1171                if (fileCur) { fileCur.textContent = ''; }
1172                if (form.hasAttribute('data-editor-open')) {
1173                    // Immer sichtbarer Editor -> zurueck in den Neu-Modus.
1174                    if (form.elements.id) { form.elements.id.value = ''; }
1175                } else {
1176                    form.hidden = true;
1177                }
1178            }
1179            delete state.editing[collection];
1180        }
1181
1182        function clientFileCheck(file, maxBytes, kind) {
1183            if (!file) { return null; }
1184            var limit = Number(root.getAttribute('data-limit-bytes') || cfg.pressMaxBytes || 0);
1185            if (maxBytes) { limit = maxBytes; }
1186            if (limit && file.size > limit) {
1187                return 'Datei zu gross (' + bytes(file.size) + '). Maximal ' + bytes(limit) + (cfg.wpMaxBytes && cfg.wpMaxBytes < cfg.pressMaxBytes ? ' (Server-Grenze upload_max_filesize/post_max_size)' : '') + '.';
1188            }
1189            var name = (file.name || '').toLowerCase();
1190            if (kind === 'image' && /\.(tif|tiff|webp|heic|heif|gif|bmp)$/.test(name)) {
1191                return 'TIFF/WebP/HEIC werden nicht unterstützt – der Bildnachweis kann nur in JPG und PNG gestempelt werden. Bitte als JPG (300 dpi) oder PNG exportieren.';
1192            }
1193            return null;
1194        }
1195
1196        /**
1197         * Form fields -> body (radio/checkbox/disabled rules) incl. forced
1198         * visibility (documents, quotes, echo; media has no form any more,
1199         * AP7). consent_confirmed is never sent.
1200         */
1201        function collectFields(form) {
1202            var fields = {};
1203            Array.prototype.forEach.call(form.elements, function (el) {
1204                if (!el.name || el.name === 'id' || el.name === 'consent_confirmed' || el.type === 'file' || el.type === 'submit' || el.type === 'button') { return; }
1205                if (el.type === 'radio') { if (el.checked) { fields[el.name] = el.value; } return; }
1206                if (el.type === 'checkbox') { fields[el.name] = el.checked ? '1' : '0'; return; }
1207                fields[el.name] = el.value;
1208            });
1209            return enforceVisibility(fields);
1210        }
1211
1212        /**
1213         * Editor absenden. fileOverride: Datei aus Dropzone/Mehrfachauswahl
1214         * (sonst das Datei-Feld des Formulars). Liefert Promise<bool>.
1215         */
1216        function submitEditor(collection, form, fileOverride) {
1217            var errEl = $('[data-editor-error]', form);
1218            if (errEl) { errEl.textContent = ''; }
1219            var fail = function (msg) { if (errEl) { errEl.textContent = msg; } return Promise.resolve(false); };
1220            var id = form.elements.id ? Number(form.elements.id.value || 0) : 0;
1221            var fileInput = form.elements.file || form.elements.logo;
1222            var file = fileOverride || (fileInput && fileInput.files && fileInput.files[0] ? fileInput.files[0] : null);
1223            var needsFile = collection === 'documents' && !id;
1224
1225            if (needsFile && !file) {
1226                return fail('Bitte eine Datei auswählen.');
1227            }
1228            var kind = form.elements.kind ? form.elements.kind.value : null;
1229            var check = clientFileCheck(file, collection === 'echo' ? cfg.logoMaxBytes : 0, kind);
1230            if (check) {
1231                return fail(check);
1232            }
1233
1234            var fields = collectFields(form);
1235            var saveBtn = form.querySelector('[data-action="save"]');
1236            if (saveBtn) { saveBtn.disabled = true; }
1237            var progress = $('[data-progress]', form);
1238            var bar = progress ? progress.querySelector('.ee-press-progress-bar') : null;
1239            var ptxt = progress ? $('[data-progress-text]', progress) : null;
1240
1241            var request;
1242            var path = cfg.basePath + '/' + collection + (id ? '/' + id : '');
1243            if (file) {
1244                var fd = new FormData();
1245                Object.keys(fields).forEach(function (k) { fd.append(k, fields[k]); });
1246                fd.append(fileInput ? fileInput.name : 'file', file, file.name);
1247                if (progress) {
1248                    progress.hidden = false;
1249                    if (bar) { bar.style.width = '0%'; }
1250                    if (ptxt) { ptxt.textContent = '0 %'; }
1251                }
1252                request = upload(path, fd, function (ratio) {
1253                    if (!progress) { return; }
1254                    var pct = Math.round(ratio * 100);
1255                    if (bar) { bar.style.width = pct + '%'; }
1256                    if (ptxt) { ptxt.textContent = pct < 100 ? pct + ' % hochgeladen' + (file.name ? ' (' + file.name + ')' : '') : 'Verarbeite (Stempel, Vorschau) …'; }
1257                });
1258            } else {
1259                request = api(id ? 'PUT' : 'POST', path, fields);
1260            }
1261
1262            return request.then(function (data) {
1263                if (progress) { progress.hidden = true; }
1264                if (saveBtn) { saveBtn.disabled = false; }
1265                notice('Gespeichert.');
1266                return loadCollection(collection).then(function () { loadSettings(); return true; });
1267            }).catch(function (err) {
1268                if (progress) { progress.hidden = true; }
1269                if (saveBtn) { saveBtn.disabled = false; }
1270                if (errEl) { errEl.textContent = errorMessage(err, 'Speichern fehlgeschlagen'); }
1271                return false;
1272            });
1273        }
1274
1275        // --------------------------------------------------------------
1276        // Pressebilder: Sofort-Upload + Inline-Pflege (AP7)
1277        // --------------------------------------------------------------
1278
1279        function uploadLimit() {
1280            return Number(root.getAttribute('data-limit-bytes') || cfg.pressMaxBytes || 0);
1281        }
1282
1283        function uploadNode(key) {
1284            var grid = $('[data-list="media"]');
1285            return grid ? grid.querySelector('[data-upload-key="' + key + '"]') : null;
1286        }
1287
1288        /** Fortschritt in die Platzhalter-Kachel malen (ohne Neuzeichnen). */
1289        function paintUpload(u) {
1290            var node = uploadNode(u.key);
1291            if (!node) { return; }
1292            var fill = node.querySelector('.ee-press-upload-fill');
1293            var txt = node.querySelector('[data-upload-text]');
1294            if (fill) { fill.style.width = (u.pct || 0) + '%'; }
1295            if (txt) { txt.textContent = uploadText(u); }
1296        }
1297
1298        function dropUpload(u) {
1299            state.uploads = state.uploads.filter(function (x) { return x !== u; });
1300        }
1301
1302        /** Platzhalter einhaengen, ohne vorhandene Kacheln (und Eingaben) neu zu zeichnen. */
1303        function appendUploadCards(entries) {
1304            var grid = $('[data-list="media"]');
1305            if (!grid) { return; }
1306            if (!state.items.media.length && !grid.querySelector('[data-upload-key]')) {
1307                renderMedia();
1308                return;
1309            }
1310            var tmp = ownerDoc().createElement('div');
1311            tmp.innerHTML = entries.map(uploadCard).join('');
1312            while (tmp.firstChild) { grid.appendChild(tmp.firstChild); }
1313        }
1314
1315        function uploadOne(u) {
1316            var fd = new FormData();
1317            if (u.title) { fd.append('title', u.title); }
1318            fd.append('file', u.file, u.name);
1319            u.started = true;
1320            paintUpload(u);
1321            return upload(cfg.basePath + '/media', fd, function (ratio) {
1322                u.pct = Math.round(ratio * 100);
1323                paintUpload(u);
1324            }).then(function (data) {
1325                var item = data && data.item;
1326                dropUpload(u);
1327                if (item) {
1328                    state.items.media.push(item);
1329                    var node = uploadNode(u.key);
1330                    if (node) { swapNode(node, mediaCard(item)); } else { renderMedia(); }
1331                    renderMediaSummary();
1332                    if (item.kind === 'image' && item.credit_state !== 'ok') {
1333                        notice('Hochgeladen, aber der Nachweis konnte NICHT gestempelt werden (' + item.credit_state + '). Über „Stempeln" erneut versuchen.', true);
1334                    }
1335                } else {
1336                    var gone = uploadNode(u.key);
1337                    if (gone && gone.parentNode) { gone.parentNode.removeChild(gone); }
1338                }
1339                return true;
1340            }).catch(function (err) {
1341                u.error = errorMessage(err, 'Upload fehlgeschlagen');
1342                u.file = null;
1343                var node = uploadNode(u.key);
1344                if (node) { swapNode(node, uploadCard(u)); } else { renderMedia(); }
1345                return false;
1346            });
1347        }
1348
1349        /**
1350         * Dropzone/Auswahl: jede Datei sofort, NACHEINANDER (50 MB je Datei,
1351         * PHP-Limits) per POST …/media. Ohne kind/visibility/credit — der
1352         * Server leitet die Art ab, startet geschuetzt und nimmt die
1353         * Standard-Nachweiskette. Promise<bool> (alle ok).
1354         */
1355        function uploadMediaFiles(files) {
1356            var list = Array.prototype.slice.call(files || []);
1357            if (!list.length) { return Promise.resolve(false); }
1358            var limit = uploadLimit();
1359            var entries = list.map(function (f) {
1360                var u = { key: 'u' + (++uploadSeq), name: String(f.name || 'Datei'), title: titleFromFilename(f.name), pct: 0, file: f, error: mediaFileError(f, limit) };
1361                if (u.error) { u.file = null; }
1362                state.uploads.push(u);
1363                return u;
1364            });
1365            appendUploadCards(entries);
1366            var ok = true;
1367            var done = 0;
1368            var chain = Promise.resolve();
1369            entries.forEach(function (u) {
1370                chain = chain.then(function () {
1371                    if (u.error) { ok = false; return null; }
1372                    return uploadOne(u).then(function (r) { ok = ok && r; if (r) { done++; } });
1373                });
1374            });
1375            return chain.then(function () {
1376                if (done) {
1377                    notice(done === 1 ? '1 Datei hochgeladen (geschützt). Titel, Sichtbarkeit und Nachweis direkt an der Kachel.' : done + ' Dateien hochgeladen (geschützt). Titel, Sichtbarkeit und Nachweis direkt an der Kachel.');
1378                    loadSettings();
1379                }
1380                return ok;
1381            });
1382        }
1383
1384        /** True, waehrend ein Inline-Feld der Bilderliste den Fokus hat. */
1385        function inlineBusy() {
1386            var a = ownerDoc().activeElement;
1387            return !!(a && a.getAttribute && a.getAttribute('data-inline') && root.contains(a));
1388        }
1389
1390        /** PUT …/media/{id} mit einem Feld; ersetzt danach nur diese Kachel. */
1391        function saveMedia(id, body) {
1392            var node = mediaCardNode(id);
1393            if (node) { node.classList.add('is-saving'); }
1394            return api('PUT', cfg.basePath + '/media/' + id, body).then(function (data) {
1395                var item = data && data.item;
1396                if (item) {
1397                    state.items.media = state.items.media.map(function (x) { return Number(x.id) === Number(id) ? item : x; });
1398                }
1399                rerenderMediaCard(id);
1400                if (data && data.restamped && data.restamped !== 'ok') {
1401                    notice('Gespeichert, aber der Nachweis konnte nicht neu gestempelt werden (' + data.restamped + ').', true);
1402                } else {
1403                    notice(data && data.restamped === 'ok' ? 'Gespeichert, Bildnachweis neu gestempelt.' : 'Gespeichert.');
1404                }
1405                renderZip();
1406                return true;
1407            }).catch(function (err) {
1408                notice('Speichern fehlgeschlagen: ' + errorMessage(err), true);
1409                rerenderMediaCard(id);
1410                return false;
1411            });
1412        }
1413
1414        /** Titel: Klick -> Textfeld (Enter/Blur speichert, Esc bricht ab). */
1415        function startTitleEdit(btn) {
1416            var id = Number(btn.getAttribute('data-id'));
1417            var item = mediaItem(id);
1418            if (!item) { return; }
1419            var input = ownerDoc().createElement('input');
1420            input.type = 'text';
1421            input.className = 'ee-press-inline-input ee-press-inline-titleinput';
1422            input.setAttribute('data-inline', 'title');
1423            input.setAttribute('data-id', String(id));
1424            input.setAttribute('maxlength', '190');
1425            input.setAttribute('aria-label', 'Titel');
1426            input.value = item.title || '';
1427            btn.parentNode.replaceChild(input, btn);
1428            input.focus();
1429            if (input.select) { input.select(); }
1430        }
1431
1432        /**
1433         * Inline-Feld abschliessen (Enter, change, Blur — was zuerst kommt,
1434         * einmal). Unveraendert -> nur zurueck in die Anzeige.
1435         */
1436        function commitInline(el) {
1437            if (!el || el._eeDone) { return Promise.resolve(false); }
1438            var name = el.getAttribute('data-inline');
1439            var id = Number(el.getAttribute('data-id'));
1440            var item = mediaItem(id);
1441            if (!item) { return Promise.resolve(false); }
1442            if (name === 'has_persons') {
1443                return saveMedia(id, { has_persons: !!el.checked });
1444            }
1445            el._eeDone = true;
1446            if (name === 'title') {
1447                var t = String(el.value || '').trim();
1448                if (t === '' || t === String(item.title || '').trim()) { rerenderMediaCard(id); return Promise.resolve(false); }
1449                return saveMedia(id, { title: t });
1450            }
1451            if (name === 'credit') {
1452                var def = el.getAttribute('data-default') || defaultCredit();
1453                var next = creditValue(el.value, def);
1454                var cur = creditValue(item.credit, def);
1455                if (next === cur) {
1456                    el._eeDone = false;
1457                    if (!String(el.value || '').trim()) { el.value = def; }
1458                    syncDefaultChip(el);
1459                    return Promise.resolve(false);
1460                }
1461                return saveMedia(id, { credit: next });
1462            }
1463            return Promise.resolve(false);
1464        }
1465
1466        function cancelInline(el) {
1467            el._eeDone = true;
1468            var id = Number(el.getAttribute('data-id'));
1469            rerenderMediaCard(id);
1470        }
1471
1472        function toggleVisibility(id) {
1473            var item = mediaItem(id);
1474            if (!item || mediaLocked(item)) { return Promise.resolve(false); }
1475            return saveMedia(id, { visibility: item.visibility === 'public' ? 'protected' : 'public' });
1476        }
1477
1478        function liftEmbargo(id) {
1479            return saveMedia(id, { embargo_until: null });
1480        }
1481
1482        function deleteItem(collection, id) {
1483            var item = state.items[collection].filter(function (x) { return x.id === id; })[0];
1484            var label = item ? (item.title || item.outlet || item.quote_text || item.file_basename || ('#' + id)) : ('#' + id);
1485            if (!window.confirm('„' + String(label).slice(0, 80) + '" wirklich löschen?\n\nDatei und Eintrag werden endgültig entfernt; das ZIP wird danach neu gebaut.')) {
1486                return;
1487            }
1488            api('DELETE', cfg.basePath + '/' + collection + '/' + id).then(function () {
1489                notice('Gelöscht.');
1490                if (state.editing[collection] === id) { closeEditor(collection); }
1491                loadCollection(collection);
1492            }).catch(function (err) {
1493                notice('Löschen fehlgeschlagen: ' + errorMessage(err), true);
1494            });
1495        }
1496
1497        function restamp(id) {
1498            notice('Stempele …');
1499            api('POST', cfg.basePath + '/media/' + id + '/restamp', {}).then(function (data) {
1500                if (data && data.success) {
1501                    notice('Nachweis gestempelt (' + (data.credit_method || '') + ').');
1502                } else {
1503                    notice('Stempeln fehlgeschlagen: ' + ((data && data.credit_error) || (data && data.credit_state)), true);
1504                }
1505                loadCollection('media');
1506            }).catch(function (err) {
1507                notice('Stempeln fehlgeschlagen: ' + errorMessage(err), true);
1508            });
1509        }
1510
1511        // --------------------------------------------------------------
1512        // Presse-Zugänge (portal: read-only list + revoke; issuing lives in the
1513        // B2B partner tile — R3/S13–S15)
1514        // --------------------------------------------------------------
1515
1516        function revokeLink(id) {
1517            var l = state.links.filter(function (x) { return x.id === id; })[0];
1518            if (!window.confirm('Presse-Link „' + (l ? l.label : '#' + id) + '" sperren? Downloads werden sofort abgelehnt. Das lässt sich nicht rückgängig machen.')) {
1519                return;
1520            }
1521            api('POST', '/links/' + id + '/revoke', {}).then(function () {
1522                notice('Gesperrt.');
1523                loadLinks();
1524            }).catch(function (err) {
1525                notice('Sperren fehlgeschlagen: ' + errorMessage(err), true);
1526            });
1527        }
1528
1529        // --------------------------------------------------------------
1530        // ZIP
1531        // --------------------------------------------------------------
1532
1533        function rebuildZip(btn) {
1534            if (btn) { btn.disabled = true; }
1535            var el = $('[data-press-zip-state]');
1536            if (el) { el.textContent = (isProject() ? 'Pressepaket' : 'Komplett-ZIP') + ': wird gebaut …'; }
1537            var body = isProject() ? { scope: 'project', project_id: cfg.projectId } : { scope: 'global' };
1538            api('POST', '/zip/rebuild', body).then(function (data) {
1539                if (btn) { btn.disabled = false; }
1540                state.zipInfo = data.zip;
1541                renderZip();
1542                notice('ZIP gebaut: ' + data.fileCount + ' Dateien, ' + bytes(data.filesize) + '.');
1543            }).catch(function (err) {
1544                if (btn) { btn.disabled = false; }
1545                renderZip();
1546                notice('ZIP-Bau fehlgeschlagen: ' + errorMessage(err), true);
1547            });
1548        }
1549
1550        // --------------------------------------------------------------
1551        // Ereignisse (einmal pro Wurzel)
1552        // --------------------------------------------------------------
1553
1554        var drag = null;
1555
1556        function bind() {
1557            root.addEventListener('click', function (ev) {
1558                var sub = ev.target.closest('[data-sub]');
1559                if (sub && root.contains(sub)) { showTab(sub.getAttribute('data-sub')); return; }
1560
1561                var go = ev.target.closest('[data-goto]');
1562                if (go) { ev.preventDefault(); showTab(go.getAttribute('data-goto')); return; }
1563
1564                var copyT = ev.target.closest('[data-copy-text]');
1565                if (copyT) { copyText(copyT.getAttribute('data-copy-text'), copyT); return; }
1566                var copy = ev.target.closest('[data-copy]');
1567                if (copy) {
1568                    var target = $(copy.getAttribute('data-copy')) || document.querySelector(copy.getAttribute('data-copy'));
1569                    if (target) { copyText(target.textContent, copy); }
1570                    return;
1571                }
1572                var copyId = ev.target.closest('[data-copy-target]');
1573                if (copyId) {
1574                    var t = document.getElementById(copyId.getAttribute('data-copy-target'));
1575                    if (t) { copyText(t.textContent || t.href || '', copyId); }
1576                    return;
1577                }
1578                var openId = ev.target.closest('[data-open-target]');
1579                if (openId) {
1580                    var a = document.getElementById(openId.getAttribute('data-open-target'));
1581                    if (a && a.href && a.href !== '#') { window.open(a.href, '_blank', 'noopener'); }
1582                    return;
1583                }
1584                var zipBtn = ev.target.closest('[data-press-zip-rebuild]');
1585                if (zipBtn) { rebuildZip(zipBtn); return; }
1586
1587                var dz = ev.target.closest('[data-dropzone]');
1588                if (dz && !ev.target.closest('input')) {
1589                    var fi = dz.querySelector('input[type="file"]');
1590                    if (fi) { fi.click(); }
1591                    return;
1592                }
1593
1594                var brandPick = ev.target.closest('[data-brand-pick]');
1595                if (brandPick) { pickBrandLogo(brandPick.getAttribute('data-brand-pick')); return; }
1596                var brandRemove = ev.target.closest('[data-brand-remove]');
1597                if (brandRemove) { removeBrandLogo(brandRemove.getAttribute('data-brand-remove')); return; }
1598
1599                var titleBtn = ev.target.closest('[data-inline-edit="title"]');
1600                if (titleBtn) { startTitleEdit(titleBtn); return; }
1601
1602                var btn = ev.target.closest('[data-action]');
1603                if (!btn) { return; }
1604                var action = btn.getAttribute('data-action');
1605                var collection = btn.getAttribute('data-collection');
1606                var id = Number(btn.getAttribute('data-id') || 0);
1607
1608                if (action === 'new') {
1609                    openEditor(collection, null);
1610                } else if (action === 'edit') {
1611                    var item = state.items[collection].filter(function (x) { return x.id === id; })[0];
1612                    openEditor(collection, item || null);
1613                } else if (action === 'delete') {
1614                    deleteItem(collection, id);
1615                } else if (action === 'restamp') {
1616                    restamp(id);
1617                } else if (action === 'toggle-visibility') {
1618                    if (!btn.disabled) { toggleVisibility(id); }
1619                } else if (action === 'lift-embargo') {
1620                    liftEmbargo(id);
1621                } else if (action === 'upload-dismiss') {
1622                    var key = btn.getAttribute('data-upload');
1623                    state.uploads = state.uploads.filter(function (u) { return u.key !== key; });
1624                    var card = btn.closest('[data-upload-key]');
1625                    if (card && card.parentNode) { card.parentNode.removeChild(card); }
1626                    if (!state.items.media.length && !state.uploads.length) { renderMedia(); }
1627                } else if (action === 'revoke') {
1628                    revokeLink(id);
1629                } else if (action === 'cancel') {
1630                    var form = btn.closest('[data-editor]');
1631                    if (form) { closeEditor(form.getAttribute('data-editor')); }
1632                }
1633            });
1634
1635            root.addEventListener('submit', function (ev) {
1636                var form = ev.target.closest('[data-editor]');
1637                if (form) {
1638                    ev.preventDefault();
1639                    var collection = form.getAttribute('data-editor');
1640                    submitEditor(collection, form).then(function (ok) { if (ok) { closeEditor(collection); } });
1641                    return;
1642                }
1643                var sf = ev.target.closest('[data-press-settings]');
1644                if (sf) { ev.preventDefault(); submitSettings(sf); }
1645            });
1646
1647            root.addEventListener('input', function (ev) {
1648                if (ev.target.closest('[data-press-settings]')) { updateCreditPreview(); }
1649                if (ev.target.getAttribute && ev.target.getAttribute('data-inline') === 'credit') { syncDefaultChip(ev.target); }
1650            });
1651
1652            // Textfelder in einer ziehbaren Kachel: Safari/Firefox setzen dort
1653            // sonst keinen Cursor (Drag frisst die Maus). Waehrend der Eingabe
1654            // ist die Kachel nicht ziehbar; focusout gibt sie wieder frei.
1655            root.addEventListener('mousedown', function (ev) {
1656                var el = ev.target;
1657                if (!el || !el.getAttribute || el.type !== 'text' || !el.getAttribute('data-inline')) { return; }
1658                var card = el.closest('[draggable]');
1659                if (card) { card.setAttribute('draggable', 'false'); }
1660            });
1661            root.addEventListener('focusin', function (ev) {
1662                var el = ev.target;
1663                if (!el || !el.getAttribute || el.type !== 'text' || !el.getAttribute('data-inline')) { return; }
1664                var card = el.closest('[draggable]');
1665                if (card) { card.setAttribute('draggable', 'false'); }
1666            });
1667
1668            // Inline-Felder der Bilderliste: Enter speichert, Esc bricht ab.
1669            root.addEventListener('keydown', function (ev) {
1670                var el = ev.target;
1671                // Dropzone per Tastatur (role=button): Enter/Leertaste oeffnet die Auswahl.
1672                if (el && el.getAttribute && el.getAttribute('data-dropzone') && (ev.key === 'Enter' || ev.key === ' ')) {
1673                    ev.preventDefault();
1674                    var pick = el.querySelector('input[type="file"]');
1675                    if (pick) { pick.click(); }
1676                    return;
1677                }
1678                var name = el && el.getAttribute ? el.getAttribute('data-inline') : null;
1679                if (name !== 'title' && name !== 'credit') { return; }
1680                if (ev.key === 'Enter') { ev.preventDefault(); commitInline(el); }
1681                else if (ev.key === 'Escape' || ev.key === 'Esc') { ev.preventDefault(); cancelInline(el); }
1682            });
1683
1684            root.addEventListener('change', function (ev) {
1685                if (ev.target.hasAttribute && ev.target.hasAttribute('data-brand-file')) {
1686                    var target = ev.target.getAttribute('data-brand-target');
1687                    if (target && ev.target.files && ev.target.files.length) { uploadBrandLogo(target, ev.target.files[0]); }
1688                    return;
1689                }
1690                // Pressebilder: Auswahl in der Dropzone ->
1690 sofort hochladen.
1691                if (ev.target.type === 'file' && ev.target.closest('[data-dropzone="media"]')) {
1692                    if (ev.target.files && ev.target.files.length) {
1693                        var picked = Array.prototype.slice.call(ev.target.files);
1694                        ev.target.value = '';
1695                        uploadMediaFiles(picked);
1696                    }
1697                    return;
1698                }
1699                // Inline-Felder: change speichert (Safari gibt Checkboxen/Feldern
1700                // nicht immer einen focusout, B17).
1701                if (ev.target.getAttribute && ev.target.getAttribute('data-inline')) {
1702                    commitInline(ev.target);
1703                    return;
1704                }
1705                var head = ev.target.closest('[data-press-head]');
1706                if (head) { onHeadChange(head); }
1707            });
1708            // R3/S8 (H): a default-backed field left empty shows the default again.
1709            root.addEventListener('focusout', function (ev) {
1710                var head = ev.target && ev.target.closest ? ev.target.closest('[data-press-head][data-default]') : null;
1711                if (head) { applyDefaultValue(head); }
1712                var name = ev.target && ev.target.getAttribute ? ev.target.getAttribute('data-inline') : null;
1713                if (name === 'title' || name === 'credit') {
1714                    var card = ev.target.closest('[draggable]');
1715                    if (card) { card.setAttribute('draggable', 'true'); }
1716                    commitInline(ev.target);
1717                }
1718            });
1719
1720            // Dropzone: Drag & Drop
1721            root.addEventListener('dragenter', function (ev) {
1722                var dz = ev.target.closest('[data-dropzone]');
1723                if (dz) { ev.preventDefault(); dz.classList.add('drag-over'); }
1724            });
1725            root.addEventListener('dragover', function (ev) {
1726                var dz = ev.target.closest('[data-dropzone]');
1727                if (dz) { ev.preventDefault(); dz.classList.add('drag-over'); return; }
1728                if (!drag) { return; }
1729                var over = ev.target.closest('[draggable="true"][data-collection]');
1730                if (!over || over === drag || over.getAttribute('data-collection') !== drag.getAttribute('data-collection')) { return; }
1731                ev.preventDefault();
1732                over.classList.add('is-over');
1733            });
1734            root.addEventListener('dragleave', function (ev) {
1735                var dz = ev.target.closest('[data-dropzone]');
1736                if (dz && !dz.contains(ev.relatedTarget)) { dz.classList.remove('drag-over'); }
1737                var over = ev.target.closest('[draggable="true"]');
1738                if (over) { over.classList.remove('is-over'); }
1739            });
1740            root.addEventListener('drop', function (ev) {
1741                var dz = ev.target.closest('[data-dropzone]');
1742                if (dz) {
1743                    ev.preventDefault();
1744                    dz.classList.remove('drag-over');
1745                    var files = ev.dataTransfer && ev.dataTransfer.files;
1746                    if (dz.getAttribute('data-dropzone') === 'media' && files && files.length) { uploadMediaFiles(files); }
1747                    return;
1748                }
1749                if (!drag) { return; }
1750                var over = ev.target.closest('[draggable="true"][data-collection]');
1751                if (!over || over === drag) { return; }
1752                ev.preventDefault();
1753                over.classList.remove('is-over');
1754                var parent = over.parentNode;
1755                var children = Array.prototype.slice.call(parent.children);
1756                if (children.indexOf(drag) < children.indexOf(over)) {
1757                    parent.insertBefore(drag, over.nextSibling);
1758                } else {
1759                    parent.insertBefore(drag, over);
1760                }
1761                var collection = drag.getAttribute('data-collection');
1762                var ids = Array.prototype.map.call(parent.querySelectorAll('[data-id]'), function (n) { return Number(n.getAttribute('data-id')); });
1763                api('POST', cfg.basePath + '/' + collection + '/sort', { ids: ids }).then(function () {
1764                    loadCollection(collection);
1765                }).catch(function (err) {
1766                    notice('Sortieren fehlgeschlagen: ' + errorMessage(err), true);
1767                    loadCollection(collection);
1768                });
1769            });
1770
1771            // Listen-Sortierung
1772            root.addEventListener('dragstart', function (ev) {
1773                var el = ev.target.closest('[draggable="true"][data-collection]');
1774                if (!el) { return; }
1775                drag = el;
1776                el.classList.add('is-dragging');
1777                try { ev.dataTransfer.setData('text/plain', el.getAttribute('data-id')); } catch (e) { /* IE */ }
1778                ev.dataTransfer.effectAllowed = 'move';
1779            });
1780            root.addEventListener('dragend', function () {
1781                if (drag) { drag.classList.remove('is-dragging'); }
1782                $$('.is-over').forEach(function (n) { n.classList.remove('is-over'); });
1783                drag = null;
1784            });
1785        }
1786
1787        // --------------------------------------------------------------
1788        // Start / Reload
1789        // --------------------------------------------------------------
1790
1791        function reload() {
1792            state.items = { documents: [], media: [], quotes: [], echo: [] };
1793            state.links = [];
1794            state.editing = {};
1795            state.uploads = [];
1796            var remembered = null;
1797            try { remembered = window.localStorage.getItem(tabKey()); } catch (e) { /* privat */ }
1798            if (remembered && $('[data-pane="' + remembered + '"]')) { showTab(remembered); }
1799
1800            var first = isProject() ? loadHead() : Promise.resolve(null);
1801            return first.then(function () {
1802                return Promise.all([
1803                    loadSettings(),
1804                    loadCollection('documents'),
1805                    loadCollection('media'),
1806                    loadCollection('quotes'),
1807                    loadCollection('echo'),
1808                    loadLinks(),
1809                    loadProjects()
1810                ]);
1811            });
1812        }
1813
1814        bind();
1815
1816        return {
1817            root: root,
1818            cfg: cfg,
1819            state: state,
1820            configure: configure,
1821            reload: reload,
1822            showTab: showTab,
1823            api: api,
1824            // fuer tests/js
1825            submitEditor: submitEditor,
1826            uploadMediaFiles: uploadMediaFiles,
1827            saveMedia: saveMedia,
1828            collectFields: collectFields,
1829            saveHead: saveHead,
1830            creditLine: creditLine
1831        };
1832    }
1833
1834    // ------------------------------------------------------------------
1835    // Oeffentliche Schnittstelle
1836    // ------------------------------------------------------------------
1837
1838    var EEPressManager = {
1839        /**
1840         * Instanz an einer Wurzel anlegen (oder umkonfigurieren) und laden.
1841         * Listener werden nur einmal pro Wurzel gebunden.
1842         */
1843        init: function (root, opts) {
1844            if (!root) { return null; }
1845            var inst = root._eePressManager;
1846            if (inst) {
1847                inst.configure(opts);
1848            } else {
1849                inst = create(root, opts);
1850                root._eePressManager = inst;
1851            }
1852            inst.reload();
1853            return inst;
1854        },
1855
1856        /**
1857         * Projekt-Popup (AP-B2): Reiter "Presse" fuer ein Projekt vorbereiten.
1858         * Laedt lazy beim ersten Klick auf den Reiter; bei Projektwechsel
1859         * wird beim naechsten Klick neu geladen. Aufruf aus
1860         * ee-project-showcase.js (window.eeShowcase.init).
1861         */
1862        initProjectPane: function (projectId) {
1863            var root = document.getElementById('projectSettingsPanePresse');
1864            var cfg = window.eePressManager || {};
1865            projectId = Number(projectId || 0);
1866            if (!root || !cfg.apiBase || !projectId) { return null; }
1867
1868            var opts = {};
1869            Object.keys(cfg).forEach(function (k) { opts[k] = cfg[k]; });
1870            opts.scope     = 'project';
1871            opts.projectId = projectId;
1872            opts.basePath  = basePathFor('project', projectId);
1873            opts.theme     = 'dark';
1874            opts.slug      = root.getAttribute('data-press-slug') || '';
1875            if (cfg.showcaseBase) { opts.headUrl = String(cfg.showcaseBase).replace(/\/$/, '') + '/' + projectId; }
1876
1877            // Bei jedem Popup-Open neu laden (lazy beim ersten Klick auf den
1878            // Reiter) — auch fuer dasselbe Projekt, damit Aenderungen aus dem
1879            // Portal-Bereich sichtbar werden.
1880            root.setAttribute('data-press-pending', '1');
1881            root._eePressPendingOpts = opts;
1882
1883            var run = function () {
1884                if (root.getAttribute('data-press-pending') !== '1') { return; }
1885                root.setAttribute('data-press-pending', '0');
1886                EEPressManager.init(root, root._eePressPendingOpts);
1887            };
1888            // AP-B4: Trigger ist der Segment-Button "Presse" im Studio-Pane
1889            // (StudioPaneRenderer), nicht mehr ein eigener Tab-Button.
1890            var seg = document.querySelector('[data-studio-segment="presse"]');
1891            if (seg && seg.dataset.eePressBound !== '1') {
1892                seg.dataset.eePressBound = '1';
1893                seg.addEventListener('click', run);
1894            }
1895            // Segment ist bereits sichtbar (Re-Open mit gemerktem Segment) -> sofort laden.
1896            if (root.classList.contains('on')) { run(); }
1897            return root._eePressManager || null;
1898        },
1899
1900        util: {
1901            esc: esc,
1902            bytes: bytes,
1903            dateDe: dateDe,
1904            dateTimeDe: dateTimeDe,
1905            utcToLocalDe: utcToLocalDe,
1906            badgeText: badgeText,
1907            basePathFor: basePathFor,
1908            enforceVisibility: enforceVisibility,
1909            titleFromFilename: titleFromFilename,
1910            mediaFileError: mediaFileError,
1911            embargoActive: embargoActive,
1912            creditValue: creditValue,
1913            publicCount: publicCount,
1914            themes: THEMES
1915        }
1916    };
1917
1918    window.EEPressManager = EEPressManager;
1919
1920    // Portal-Seite (AP-B3): Selbststart an #eePressManager.
1921    var portalRoot = typeof document !== 'undefined' ? document.getElementById('eePressManager') : null;
1922    var portalCfg = window.eePressManager || {};
1923    if (portalRoot && portalCfg.apiBase) {
1924        var portalOpts = {};
1925        Object.keys(portalCfg).forEach(function (k) { portalOpts[k] = portalCfg[k]; });
1926        portalOpts.scope = 'global';
1927        portalOpts.basePath = '/global';
1928        portalOpts.theme = 'light';
1929        EEPressManager.init(portalRoot, portalOpts);
1930    }
1931})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.