PHASE 1 — Sofort-Cleanup ohne Risiko: - Neue Datei utilities.css mit ~25 Klassen für häufige Kombinationen: * text-xs-muted, text-xs-secondary, text-sm-muted, text-sm-secondary * flex-gap-2/3, flex-col-gap-2/3/4, flex-center-gap-1/2/3 * flex-between, flex-1-min, mb-1/3, mt-1/3 * icon-xs/sm/md/lg, label-block, caption - index.html bindet utilities.css ein - mb-3/mt-3 ergänzt (waren in design-system.css unvollständig) PHASE 2 — .by-tab Modifier für Vereinheitlichung: - .by-tabs.grid (mit --tab-cols Variable für Admin/Health/etc.) - .by-tabs.sticky (Desktop vertikale Tabs für Admin) - .by-tabs.wrap (Zuchthunde, flex-wrap statt scroll) - .by-tabs.separated (Sitting, mit eigenem Hintergrund + Border) PHASE 3 — Inline-Style → Klassen-Migration (Python-Script): - 948 Inline-Styles entfernt (5101 → 4153, -18%) - 962 Migrationen über 47 Page-Dateien - Top-Treffer: admin.js (180), health.js (67), dog-profile.js (67), litters.js (62), settings.js (61), zuchthunde.js (51) - Patterns: text-muted, text-secondary, text-danger, text-xs-muted, text-sm-muted, grid-2 (Duplikat-Bug behoben!), flex-col-gap-3, p-3/4, mb-2/3/4, hidden, w-full, flex-1, ... - Bewahrt bestehende class-Attribute (mergt korrekt) Alle 19 Tests grün. Kein visueller Diff erwartet (gleiche Property-Werte).
322 lines
15 KiB
JavaScript
322 lines
15 KiB
JavaScript
/* ============================================================
|
||
BAN YARO — Züchter-Profil-Editor
|
||
Selbstverwaltung des öffentlichen Züchter-Profils.
|
||
============================================================ */
|
||
|
||
window.Page_breeder_editor = (() => {
|
||
|
||
let _container = null;
|
||
let _data = null; // { profile, litters, storage_mb, storage_limit_mb }
|
||
|
||
async function init(container) {
|
||
_container = container;
|
||
_container.innerHTML = `<div style="max-width:680px;margin:0 auto;padding:var(--space-4)">${UI.skeleton(5)}</div>`;
|
||
await _load();
|
||
}
|
||
|
||
function refresh() { _load(); }
|
||
function onDogChange() {}
|
||
|
||
async function _load() {
|
||
try {
|
||
_data = await API.get('/breeder/my-editor');
|
||
_render();
|
||
} catch (e) {
|
||
_container.innerHTML = `<div style="padding:var(--space-6);color:var(--c-danger)">${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
function _render() {
|
||
const { profile: p, litters, storage_mb, storage_limit_mb } = _data;
|
||
_container.innerHTML = `
|
||
<div style="max-width:680px;margin:0 auto;padding:var(--space-4)">
|
||
<div style="margin-bottom:var(--space-5)">
|
||
<h1 style="font-size:var(--text-xl);font-weight:800;margin:0 0 var(--space-1)">Mein Züchter-Profil</h1>
|
||
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin:0">
|
||
Gestalte deine öffentliche Profilseite — Fotos, Videos und Infos zu deinen Würfen.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Logo & Grundinfos -->
|
||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
|
||
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
|
||
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-3)">Logo / Titelbild</div>
|
||
<div style="display:flex;align-items:center;gap:var(--space-4)">
|
||
<div id="be-logo-preview" style="width:80px;height:80px;border-radius:var(--radius-md);
|
||
background:var(--c-surface-2);overflow:hidden;flex-shrink:0;
|
||
display:flex;align-items:center;justify-content:center">
|
||
${p.logo_url
|
||
? `<img src="${_esc(p.logo_url)}" style="width:100%;height:100%;object-fit:cover">`
|
||
: `<svg class="ph-icon" style="width:32px;height:32px;opacity:.3"><use href="/icons/phosphor.svg#image"></use></svg>`}
|
||
</div>
|
||
<div>
|
||
<label class="btn btn-secondary btn-sm" style="cursor:pointer">
|
||
Logo hochladen
|
||
<input type="file" id="be-logo-input" accept="image/*" class="hidden">
|
||
</label>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">
|
||
Quadratisch · max. 5 MB · HEIC wird unterstützt
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Profil-Texte -->
|
||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
|
||
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
|
||
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-3)">Profil-Texte</div>
|
||
<form id="be-text-form" class="flex-col-gap-3">
|
||
<div class="grid-2">
|
||
<div class="form-group">
|
||
<label class="form-label">Zwingername *</label>
|
||
<input class="form-control" name="zwingername" type="text" required
|
||
value="${_esc(p.zwingername || '')}">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Rasse(n)</label>
|
||
<input class="form-control" name="rasse_text" type="text"
|
||
value="${_esc(p.rasse_text || '')}">
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Slogan <span style="font-weight:400;color:var(--c-text-muted)">(max. 80 Zeichen)</span></label>
|
||
<input class="form-control" name="tagline" type="text" maxlength="80"
|
||
placeholder="z. B. Liebevolle Aufzucht seit 2010 · VDH-anerkannt"
|
||
value="${_esc(p.tagline || '')}">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Über uns / Zwingerbeschreibung</label>
|
||
<textarea class="form-control" name="beschreibung" rows="4" maxlength="800"
|
||
placeholder="Wer seid ihr, was ist euch bei der Zucht wichtig?">${_esc(p.beschreibung || '')}</textarea>
|
||
</div>
|
||
<div class="grid-2">
|
||
<div class="form-group">
|
||
<label class="form-label">Stadt</label>
|
||
<input class="form-control" name="stadt" type="text" value="${_esc(p.stadt || '')}">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Verein</label>
|
||
<input class="form-control" name="verein" type="text" value="${_esc(p.verein || '')}">
|
||
</div>
|
||
</div>
|
||
<div class="grid-2">
|
||
<div class="form-group">
|
||
<label class="form-label">Website</label>
|
||
<input class="form-control" name="website" type="url"
|
||
placeholder="https://" value="${_esc(p.website || '')}">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Instagram</label>
|
||
<input class="form-control" name="instagram" type="text"
|
||
placeholder="@zwingername" value="${_esc(p.instagram || '')}">
|
||
</div>
|
||
</div>
|
||
<button type="submit" class="btn btn-secondary btn-sm" style="align-self:flex-start">
|
||
Profil speichern
|
||
</button>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Profil-Fotos & Videos -->
|
||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
|
||
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:var(--space-2)">
|
||
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--c-text-muted)">
|
||
Profil-Fotos & Videos
|
||
</div>
|
||
</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">
|
||
JPG, PNG, HEIC, MP4, MOV · max. 200 MB pro Datei
|
||
</div>
|
||
${_storageBar(storage_mb, storage_limit_mb)}
|
||
<div id="be-photos-grid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-2);margin:var(--space-3) 0">
|
||
${_renderPhotoGrid(p.photos || [])}
|
||
</div>
|
||
<label class="btn btn-secondary btn-sm" style="cursor:pointer;display:inline-flex;align-items:center;gap:6px">
|
||
<svg class="ph-icon" style="width:16px;height:16px"><use href="/icons/phosphor.svg#plus"></use></svg>
|
||
Foto / Video hinzufügen
|
||
<input type="file" id="be-profile-photo-input" accept="image/*,video/*" class="hidden">
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Würfe — Schnellupload -->
|
||
${litters.length ? `
|
||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
|
||
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
|
||
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-3)">
|
||
Aktuelle Würfe — Fotos & Videos
|
||
</div>
|
||
<div class="flex-col-gap-3">
|
||
${litters.map(l => _renderLitterCard(l)).join('')}
|
||
</div>
|
||
</div>` : ''}
|
||
|
||
</div>
|
||
`;
|
||
_bindEvents();
|
||
}
|
||
|
||
function _renderPhotoGrid(photos) {
|
||
return photos.map((ph, i) => {
|
||
const isVid = ph.media_type === 'video' || (ph.url || '').endsWith('.mp4');
|
||
return `
|
||
<div style="position:relative;aspect-ratio:1;border-radius:var(--radius-md);overflow:hidden;background:var(--c-surface-2)">
|
||
${isVid
|
||
? `<video src="${_esc(ph.url)}" style="width:100%;height:100%;object-fit:cover" muted playsinline loop
|
||
onmouseenter="this.play()" onmouseleave="this.pause()"></video>
|
||
<div style="position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.55);border-radius:4px;padding:1px 5px;font-size:10px;color:#fff">▶ Video</div>`
|
||
: `<img src="${_esc(ph.thumbnail_url || ph.url)}" style="width:100%;height:100%;object-fit:cover">`}
|
||
${ph.is_primary ? `<div style="position:absolute;top:4px;left:4px;background:rgba(196,132,58,.9);border-radius:3px;padding:1px 5px;font-size:9px;color:#fff;font-weight:700">LOGO</div>` : ''}
|
||
<button class="be-photo-del" data-id="${ph.id}"
|
||
style="position:absolute;top:4px;right:4px;background:rgba(0,0,0,.6);
|
||
border:none;border-radius:50%;width:24px;height:24px;cursor:pointer;
|
||
color:#fff;font-size:14px;display:flex;align-items:center;justify-content:center">×</button>
|
||
${!ph.is_primary ? `<button class="be-photo-primary" data-id="${ph.id}"
|
||
title="Als Logo setzen"
|
||
style="position:absolute;bottom:4px;right:4px;background:rgba(0,0,0,.55);
|
||
border:none;border-radius:3px;padding:1px 5px;font-size:9px;cursor:pointer;color:#fff">Logo</button>` : ''}
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function _renderLitterCard(l) {
|
||
const label = l.geburtsdatum
|
||
? `Wurf vom ${new Date(l.geburtsdatum).toLocaleDateString('de-DE')}`
|
||
: `Wurf #${l.id}`;
|
||
const info = [
|
||
l.welpen_gesamt ? `${l.welpen_gesamt} Welpen` : null,
|
||
`${l.foto_count} Medien`,
|
||
].filter(Boolean).join(' · ');
|
||
return `
|
||
<div style="border:1px solid var(--c-border);border-radius:var(--radius-md);padding:var(--space-3)">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-2)">
|
||
<div>
|
||
<div style="font-weight:700;font-size:var(--text-sm)">${_esc(label)}</div>
|
||
<div class="text-xs-muted">${info}</div>
|
||
</div>
|
||
<label class="btn btn-secondary btn-sm" style="cursor:pointer">
|
||
<svg class="ph-icon" style="width:14px;height:14px"><use href="/icons/phosphor.svg#upload-simple"></use></svg>
|
||
Upload
|
||
<input type="file" class="be-litter-input" data-litter-id="${l.id}"
|
||
data-label="${_esc(label)}" accept="image/*,video/*" class="hidden">
|
||
</label>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function _storageBar(usedMb, limitMb) {
|
||
const pct = Math.min(100, Math.round((usedMb / limitMb) * 100));
|
||
const color = pct > 85 ? '#dc2626' : pct > 60 ? '#f59e0b' : '#22c55e';
|
||
return `
|
||
<div style="display:flex;align-items:center;gap:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">
|
||
<div style="flex:1;height:4px;background:var(--c-surface-2);border-radius:2px;overflow:hidden">
|
||
<div style="width:${pct}%;height:100%;background:${color};border-radius:2px"></div>
|
||
</div>
|
||
<span style="white-space:nowrap">${usedMb.toFixed(1)} / ${limitMb} MB</span>
|
||
</div>`;
|
||
}
|
||
|
||
function _bindEvents() {
|
||
const el = _container;
|
||
|
||
// Logo hochladen
|
||
el.querySelector('#be-logo-input')?.addEventListener('change', async e => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
fd.append('entity_type', 'breeder');
|
||
fd.append('entity_id', String(_data.profile.id));
|
||
fd.append('is_primary', '1');
|
||
fd.append('visibility', 'public');
|
||
try {
|
||
await API.breederPhotos.upload(fd);
|
||
UI.toast.success('Logo gespeichert.');
|
||
await _load();
|
||
} catch (err) { UI.toast.error(err.message); }
|
||
});
|
||
|
||
// Profil-Texte speichern
|
||
el.querySelector('#be-text-form')?.addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const btn = e.target.querySelector('[type="submit"]');
|
||
const fd = UI.formData(e.target);
|
||
await UI.asyncButton(btn, async () => {
|
||
await API.put('/breeder/profile', fd);
|
||
_data.profile = { ..._data.profile, ...fd };
|
||
UI.toast.success('Profil gespeichert.');
|
||
});
|
||
});
|
||
|
||
// Profil-Foto/-Video hochladen
|
||
el.querySelector('#be-profile-photo-input')?.addEventListener('change', async e => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
const isVideo = file.type.startsWith('video/');
|
||
if (isVideo) UI.toast.info('Video wird komprimiert – das kann 1–2 Minuten dauern …', 120_000);
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
fd.append('entity_type', 'breeder');
|
||
fd.append('entity_id', String(_data.profile.id));
|
||
fd.append('visibility', 'public');
|
||
try {
|
||
await API.breederPhotos.upload(fd);
|
||
UI.toast.success(isVideo ? 'Video hinzugefügt.' : 'Foto hinzugefügt.');
|
||
await _load();
|
||
} catch (err) { UI.toast.error(err.message); }
|
||
});
|
||
|
||
// Foto löschen
|
||
el.querySelectorAll('.be-photo-del').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
if (!confirm('Löschen?')) return;
|
||
try {
|
||
await API.breederPhotos.remove(parseInt(btn.dataset.id));
|
||
await _load();
|
||
} catch (err) { UI.toast.error(err.message); }
|
||
});
|
||
});
|
||
|
||
// Als Logo setzen
|
||
el.querySelectorAll('.be-photo-primary').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
try {
|
||
await API.patch(`/breeder/photos/${btn.dataset.id}/primary`, {});
|
||
await _load();
|
||
} catch (err) { UI.toast.error(err.message); }
|
||
});
|
||
});
|
||
|
||
// Wurf-Upload
|
||
el.querySelectorAll('.be-litter-input').forEach(input => {
|
||
input.addEventListener('change', async e => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
const isVideo = file.type.startsWith('video/');
|
||
const litterId = input.dataset.litterId;
|
||
const label = input.dataset.label;
|
||
if (isVideo) UI.toast.info('Video wird komprimiert – das kann 1–2 Minuten dauern …', 120_000);
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
fd.append('entity_type', 'litter');
|
||
fd.append('entity_id', litterId);
|
||
fd.append('visibility', 'public');
|
||
try {
|
||
await API.breederPhotos.upload(fd);
|
||
UI.toast.success(`${isVideo ? 'Video' : 'Foto'} zu „${label}" hinzugefügt.`);
|
||
// Foto-Count aktualisieren
|
||
const litter = _data.litters.find(l => String(l.id) === String(litterId));
|
||
if (litter) litter.foto_count++;
|
||
_render();
|
||
} catch (err) { UI.toast.error(err.message); }
|
||
});
|
||
});
|
||
}
|
||
|
||
function _esc(s) {
|
||
return String(s ?? '').replace(/[&<>"']/g, c =>
|
||
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
}
|
||
|
||
return { init, refresh, onDogChange };
|
||
|
||
})();
|