Social Media Manager: Route, DB, KI-Prompts, Frontend, Rolle; SW by-v338
This commit is contained in:
parent
d90d4f1eeb
commit
0df6d569c1
9 changed files with 784 additions and 6 deletions
|
|
@ -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)
|
||||
|
|
|
|||
394
backend/static/js/pages/social.js
Normal file
394
backend/static/js/pages/social.js
Normal 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,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// 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 };
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue