Phase 15: language + bias als Spalten, Filter, Edit-Form
Bisher waren die DB-Felder sources.language und sources.bias zwar gepflegt (254/275 Quellen mit bias, 254 mit language), aber in der Verwaltung nicht sichtbar. Der Admin konnte nicht filtern oder editieren. Backend (routers/sources.py) - GlobalSourceCreate + GlobalSourceUpdate Pydantic-Modelle: language + bias als Optional[str] erweitert (max 100 / 500 Zeichen). - SOURCE_UPDATE_COLUMNS: language + bias hinzu. - INSERT in create_global_source: schreibt language + bias mit. - Neuer Endpoint GET /api/sources/global/languages: distinct language-Werte fuer Frontend-Filter-Dropdown. Frontend HTML (dashboard.html) - Grundquellen-Filter-Bar: Sprachen-Dropdown ergaenzt. - Grundquellen-Tabellenkopf: 2 neue Spalten Sprache (sortable) + Bias. - modalSource: 2 neue Felder language (mit datalist Vorschlaegen) + bias. - Kundenquellen-Filter-Bar: Sprachen-Dropdown. - Kundenquellen-Tabellenkopf: Sprache (sortable) + Bias. Frontend JS (sources.js) - loadGlobalSources lädt /languages parallel zu /global + /global/stats, populiert beide Sprache-Dropdowns + datalist im Edit-Modal. - renderGlobalSources: cols 11 -> 13, language+bias-Zellen (Bias mit Tooltip fuer Lang-Texte). - filterGlobalSources: Sprache-Filter, Bias in Suche. - editGlobalSource: language + bias laden. - Form-Submit: language + bias mitgesendet. - renderTenantSources: cols 8 -> 10, language+bias-Zellen. - tenantFilters um language erweitert, applyTenantFilterAndSort prueft. Cache-Buster ?v=20260509 (heute) bleibt - Tag wechselt erst morgen.
Dieser Commit ist enthalten in:
@@ -24,7 +24,7 @@ logger = logging.getLogger("verwaltung.sources")
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/sources", tags=["sources"])
|
router = APIRouter(prefix="/api/sources", tags=["sources"])
|
||||||
|
|
||||||
SOURCE_UPDATE_COLUMNS = {"name", "url", "domain", "source_type", "category", "status", "notes"}
|
SOURCE_UPDATE_COLUMNS = {"name", "url", "domain", "source_type", "category", "status", "notes", "language", "bias"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/meta")
|
@router.get("/meta")
|
||||||
@@ -46,6 +46,8 @@ class GlobalSourceCreate(BaseModel):
|
|||||||
category: str = Field(default="sonstige")
|
category: str = Field(default="sonstige")
|
||||||
status: str = Field(default="active", pattern="^(active|inactive)$")
|
status: str = Field(default="active", pattern="^(active|inactive)$")
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
language: Optional[str] = Field(default=None, max_length=100)
|
||||||
|
bias: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
class GlobalSourceUpdate(BaseModel):
|
class GlobalSourceUpdate(BaseModel):
|
||||||
@@ -56,6 +58,8 @@ class GlobalSourceUpdate(BaseModel):
|
|||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
status: Optional[str] = Field(default=None, pattern="^(active|inactive)$")
|
status: Optional[str] = Field(default=None, pattern="^(active|inactive)$")
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
language: Optional[str] = Field(default=None, max_length=100)
|
||||||
|
bias: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/global")
|
@router.get("/global")
|
||||||
@@ -139,9 +143,10 @@ async def create_global_source(
|
|||||||
)
|
)
|
||||||
|
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id)
|
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, language, bias, added_by, tenant_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'system', NULL)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'system', NULL)""",
|
||||||
(data.name, data.url, data.domain, data.source_type, data.category, data.status, data.notes),
|
(data.name, data.url, data.domain, data.source_type, data.category, data.status, data.notes,
|
||||||
|
data.language, data.bias),
|
||||||
)
|
)
|
||||||
src_id = cursor.lastrowid
|
src_id = cursor.lastrowid
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -223,6 +228,24 @@ async def delete_global_source(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/global/languages")
|
||||||
|
async def get_global_languages(
|
||||||
|
admin: dict = Depends(get_current_admin),
|
||||||
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
|
):
|
||||||
|
"""Distinct language-Werte aus Grundquellen - für Frontend-Filter-Dropdown."""
|
||||||
|
cur = await db.execute("""
|
||||||
|
SELECT DISTINCT language
|
||||||
|
FROM sources
|
||||||
|
WHERE tenant_id IS NULL AND language IS NOT NULL AND language != ''
|
||||||
|
ORDER BY language
|
||||||
|
""")
|
||||||
|
return [r["language"] for r in await cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/global/stats")
|
@router.get("/global/stats")
|
||||||
async def get_global_stats(
|
async def get_global_stats(
|
||||||
admin: dict = Depends(get_current_admin),
|
admin: dict = Depends(get_current_admin),
|
||||||
|
|||||||
@@ -313,6 +313,9 @@
|
|||||||
<option value="active">Aktiv</option>
|
<option value="active">Aktiv</option>
|
||||||
<option value="inactive">Inaktiv</option>
|
<option value="inactive">Inaktiv</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select class="filter-select" id="globalFilterLanguage" onchange="filterGlobalSources()">
|
||||||
|
<option value="">Alle Sprachen</option>
|
||||||
|
</select>
|
||||||
<span class="text-secondary" id="globalSourceCount"></span>
|
<span class="text-secondary" id="globalSourceCount"></span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-secondary" id="discoverSourceBtn">Erkennen</button>
|
<button class="btn btn-secondary" id="discoverSourceBtn">Erkennen</button>
|
||||||
@@ -330,6 +333,8 @@
|
|||||||
<th class="sortable" data-sort="article_count" onclick="sortGlobalSources('article_count')">Artikel <span class="sort-icon"></span></th>
|
<th class="sortable" data-sort="article_count" onclick="sortGlobalSources('article_count')">Artikel <span class="sort-icon"></span></th>
|
||||||
<th class="sortable" data-sort="articles_30d" onclick="sortGlobalSources('articles_30d')">Aktivität <span class="sort-icon"></span></th>
|
<th class="sortable" data-sort="articles_30d" onclick="sortGlobalSources('articles_30d')">Aktivität <span class="sort-icon"></span></th>
|
||||||
<th class="sortable" data-sort="tenant_excluded_count" onclick="sortGlobalSources('tenant_excluded_count')">Sperren <span class="sort-icon"></span></th>
|
<th class="sortable" data-sort="tenant_excluded_count" onclick="sortGlobalSources('tenant_excluded_count')">Sperren <span class="sort-icon"></span></th>
|
||||||
|
<th class="sortable" data-sort="language" onclick="sortGlobalSources('language')">Sprache <span class="sort-icon"></span></th>
|
||||||
|
<th>Bias</th>
|
||||||
<th class="sortable" data-sort="last_seen_at" onclick="sortGlobalSources('last_seen_at')">Letzter Treffer <span class="sort-icon"></span></th>
|
<th class="sortable" data-sort="last_seen_at" onclick="sortGlobalSources('last_seen_at')">Letzter Treffer <span class="sort-icon"></span></th>
|
||||||
<th class="sortable" data-sort="health_status" onclick="sortGlobalSources('health_status')">Health <span class="sort-icon"></span></th>
|
<th class="sortable" data-sort="health_status" onclick="sortGlobalSources('health_status')">Health <span class="sort-icon"></span></th>
|
||||||
<th class="sortable" data-sort="status" onclick="sortGlobalSources('status')">Status <span class="sort-icon"></span></th>
|
<th class="sortable" data-sort="status" onclick="sortGlobalSources('status')">Status <span class="sort-icon"></span></th>
|
||||||
@@ -356,6 +361,9 @@
|
|||||||
<select class="filter-select" id="tenantFilterOrg" onchange="filterTenantSources()">
|
<select class="filter-select" id="tenantFilterOrg" onchange="filterTenantSources()">
|
||||||
<option value="">Alle Organisationen</option>
|
<option value="">Alle Organisationen</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select class="filter-select" id="tenantFilterLanguage" onchange="filterTenantSources()">
|
||||||
|
<option value="">Alle Sprachen</option>
|
||||||
|
</select>
|
||||||
<span class="text-secondary" id="tenantSourceCount"></span>
|
<span class="text-secondary" id="tenantSourceCount"></span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" id="tenantBulkPromoteBtn" disabled onclick="bulkPromoteSelected()">
|
<button class="btn btn-primary" id="tenantBulkPromoteBtn" disabled onclick="bulkPromoteSelected()">
|
||||||
@@ -373,6 +381,8 @@
|
|||||||
<th class="sortable" data-sort="source_type" onclick="sortTenantSources('source_type')">Typ <span class="sort-icon"></span></th>
|
<th class="sortable" data-sort="source_type" onclick="sortTenantSources('source_type')">Typ <span class="sort-icon"></span></th>
|
||||||
<th class="sortable" data-sort="category" onclick="sortTenantSources('category')">Kategorie <span class="sort-icon"></span></th>
|
<th class="sortable" data-sort="category" onclick="sortTenantSources('category')">Kategorie <span class="sort-icon"></span></th>
|
||||||
<th class="sortable" data-sort="org_name" onclick="sortTenantSources('org_name')">Organisation <span class="sort-icon"></span></th>
|
<th class="sortable" data-sort="org_name" onclick="sortTenantSources('org_name')">Organisation <span class="sort-icon"></span></th>
|
||||||
|
<th class="sortable" data-sort="language" onclick="sortTenantSources('language')">Sprache <span class="sort-icon"></span></th>
|
||||||
|
<th>Bias</th>
|
||||||
<th>Hinzugefügt von</th>
|
<th>Hinzugefügt von</th>
|
||||||
<th>Aktionen</th>
|
<th>Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -597,6 +607,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="sourceStatus">Status</label>
|
<label for="sourceStatus">Status</label>
|
||||||
<select id="sourceStatus">
|
<select id="sourceStatus">
|
||||||
@@ -604,6 +615,16 @@
|
|||||||
<option value="inactive">Inaktiv</option>
|
<option value="inactive">Inaktiv</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sourceLanguage">Sprache</label>
|
||||||
|
<input type="text" id="sourceLanguage" list="languageSuggestions" placeholder="z.B. Deutsch, Englisch, Russisch">
|
||||||
|
<datalist id="languageSuggestions"></datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sourceBias">Bias / Einordnung</label>
|
||||||
|
<input type="text" id="sourceBias" placeholder="z.B. Nachrichtenagentur, faktenbasiert-neutral" maxlength="500">
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="sourceNotes">Notizen</label>
|
<label for="sourceNotes">Notizen</label>
|
||||||
<input type="text" id="sourceNotes" placeholder="Optional">
|
<input type="text" id="sourceNotes" placeholder="Optional">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
let globalSourcesCache = [];
|
let globalSourcesCache = [];
|
||||||
let tenantSourcesCache = [];
|
let tenantSourcesCache = [];
|
||||||
// Phase 3c: Tenant-Tab State
|
// Phase 3c: Tenant-Tab State
|
||||||
let tenantFilters = { search: "", type: "", category: "", org: "" };
|
let tenantFilters = { search: "", type: "", category: "", org: "", language: "" };
|
||||||
let tenantSort = { field: "org_name", asc: true };
|
let tenantSort = { field: "org_name", asc: true };
|
||||||
let tenantSelected = new Set();
|
let tenantSelected = new Set();
|
||||||
|
|
||||||
@@ -50,11 +50,32 @@ async function loadGlobalSources() {
|
|||||||
populateSelect(document.getElementById("globalFilterType"),
|
populateSelect(document.getElementById("globalFilterType"),
|
||||||
(window.META.types || []).filter(t => t.key !== "excluded"), "Alle Typen");
|
(window.META.types || []).filter(t => t.key !== "excluded"), "Alle Typen");
|
||||||
}
|
}
|
||||||
const [list, stats] = await Promise.all([
|
const [list, stats, languages] = await Promise.all([
|
||||||
API.get("/api/sources/global"),
|
API.get("/api/sources/global"),
|
||||||
API.get("/api/sources/global/stats"),
|
API.get("/api/sources/global/stats"),
|
||||||
|
API.get("/api/sources/global/languages").catch(() => []),
|
||||||
]);
|
]);
|
||||||
globalSourcesCache = list;
|
globalSourcesCache = list;
|
||||||
|
populateSelect(
|
||||||
|
document.getElementById("globalFilterLanguage"),
|
||||||
|
(languages || []).map(l => ({ key: l, label: l })),
|
||||||
|
"Alle Sprachen",
|
||||||
|
);
|
||||||
|
populateSelect(
|
||||||
|
document.getElementById("tenantFilterLanguage"),
|
||||||
|
(languages || []).map(l => ({ key: l, label: l })),
|
||||||
|
"Alle Sprachen",
|
||||||
|
);
|
||||||
|
// datalist fuer Edit-Modal
|
||||||
|
const dl = document.getElementById("languageSuggestions");
|
||||||
|
if (dl) {
|
||||||
|
dl.innerHTML = "";
|
||||||
|
(languages || []).forEach(l => {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = l;
|
||||||
|
dl.appendChild(o);
|
||||||
|
});
|
||||||
|
}
|
||||||
renderGlobalStats(stats);
|
renderGlobalStats(stats);
|
||||||
renderGlobalSources(globalSourcesCache);
|
renderGlobalSources(globalSourcesCache);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -138,7 +159,7 @@ function renderGlobalStats(stats) {
|
|||||||
|
|
||||||
function renderGlobalSources(sources) {
|
function renderGlobalSources(sources) {
|
||||||
const tbody = document.getElementById("globalSourceTable");
|
const tbody = document.getElementById("globalSourceTable");
|
||||||
const cols = 11;
|
const cols = 13;
|
||||||
if (sources.length === 0) {
|
if (sources.length === 0) {
|
||||||
tbody.innerHTML = `<tr><td colspan="${cols}" class="text-muted">Keine Grundquellen</td></tr>`;
|
tbody.innerHTML = `<tr><td colspan="${cols}" class="text-muted">Keine Grundquellen</td></tr>`;
|
||||||
return;
|
return;
|
||||||
@@ -178,6 +199,8 @@ function renderGlobalSources(sources) {
|
|||||||
<td class="text-right">${s.article_count || 0}</td>
|
<td class="text-right">${s.article_count || 0}</td>
|
||||||
<td class="${(s.articles_30d || 0) === 0 ? "activity-cell activity-zero" : "activity-cell"}" title="7 Tage / 30 Tage"><strong>${s.articles_7d || 0}</strong> / ${s.articles_30d || 0}</td>
|
<td class="${(s.articles_30d || 0) === 0 ? "activity-cell activity-zero" : "activity-cell"}" title="7 Tage / 30 Tage"><strong>${s.articles_7d || 0}</strong> / ${s.articles_30d || 0}</td>
|
||||||
<td class="text-right"><span class="${(s.tenant_excluded_count || 0) === 0 ? "exclude-badge exclude-zero" : "exclude-badge"}">${s.tenant_excluded_count || 0}</span></td>
|
<td class="text-right"><span class="${(s.tenant_excluded_count || 0) === 0 ? "exclude-badge exclude-zero" : "exclude-badge"}">${s.tenant_excluded_count || 0}</span></td>
|
||||||
|
<td class="text-secondary">${esc(s.language || "-")}</td>
|
||||||
|
<td class="text-secondary" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.bias || "")}">${esc(s.bias || "-")}</td>
|
||||||
<td class="text-secondary">${lastSeen}</td>
|
<td class="text-secondary">${lastSeen}</td>
|
||||||
<td><span class="health-badge ${hsClass}">${hsLabel}</span></td>
|
<td><span class="health-badge ${hsClass}">${hsLabel}</span></td>
|
||||||
<td><span class="badge badge-${s.status === "active" ? "active" : "inactive"}">${s.status === "active" ? "Aktiv" : "Inaktiv"}</span></td>
|
<td><span class="badge badge-${s.status === "active" ? "active" : "inactive"}">${s.status === "active" ? "Aktiv" : "Inaktiv"}</span></td>
|
||||||
@@ -213,11 +236,13 @@ function filterGlobalSources() {
|
|||||||
const catFilter = document.getElementById("globalFilterCategory")?.value || "";
|
const catFilter = document.getElementById("globalFilterCategory")?.value || "";
|
||||||
const statusFilter = document.getElementById("globalFilterStatus")?.value || "";
|
const statusFilter = document.getElementById("globalFilterStatus")?.value || "";
|
||||||
|
|
||||||
|
const langFilter = document.getElementById("globalFilterLanguage")?.value || "";
|
||||||
let filtered = globalSourcesCache.filter((s) => {
|
let filtered = globalSourcesCache.filter((s) => {
|
||||||
if (q && !(s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.url || "").toLowerCase().includes(q))) return false;
|
if (q && !(s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.url || "").toLowerCase().includes(q) || (s.bias || "").toLowerCase().includes(q))) return false;
|
||||||
if (typeFilter && s.source_type !== typeFilter) return false;
|
if (typeFilter && s.source_type !== typeFilter) return false;
|
||||||
if (catFilter && s.category !== catFilter) return false;
|
if (catFilter && s.category !== catFilter) return false;
|
||||||
if (statusFilter && s.status !== statusFilter) return false;
|
if (statusFilter && s.status !== statusFilter) return false;
|
||||||
|
if (langFilter && s.language !== langFilter) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -270,6 +295,8 @@ function editGlobalSource(id) {
|
|||||||
document.getElementById("sourceCategory").value = s.category;
|
document.getElementById("sourceCategory").value = s.category;
|
||||||
document.getElementById("sourceStatus").value = s.status;
|
document.getElementById("sourceStatus").value = s.status;
|
||||||
document.getElementById("sourceNotes").value = s.notes || "";
|
document.getElementById("sourceNotes").value = s.notes || "";
|
||||||
|
document.getElementById("sourceLanguage").value = s.language || "";
|
||||||
|
document.getElementById("sourceBias").value = s.bias || "";
|
||||||
openModal("modalSource");
|
openModal("modalSource");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +322,8 @@ function setupSourceForms() {
|
|||||||
category: document.getElementById("sourceCategory").value,
|
category: document.getElementById("sourceCategory").value,
|
||||||
status: document.getElementById("sourceStatus").value,
|
status: document.getElementById("sourceStatus").value,
|
||||||
notes: document.getElementById("sourceNotes").value || null,
|
notes: document.getElementById("sourceNotes").value || null,
|
||||||
|
language: document.getElementById("sourceLanguage").value || null,
|
||||||
|
bias: document.getElementById("sourceBias").value || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -381,6 +410,7 @@ function applyTenantFilterAndSort() {
|
|||||||
if (tenantFilters.type && s.source_type !== tenantFilters.type) return false;
|
if (tenantFilters.type && s.source_type !== tenantFilters.type) return false;
|
||||||
if (tenantFilters.category && s.category !== tenantFilters.category) return false;
|
if (tenantFilters.category && s.category !== tenantFilters.category) return false;
|
||||||
if (tenantFilters.org && s.org_name !== tenantFilters.org) return false;
|
if (tenantFilters.org && s.org_name !== tenantFilters.org) return false;
|
||||||
|
if (tenantFilters.language && s.language !== tenantFilters.language) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -403,6 +433,7 @@ function filterTenantSources() {
|
|||||||
tenantFilters.type = document.getElementById("tenantFilterType")?.value || "";
|
tenantFilters.type = document.getElementById("tenantFilterType")?.value || "";
|
||||||
tenantFilters.category = document.getElementById("tenantFilterCategory")?.value || "";
|
tenantFilters.category = document.getElementById("tenantFilterCategory")?.value || "";
|
||||||
tenantFilters.org = document.getElementById("tenantFilterOrg")?.value || "";
|
tenantFilters.org = document.getElementById("tenantFilterOrg")?.value || "";
|
||||||
|
tenantFilters.language = document.getElementById("tenantFilterLanguage")?.value || "";
|
||||||
applyTenantFilterAndSort();
|
applyTenantFilterAndSort();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,7 +494,7 @@ async function bulkPromoteSelected() {
|
|||||||
|
|
||||||
function renderTenantSources(sources) {
|
function renderTenantSources(sources) {
|
||||||
const tbody = document.getElementById("tenantSourceTable");
|
const tbody = document.getElementById("tenantSourceTable");
|
||||||
const cols = 8;
|
const cols = 10;
|
||||||
if (sources.length === 0) {
|
if (sources.length === 0) {
|
||||||
tbody.innerHTML = `<tr><td colspan="${cols}" class="text-muted">Keine Kundenquellen</td></tr>`;
|
tbody.innerHTML = `<tr><td colspan="${cols}" class="text-muted">Keine Kundenquellen</td></tr>`;
|
||||||
document.getElementById("tenantSourceCount").textContent = `0 / ${tenantSourcesCache.length} Kundenquellen`;
|
document.getElementById("tenantSourceCount").textContent = `0 / ${tenantSourcesCache.length} Kundenquellen`;
|
||||||
@@ -480,6 +511,8 @@ function renderTenantSources(sources) {
|
|||||||
<td>${typeLabel(s.source_type)}</td>
|
<td>${typeLabel(s.source_type)}</td>
|
||||||
<td>${categoryLabel(s.category)}</td>
|
<td>${categoryLabel(s.category)}</td>
|
||||||
<td>${esc(s.org_name || "-")}</td>
|
<td>${esc(s.org_name || "-")}</td>
|
||||||
|
<td class="text-secondary">${esc(s.language || "-")}</td>
|
||||||
|
<td class="text-secondary" style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.bias || "")}">${esc(s.bias || "-")}</td>
|
||||||
<td>${esc(s.added_by || "-")}</td>
|
<td>${esc(s.added_by || "-")}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-primary btn-small" onclick="promoteSource(${s.id}, '${esc(s.name)}')">Übernehmen</button>
|
<button class="btn btn-primary btn-small" onclick="promoteSource(${s.id}, '${esc(s.name)}')">Übernehmen</button>
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren