Infrastruktur: - CT 113 auf pve-hetzner erstellt (Docker, Tailscale) - Forgejo-Repo redax-wp angelegt Code (Sprint 2): - docker-compose.yml: wordpress + db + redax-web - .env.example mit allen Variablen - database.py: articles, feeds, feed_items, prompts, settings - wordpress.py: WP REST API Client (create/update post, media upload, Yoast SEO) - rss_fetcher.py: Feed-Import, Blacklist, Teaser-Modus, KI-Rewrite - app.py: Flask Dashboard, Scheduler (publish/rss/briefing), alle API-Routen - templates: base, login, index (Zwei-Spalten-Editor), feeds, history, prompts, settings, hilfe - README.md + .gitignore Made-with: Cursor
182 lines
8.4 KiB
HTML
182 lines
8.4 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Redax-WP — Feeds{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="max-w-6xl mx-auto px-6 py-6">
|
|
<div class="flex items-center justify-between mb-6">
|
|
<h1 class="text-xl font-bold text-white">📡 RSS-Feeds</h1>
|
|
<button onclick="showAddFeed()" class="btn btn-primary text-sm">+ Feed hinzufügen</button>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
|
|
<!-- Feed-Liste -->
|
|
<div>
|
|
<h2 class="text-sm font-semibold text-slate-400 mb-3">Aktive Feeds ({{ feeds|length }})</h2>
|
|
<div class="space-y-2">
|
|
{% for feed in feeds %}
|
|
<div class="card p-4">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="flex items-center gap-2">
|
|
<span class="w-2 h-2 rounded-full {% if feed.active %}bg-green-400{% else %}bg-slate-600{% endif %}"></span>
|
|
<span class="text-sm font-semibold text-white">{{ feed.name }}</span>
|
|
</div>
|
|
<div class="flex gap-1">
|
|
<button onclick="fetchFeedNow({{ feed.id }}, this)"
|
|
class="text-xs text-slate-500 hover:text-blue-400 px-2 py-1 rounded hover:bg-slate-700 transition">
|
|
🔄 Abrufen
|
|
</button>
|
|
<button onclick="deleteFeed({{ feed.id }}, '{{ feed.name }}')"
|
|
class="text-xs text-slate-500 hover:text-red-400 px-2 py-1 rounded hover:bg-slate-700 transition">
|
|
🗑️
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="text-xs text-slate-500 truncate mb-2">{{ feed.url }}</div>
|
|
<div class="flex gap-2 flex-wrap text-xs">
|
|
<span class="bg-slate-700 text-slate-300 px-2 py-0.5 rounded">{{ feed.schedule }}</span>
|
|
{% if feed.auto_publish %}<span class="bg-green-900/40 text-green-400 px-2 py-0.5 rounded border border-green-800">Auto-Publish</span>{% endif %}
|
|
{% if feed.ki_rewrite %}<span class="bg-purple-900/40 text-purple-400 px-2 py-0.5 rounded border border-purple-800">KI-Rewrite</span>{% endif %}
|
|
{% if feed.teaser_only %}<span class="bg-slate-700/50 text-slate-400 px-2 py-0.5 rounded">Teaser</span>{% endif %}
|
|
</div>
|
|
{% if feed.last_error %}
|
|
<div class="text-xs text-red-400 mt-2">⚠️ {{ feed.last_error[:80] }}</div>
|
|
{% endif %}
|
|
{% if feed.last_fetched_at %}
|
|
<div class="text-xs text-slate-600 mt-1">Zuletzt: {{ feed.last_fetched_at[:16] }}</div>
|
|
{% endif %}
|
|
</div>
|
|
{% else %}
|
|
<div class="card p-6 text-center text-slate-600 text-sm">Noch keine Feeds konfiguriert.</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Artikel-Queue -->
|
|
<div>
|
|
<h2 class="text-sm font-semibold text-slate-400 mb-3">Artikel-Queue ({{ queue|length }} neu)</h2>
|
|
<div class="space-y-2">
|
|
{% for item in queue %}
|
|
<div class="card p-3" id="queue-item-{{ item.id }}">
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-xs text-slate-500 mb-1">📡 {{ item.feed_name }}</div>
|
|
<div class="text-sm text-slate-200 truncate font-medium">{{ item.title }}</div>
|
|
<a href="{{ item.url }}" target="_blank" class="text-xs text-blue-400 hover:text-blue-300 truncate block">{{ item.url[:60] }}...</a>
|
|
</div>
|
|
<div class="flex flex-col gap-1 shrink-0">
|
|
<button onclick="approveItem({{ item.id }})"
|
|
class="text-xs bg-green-800 hover:bg-green-700 text-white px-2 py-1 rounded">✓ Übernehmen</button>
|
|
<button onclick="rejectItem({{ item.id }})"
|
|
class="text-xs text-slate-600 hover:text-red-400 px-2 py-1 rounded hover:bg-slate-700">✗ Ablehnen</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="card p-6 text-center text-slate-600 text-sm">Queue ist leer — alle Artikel verarbeitet.</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add Feed Modal -->
|
|
<div id="add-feed-modal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
|
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6 w-full max-w-md mx-4">
|
|
<h3 class="text-white font-semibold mb-4">+ Feed hinzufügen</h3>
|
|
<div class="space-y-3">
|
|
<div>
|
|
<label class="text-xs text-slate-400 block mb-1">Name</label>
|
|
<input type="text" id="new-feed-name" class="w-full" placeholder="z.B. Heise Online">
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-slate-400 block mb-1">RSS-URL</label>
|
|
<input type="url" id="new-feed-url" class="w-full" placeholder="https://...">
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-slate-400 block mb-1">Abruf-Intervall (Minuten)</label>
|
|
<select id="new-feed-schedule" class="w-full">
|
|
<option value="*/30 * * * *">Alle 30 Minuten</option>
|
|
<option value="*/60 * * * *">Stündlich</option>
|
|
<option value="*/120 * * * *">Alle 2 Stunden</option>
|
|
<option value="*/180 * * * *">Alle 3 Stunden</option>
|
|
<option value="0 8,14,20 * * *">3x täglich (8/14/20 Uhr)</option>
|
|
</select>
|
|
</div>
|
|
<div class="flex gap-4">
|
|
<label class="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
|
|
<input type="checkbox" id="new-feed-auto" class="rounded"> Auto-Publish
|
|
</label>
|
|
<label class="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
|
|
<input type="checkbox" id="new-feed-ki" class="rounded"> KI-Rewrite
|
|
</label>
|
|
<label class="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
|
|
<input type="checkbox" id="new-feed-teaser" class="rounded" checked> Nur Teaser
|
|
</label>
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-slate-400 block mb-1">Blacklist (kommagetrennt)</label>
|
|
<input type="text" id="new-feed-blacklist" class="w-full text-xs" placeholder="Anzeige:, Sponsored, Werbung">
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2 mt-5">
|
|
<button onclick="submitAddFeed()" class="btn btn-primary flex-1">Feed hinzufügen</button>
|
|
<button onclick="hideAddFeed()" class="btn btn-ghost">Abbrechen</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
function showAddFeed() { document.getElementById('add-feed-modal').classList.remove('hidden'); }
|
|
function hideAddFeed() { document.getElementById('add-feed-modal').classList.add('hidden'); }
|
|
|
|
async function submitAddFeed() {
|
|
const data = {
|
|
name: document.getElementById('new-feed-name').value,
|
|
url: document.getElementById('new-feed-url').value,
|
|
schedule: document.getElementById('new-feed-schedule').value,
|
|
active: 1,
|
|
auto_publish: document.getElementById('new-feed-auto').checked ? 1 : 0,
|
|
ki_rewrite: document.getElementById('new-feed-ki').checked ? 1 : 0,
|
|
teaser_only: document.getElementById('new-feed-teaser').checked ? 1 : 0,
|
|
blacklist: document.getElementById('new-feed-blacklist').value,
|
|
};
|
|
if (!data.name || !data.url) { showToast('⚠️ Name und URL erforderlich'); return; }
|
|
const r = await fetch('/api/feeds/add', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
|
|
const d = await r.json();
|
|
if (d.success) { showToast('✅ Feed hinzugefügt'); setTimeout(() => location.reload(), 1000); }
|
|
else showToast('❌ Fehler');
|
|
}
|
|
|
|
async function fetchFeedNow(id, btn) {
|
|
btn.textContent = '⟳ ...';
|
|
const r = await fetch(`/api/feeds/${id}/fetch`, {method:'POST'});
|
|
const d = await r.json();
|
|
if (d.success) showToast(`✅ ${d.new_items} neue Artikel`);
|
|
btn.textContent = '🔄 Abrufen';
|
|
}
|
|
|
|
async function deleteFeed(id, name) {
|
|
if (!confirm(`Feed "${name}" wirklich löschen?`)) return;
|
|
const r = await fetch(`/api/feeds/${id}/delete`, {method:'POST'});
|
|
const d = await r.json();
|
|
if (d.success) { showToast('🗑️ Feed gelöscht'); setTimeout(() => location.reload(), 1000); }
|
|
}
|
|
|
|
async function approveItem(id) {
|
|
const r = await fetch(`/api/queue/${id}/approve`, {method:'POST'});
|
|
const d = await r.json();
|
|
if (d.success) {
|
|
showToast('✅ Artikel übernommen');
|
|
document.getElementById(`queue-item-${id}`).remove();
|
|
}
|
|
}
|
|
|
|
async function rejectItem(id) {
|
|
const r = await fetch(`/api/queue/${id}/reject`, {method:'POST'});
|
|
const d = await r.json();
|
|
if (d.success) document.getElementById(`queue-item-${id}`).remove();
|
|
}
|
|
{% endblock %}
|