feat(sources): X-Account-Verwaltung im Verwaltungsportal
Neuer Sub-Tab "X-Accounts" unter Quellen: die als Recherchequelle eingebundenen X-Accounts anzeigen, hinzufuegen, bearbeiten und entfernen. Schreibt source_type=x_account in die geteilte sources-Tabelle, von wo der Monitor sie pro Lage nutzt. - x_account im source_type-Pattern von GlobalSourceCreate/Update - primary_language in Create/Update plus INSERT (Keyword-Matching) - x_account-Typ und x-Kategorie in source_meta.py - Sub-Tab, Tabelle und Modal in dashboard.html, Logik in sources.js Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -38,10 +38,152 @@ function setupSourceSubTabs() {
|
||||
else if (subtab === "tenant-sources") loadTenantSources();
|
||||
else if (subtab === "source-health") loadHealthData();
|
||||
else if (subtab === "classification-review") loadClassificationQueue();
|
||||
else if (subtab === "x-accounts") loadXAccounts();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- X-Accounts (Recherche-Accounts für den Monitor) ---
|
||||
let xAccountsCache = [];
|
||||
let editingXAccountId = null;
|
||||
|
||||
function normalizeXHandle(raw) {
|
||||
let h = (raw || "").trim();
|
||||
h = h.replace(/^https?:\/\//i, "").replace(/^www\./i, "");
|
||||
h = h.replace(/^(x\.com\/|twitter\.com\/|nitter\.net\/)/i, "");
|
||||
h = h.replace(/^@/, "").replace(/\/+$/, "");
|
||||
return h.split(/[/?#]/)[0];
|
||||
}
|
||||
|
||||
async function loadXAccounts() {
|
||||
setupXAccountForm();
|
||||
const tbody = document.getElementById("xAccountTable");
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">Lade...</td></tr>';
|
||||
try {
|
||||
const all = await API.get("/api/sources/global");
|
||||
xAccountsCache = (all || []).filter((s) => s.source_type === "x_account");
|
||||
renderXAccounts(xAccountsCache);
|
||||
} catch (err) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">Fehler beim Laden</td></tr>';
|
||||
showToast("X-Accounts konnten nicht geladen werden", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function renderXAccounts(list) {
|
||||
const tbody = document.getElementById("xAccountTable");
|
||||
const cnt = document.getElementById("xAccountCount");
|
||||
if (cnt) cnt.textContent = list.length + (list.length === 1 ? " Account" : " Accounts");
|
||||
if (!list.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">Keine X-Accounts. Mit „+ X-Account hinzufügen" anlegen.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = list.map((s) => {
|
||||
const handle = normalizeXHandle(s.url || s.domain || s.name || "");
|
||||
const url = "https://x.com/" + handle;
|
||||
const lang = s.primary_language || s.language || "—";
|
||||
const notes = s.notes ? esc(s.notes) : '<span class="text-muted">—</span>';
|
||||
const status = s.status === "active"
|
||||
? '<span style="color:var(--success,#2e7d32);">Aktiv</span>'
|
||||
: '<span class="text-muted">Inaktiv</span>';
|
||||
return '<tr>'
|
||||
+ '<td><strong>' + esc(s.name || ("@" + handle)) + '</strong></td>'
|
||||
+ '<td><a href="' + esc(url) + '" target="_blank" rel="noopener">' + esc(handle) + '</a></td>'
|
||||
+ '<td>' + esc(lang) + '</td>'
|
||||
+ '<td>' + notes + '</td>'
|
||||
+ '<td>' + (s.article_count || 0) + '</td>'
|
||||
+ '<td>' + status + '</td>'
|
||||
+ '<td>'
|
||||
+ '<button class="btn btn-secondary btn-small" onclick="openXAccountModal(' + s.id + ')">Bearbeiten</button> '
|
||||
+ '<button class="btn btn-danger btn-small" onclick="confirmDeleteXAccount(' + s.id + ')">Löschen</button>'
|
||||
+ '</td>'
|
||||
+ '</tr>';
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function filterXAccounts() {
|
||||
const q = (document.getElementById("xAccountSearch").value || "").toLowerCase();
|
||||
if (!q) { renderXAccounts(xAccountsCache); return; }
|
||||
renderXAccounts(xAccountsCache.filter((s) =>
|
||||
(s.name || "").toLowerCase().includes(q)
|
||||
|| (s.url || "").toLowerCase().includes(q)
|
||||
|| (s.notes || "").toLowerCase().includes(q)
|
||||
));
|
||||
}
|
||||
|
||||
function openXAccountModal(id) {
|
||||
editingXAccountId = id || null;
|
||||
const errEl = document.getElementById("xAccountError");
|
||||
errEl.style.display = "none";
|
||||
const s = editingXAccountId ? xAccountsCache.find((a) => a.id === editingXAccountId) : null;
|
||||
if (editingXAccountId && !s) return;
|
||||
document.getElementById("xAccountModalTitle").textContent = s ? "X-Account bearbeiten" : "X-Account hinzufügen";
|
||||
document.getElementById("xAccountHandle").value = s ? normalizeXHandle(s.url || s.domain || "") : "";
|
||||
document.getElementById("xAccountName").value = s ? (s.name || "") : "";
|
||||
document.getElementById("xAccountLanguage").value = s ? (s.primary_language || s.language || "en") : "en";
|
||||
document.getElementById("xAccountNotes").value = s ? (s.notes || "") : "";
|
||||
document.getElementById("xAccountStatus").value = s ? (s.status || "active") : "active";
|
||||
openModal("modalXAccount");
|
||||
}
|
||||
|
||||
function setupXAccountForm() {
|
||||
const form = document.getElementById("xAccountForm");
|
||||
if (!form || form.dataset.wired) return;
|
||||
form.dataset.wired = "1";
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const errEl = document.getElementById("xAccountError");
|
||||
errEl.style.display = "none";
|
||||
const handle = normalizeXHandle(document.getElementById("xAccountHandle").value);
|
||||
if (!handle) {
|
||||
errEl.textContent = "Bitte einen Handle oder eine x.com-URL eingeben.";
|
||||
errEl.style.display = "block";
|
||||
return;
|
||||
}
|
||||
const nameVal = document.getElementById("xAccountName").value.trim();
|
||||
const body = {
|
||||
name: nameVal || ("@" + handle),
|
||||
url: "x.com/" + handle,
|
||||
domain: "x.com/" + handle,
|
||||
source_type: "x_account",
|
||||
category: "x",
|
||||
status: document.getElementById("xAccountStatus").value,
|
||||
notes: document.getElementById("xAccountNotes").value.trim() || null,
|
||||
primary_language: document.getElementById("xAccountLanguage").value || null,
|
||||
};
|
||||
try {
|
||||
if (editingXAccountId) {
|
||||
await API.put("/api/sources/global/" + editingXAccountId, body);
|
||||
} else {
|
||||
await API.post("/api/sources/global", body);
|
||||
}
|
||||
closeModal("modalXAccount");
|
||||
loadXAccounts();
|
||||
showToast("X-Account gespeichert.", "success");
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message || "Speichern fehlgeschlagen";
|
||||
errEl.style.display = "block";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDeleteXAccount(id) {
|
||||
const s = xAccountsCache.find((a) => a.id === id);
|
||||
if (!s) return;
|
||||
showConfirm(
|
||||
"X-Account entfernen",
|
||||
'Soll der X-Account "' + (s.name || "") + '" als Recherchequelle entfernt werden?',
|
||||
async () => {
|
||||
try {
|
||||
await API.del("/api/sources/global/" + id);
|
||||
loadXAccounts();
|
||||
showToast("X-Account entfernt.", "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "Löschen fehlgeschlagen", "error");
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// --- Grundquellen ---
|
||||
async function loadGlobalSources() {
|
||||
try {
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren