Social Media Manager: Route, DB, KI-Prompts, Frontend, Rolle; SW by-v338

This commit is contained in:
rene 2026-04-24 19:13:30 +02:00
parent d90d4f1eeb
commit 0df6d569c1
9 changed files with 784 additions and 6 deletions

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '324'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '325'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {
@ -54,6 +54,7 @@ const App = (() => {
lost: { title: 'Verlorener Hund', module: null },
friends: { title: 'Freunde', module: null, requiresAuth: true },
chat: { title: 'Nachrichten', module: null, requiresAuth: true },
social: { title: 'Social Media', module: null, requiresAuth: true },
admin: { title: 'Admin', module: null, requiresAuth: true },
impressum: { title: 'Impressum', module: null },
datenschutz: { title: 'Datenschutz', module: null },
@ -421,6 +422,11 @@ const App = (() => {
|| state.user.is_moderator;
adminItem.style.display = isMod ? '' : 'none';
}
const socialItem = document.getElementById('sidebar-social');
if (socialItem) {
const isSocial = state.user.is_social_media || state.user.rolle === 'admin';
socialItem.style.display = isSocial ? '' : 'none';
}
await _loadDogs();
// Eingeloggter User ohne Hund → Onboarding-Wizard (einmalig)

View file

@ -0,0 +1,394 @@
/* BAN YARO — Social Media Manager */
window.Page_social = (() => {
let _el, _state;
let _breeds = [];
let _contents = [];
let _activeTab = 'archiv';
const _STATUS_LABEL = {
idea: 'Idee', draft: 'Entwurf', scheduled: 'Geplant',
published: 'Veröffentlicht', archived: 'Archiviert',
};
const _STATUS_COLOR = {
idea: 'var(--c-text-muted)', draft: 'var(--c-warning)',
scheduled: 'var(--c-primary)', published: 'var(--c-success)',
archived: 'var(--c-text-muted)',
};
const _FORMAT_ICON = { reel: 'film-strip', story: 'circle', post: 'image', carousel: 'images' };
const _PLATFORM_ICON = { tiktok: 'music-notes-plus', instagram: 'instagram-logo', both: 'share-network' };
async function init(el, state) {
_el = el; _state = state;
_el.innerHTML = UI.skeleton(3);
_breeds = await API.get('/social/breeds').catch(() => []);
await _render();
}
function refresh() { if (_el) _render(); }
async function _render() {
_el.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;
margin-bottom:var(--space-4);flex-wrap:wrap;gap:var(--space-2)">
<h2 style="margin:0;font-size:var(--text-lg);font-weight:700">Social Media Manager</h2>
</div>
<!-- Tabs -->
<div style="display:flex;gap:0;border-bottom:2px solid var(--c-border);
margin-bottom:var(--space-4);overflow-x:auto">
${['archiv','generator','bewerten'].map(t => `
<button class="sm-tab${_activeTab===t?' sm-tab--active':''}" data-tab="${t}"
style="padding:var(--space-2) var(--space-4);border:none;background:none;
cursor:pointer;font-size:var(--text-sm);white-space:nowrap;
border-bottom:2px solid ${_activeTab===t?'var(--c-primary)':'transparent'};
margin-bottom:-2px;font-weight:${_activeTab===t?'600':'400'};
color:${_activeTab===t?'var(--c-primary)':'var(--c-text-secondary)'}">
${{archiv:'Archiv',generator:'Idee generieren',bewerten:'Entwurf bewerten'}[t]}
</button>`).join('')}
</div>
<div id="sm-content"></div>`;
_el.querySelectorAll('.sm-tab').forEach(btn =>
btn.addEventListener('click', () => { _activeTab = btn.dataset.tab; _render(); })
);
const cont = _el.querySelector('#sm-content');
if (_activeTab === 'archiv') await _renderArchiv(cont);
if (_activeTab === 'generator') _renderGenerator(cont);
if (_activeTab === 'bewerten') _renderBewerten(cont);
}
// ---------------------------------------------------------------
// ARCHIV
// ---------------------------------------------------------------
async function _renderArchiv(el) {
el.innerHTML = UI.skeleton(2);
const statusFilter = ['alle','idea','draft','scheduled','published','archived'];
let currentFilter = 'alle';
async function load(filter) {
currentFilter = filter;
const url = filter === 'alle' ? '/social/content' : `/social/content?status=${filter}`;
_contents = await API.get(url).catch(() => []);
renderList();
}
function renderList() {
el.innerHTML = `
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
${statusFilter.map(s => `
<button class="btn btn-sm ${currentFilter===s?'btn-primary':'btn-secondary'}"
data-filter="${s}" style="padding:2px 10px">
${{alle:'Alle',idea:'Ideen',draft:'Entwürfe',scheduled:'Geplant',
published:'Veröffentlicht',archived:'Archiv'}[s]}
</button>`).join('')}
</div>
${_contents.length === 0
? UI.emptyState({icon:'instagram-logo', title:'Noch keine Inhalte',
text:'Erstelle deinen ersten Content-Vorschlag im Tab "Idee generieren".'})
: `<div style="display:flex;flex-direction:column;gap:var(--space-3)">
${_contents.map(_renderCard).join('')}
</div>`}`;
el.querySelectorAll('[data-filter]').forEach(btn =>
btn.addEventListener('click', () => load(btn.dataset.filter))
);
el.querySelectorAll('.sm-status-btn').forEach(btn =>
btn.addEventListener('click', async () => {
const {id, status} = btn.dataset;
await API.patch(`/social/content/${id}`, {status});
await load(currentFilter);
})
);
el.querySelectorAll('.sm-delete-btn').forEach(btn =>
btn.addEventListener('click', async () => {
if (!window.confirm('Eintrag löschen?')) return;
await API.delete(`/social/content/${btn.dataset.id}`);
await load(currentFilter);
})
);
el.querySelectorAll('.sm-expand-btn').forEach(btn =>
btn.addEventListener('click', () => {
const detail = el.querySelector(`#sm-detail-${btn.dataset.id}`);
if (detail) detail.style.display = detail.style.display === 'none' ? '' : 'none';
})
);
}
await load('alle');
}
function _renderCard(c) {
const score = c.ai_score ? '⭐'.repeat(c.ai_score) : '';
const unsplashUrl = c.unsplash_query
? `https://unsplash.com/s/photos/${encodeURIComponent(c.unsplash_query)}`
: null;
const nextStatuses = {
idea: ['draft','archived'], draft: ['scheduled','archived'],
scheduled: ['published','draft'], published: ['archived'], archived: ['idea'],
}[c.status] || [];
return `
<div class="card" style="padding:var(--space-3)">
<div style="display:flex;align-items:flex-start;gap:var(--space-3)">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;
margin-bottom:var(--space-1)">
<svg class="ph-icon" style="width:16px;height:16px;flex-shrink:0">
<use href="/icons/phosphor.svg#${_PLATFORM_ICON[c.platform]||'share-network'}"></use></svg>
<svg class="ph-icon" style="width:16px;height:16px;flex-shrink:0">
<use href="/icons/phosphor.svg#${_FORMAT_ICON[c.format]||'image'}"></use></svg>
<span style="font-size:var(--text-xs);padding:1px 6px;border-radius:4px;
background:var(--c-surface-2);color:${_STATUS_COLOR[c.status]}">
${_STATUS_LABEL[c.status]||c.status}</span>
${score ? `<span style="font-size:11px">${score}</span>` : ''}
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">
${c.created_at?.slice(0,10)||''}</span>
</div>
<div style="font-weight:600;font-size:var(--text-sm);margin-bottom:var(--space-1)">
${_esc(c.topic)}</div>
${c.hook ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
font-style:italic;margin-bottom:var(--space-1)">
🎣 ${_esc(c.hook)}</div>` : ''}
</div>
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
<button class="btn btn-sm btn-secondary sm-expand-btn" data-id="${c.id}"
style="padding:2px 8px;font-size:var(--text-xs)">Details</button>
<button class="btn btn-sm btn-secondary sm-delete-btn" data-id="${c.id}"
style="padding:2px 8px;font-size:var(--text-xs);color:var(--c-danger)"></button>
</div>
</div>
<!-- Detail-Bereich (eingeklappt) -->
<div id="sm-detail-${c.id}" style="display:none;margin-top:var(--space-3);
border-top:1px solid var(--c-border);padding-top:var(--space-3)">
${c.caption ? `<div style="margin-bottom:var(--space-2)">
<div class="sm-label">Caption</div>
<div style="white-space:pre-wrap;font-size:var(--text-sm)">${_esc(c.caption)}</div>
</div>` : ''}
${c.hashtags ? `<div style="margin-bottom:var(--space-2)">
<div class="sm-label">Hashtags</div>
<div style="font-size:var(--text-xs);color:var(--c-primary)">
${c.hashtags.split(',').map(h=>`#${h.trim()}`).join(' ')}</div>
</div>` : ''}
${c.cta ? `<div style="margin-bottom:var(--space-2)">
<div class="sm-label">Call-to-Action</div>
<div style="font-size:var(--text-sm)">${_esc(c.cta)}</div>
</div>` : ''}
${c.visual_brief ? `<div style="margin-bottom:var(--space-2)">
<div class="sm-label">Visual Brief</div>
<div style="font-size:var(--text-sm)">${_esc(c.visual_brief)}</div>
</div>` : ''}
${c.image_prompt ? `<div style="margin-bottom:var(--space-2)">
<div class="sm-label">DALL-E / Midjourney Prompt</div>
<div style="font-size:var(--text-xs);background:var(--c-surface-2);
padding:var(--space-2);border-radius:6px;font-family:monospace">
${_esc(c.image_prompt)}</div>
</div>` : ''}
${c.canva_notes ? `<div style="margin-bottom:var(--space-2)">
<div class="sm-label">Canva-Tipps</div>
<div style="font-size:var(--text-sm)">${_esc(c.canva_notes)}</div>
</div>` : ''}
${c.script ? `<div style="margin-bottom:var(--space-2)">
<div class="sm-label">Video-Skript</div>
<div style="font-size:var(--text-sm);white-space:pre-wrap">${_esc(c.script)}</div>
</div>` : ''}
${unsplashUrl ? `<div style="margin-bottom:var(--space-2)">
<div class="sm-label">Stockfotos</div>
<a href="${unsplashUrl}" target="_blank" rel="noopener"
style="font-size:var(--text-sm);color:var(--c-primary)">
Unsplash: "${_esc(c.unsplash_query)}" </a>
</div>` : ''}
${c.notes ? `<div style="margin-bottom:var(--space-2)">
<div class="sm-label">Notizen / Feedback</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${_esc(c.notes)}</div>
</div>` : ''}
${nextStatuses.length ? `<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2)">
${nextStatuses.map(s => `
<button class="btn btn-sm btn-secondary sm-status-btn"
data-id="${c.id}" data-status="${s}"
style="font-size:var(--text-xs)">
${_STATUS_LABEL[s]}</button>`).join('')}
</div>` : ''}
</div>
</div>`;
}
// ---------------------------------------------------------------
// GENERATOR
// ---------------------------------------------------------------
function _renderGenerator(el) {
el.innerHTML = `
<div class="card" style="padding:var(--space-4)">
<div style="display:grid;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<label class="form-label">Plattform</label>
<select id="sm-platform" class="input">
<option value="both">TikTok + Instagram</option>
<option value="instagram">Instagram</option>
<option value="tiktok">TikTok</option>
</select>
</div>
<div>
<label class="form-label">Format</label>
<select id="sm-format" class="input">
<option value="post">Post / Bild</option>
<option value="reel">Reel / Video</option>
<option value="story">Story</option>
<option value="carousel">Carousel</option>
</select>
</div>
</div>
<div>
<label class="form-label">Thema</label>
<input id="sm-topic" class="input" placeholder="z.B. 5 Tipps gegen Hitzestress im Sommer"
style="width:100%">
</div>
<div>
<label class="form-label">Rasse (optional)</label>
<select id="sm-breed" class="input">
<option value=""> keine spezifische Rasse </option>
${_breeds.map(b => `<option value="${b.id}">${_esc(b.name)}</option>`).join('')}
</select>
</div>
<button id="sm-gen-btn" class="btn btn-primary">
${UI.icon('sparkle')} Content generieren
</button>
<div id="sm-gen-result"></div>
</div>
</div>`;
_el.querySelector('#sm-gen-btn').addEventListener('click', async () => {
const topic = _el.querySelector('#sm-topic').value.trim();
if (!topic) { UI.toast('Bitte ein Thema eingeben.', 'warning'); return; }
const btn = _el.querySelector('#sm-gen-btn');
const res = _el.querySelector('#sm-gen-result');
btn.disabled = true;
btn.textContent = 'Generiere…';
res.innerHTML = '<div style="text-align:center;padding:var(--space-4);color:var(--c-text-muted)">KI denkt nach… (~15s)</div>';
try {
const data = await API.post('/social/generate', {
platform: _el.querySelector('#sm-platform').value,
format: _el.querySelector('#sm-format').value,
topic,
breed_id: parseInt(_el.querySelector('#sm-breed').value) || null,
});
res.innerHTML = `
<div style="background:var(--c-success-bg,#f0fdf4);border:1px solid var(--c-success);
border-radius:8px;padding:var(--space-3);margin-top:var(--space-2)">
Gespeichert im Archiv (Score: ${'⭐'.repeat(data.ai_score||0)})
</div>
${_renderCard(data)}`;
res.querySelectorAll('.sm-expand-btn').forEach(btn => {
const detail = res.querySelector(`#sm-detail-${btn.dataset.id}`);
btn.addEventListener('click', () => {
if (detail) detail.style.display = detail.style.display==='none' ? '' : 'none';
});
});
_el.querySelector('#sm-topic').value = '';
} catch(e) {
res.innerHTML = `<div style="color:var(--c-danger)">${UI.icon('warning')} Fehler: ${_esc(e.message||String(e))}</div>`;
} finally {
btn.disabled = false;
btn.innerHTML = `${UI.icon('sparkle')} Content generieren`;
}
});
}
// ---------------------------------------------------------------
// BEWERTEN
// ---------------------------------------------------------------
function _renderBewerten(el) {
el.innerHTML = `
<div class="card" style="padding:var(--space-4)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
Füge deinen eigenen Entwurf ein die KI bewertet ihn, verbessert Caption,
Hashtags und gibt dir einen Visual Brief dazu.
</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);margin-bottom:var(--space-3)">
<div>
<label class="form-label">Plattform</label>
<select id="sm-eval-platform" class="input">
<option value="instagram">Instagram</option>
<option value="tiktok">TikTok</option>
<option value="both">Beide</option>
</select>
</div>
<div>
<label class="form-label">Format</label>
<select id="sm-eval-format" class="input">
<option value="post">Post / Bild</option>
<option value="reel">Reel / Video</option>
<option value="story">Story</option>
<option value="carousel">Carousel</option>
</select>
</div>
</div>
<textarea id="sm-eval-draft" class="input"
style="width:100%;min-height:120px;resize:vertical"
placeholder="Dein Caption-Entwurf, Idee oder Rohtext…"></textarea>
<button id="sm-eval-btn" class="btn btn-primary" style="margin-top:var(--space-3)">
${UI.icon('magnifying-glass')} Bewerten & verbessern
</button>
<div id="sm-eval-result" style="margin-top:var(--space-3)"></div>
</div>`;
_el.querySelector('#sm-eval-btn').addEventListener('click', async () => {
const draft = _el.querySelector('#sm-eval-draft').value.trim();
if (!draft) { UI.toast('Bitte einen Entwurf eingeben.', 'warning'); return; }
const btn = _el.querySelector('#sm-eval-btn');
const res = _el.querySelector('#sm-eval-result');
btn.disabled = true;
btn.textContent = 'Analysiere…';
res.innerHTML = '<div style="text-align:center;padding:var(--space-4);color:var(--c-text-muted)">KI analysiert… (~10s)</div>';
try {
const data = await API.post('/social/evaluate', {
platform: _el.querySelector('#sm-eval-platform').value,
format: _el.querySelector('#sm-eval-format').value,
draft,
});
res.innerHTML = `
${data.notes ? `<div class="card" style="padding:var(--space-3);background:var(--c-surface-2);
margin-bottom:var(--space-3)">
<div class="sm-label">KI-Feedback</div>
<div style="font-size:var(--text-sm)">${_esc(data.notes)}</div>
</div>` : ''}
${_renderCard(data)}`;
res.querySelectorAll('.sm-expand-btn').forEach(btn => {
const detail = res.querySelector(`#sm-detail-${btn.dataset.id}`);
btn.addEventListener('click', () => {
if (detail) detail.style.display = detail.style.display==='none' ? '' : 'none';
});
});
} catch(e) {
res.innerHTML = `<div style="color:var(--c-danger)">${UI.icon('warning')} Fehler: ${_esc(e.message||String(e))}</div>`;
} finally {
btn.disabled = false;
btn.innerHTML = `${UI.icon('magnifying-glass')} Bewerten & verbessern`;
}
});
}
function _esc(s) {
if (!s) return '';
return String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// CSS
const style = document.createElement('style');
style.textContent = `.sm-label{font-size:var(--text-xs);font-weight:600;
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.5px;
margin-bottom:3px;}`;
document.head.appendChild(style);
return { init, refresh };
})();