Per-User Domain-Ausschlüsse + Grundquellen-Schutz

- Neue Tabelle user_excluded_domains für benutzerspezifische Ausschlüsse
- Domain-Ausschlüsse wirken nur für den jeweiligen User, nicht org-weit
- user_id wird durch die gesamte Pipeline geschleust (Orchestrator → Researcher → RSS-Parser)
- Grundquellen (is_global) können nicht mehr bearbeitet/gelöscht werden im Frontend
- Grundquelle-Badge bei globalen Quellen statt Edit/Delete-Buttons
- Filter Von mir ausgeschlossen im Quellen-Modal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-03-08 14:30:21 +01:00
Ursprung 18954cf70e
Commit 5e19736a25
13 geänderte Dateien mit 149 neuen und 108 gelöschten Zeilen

Datei anzeigen

@@ -3437,6 +3437,16 @@ a:hover {
gap: var(--sp-xs);
}
/* Grundquelle-Badge */
.source-global-badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 3px;
background: var(--bg-tertiary, #2a2a2a);
color: var(--text-secondary, #888);
white-space: nowrap;
}
/* Ausgeschlossene Domain */
.source-group-header.excluded {
grid-template-columns: 1fr auto auto;

Datei anzeigen

@@ -427,7 +427,7 @@
<option value="">Alle Typen</option>
<option value="rss_feed">RSS-Feed</option>
<option value="web_source">Web-Quelle</option>
<option value="excluded">Ausgeschlossen</option>
<option value="excluded">Von mir ausgeschlossen</option>
</select>
<label for="sources-filter-category" class="sr-only">Kategorie filtern</label>
<select id="sources-filter-category" class="timeline-filter-select" onchange="App.filterSources()">

Datei anzeigen

@@ -152,6 +152,10 @@ const API = {
return this._request('POST', '/sources/discover-multi', { url });
},
getMyExclusions() {
return this._request('GET', '/sources/my-exclusions');
},
blockDomain(domain, notes) {
return this._request('POST', '/sources/block-domain', { domain, notes });
},

Datei anzeigen

@@ -425,7 +425,7 @@ const App = {
_currentUsername: '',
_allSources: [],
_sourcesOnly: [],
_blacklistOnly: [],
_myExclusions: [], // [{domain, notes, created_at}]
_expandedGroups: new Set(),
_editingSourceId: null,
_timelineFilter: 'all',
@@ -2173,13 +2173,14 @@ const App = {
async loadSources() {
try {
const [sources, stats] = await Promise.all([
const [sources, stats, myExclusions] = await Promise.all([
API.listSources(),
API.getSourceStats(),
API.getMyExclusions(),
]);
this._allSources = sources;
this._sourcesOnly = sources.filter(s => s.source_type !== 'excluded');
this._blacklistOnly = sources.filter(s => s.source_type === 'excluded');
this._myExclusions = myExclusions || [];
this.renderSourceStats(stats);
this.renderSourceList();
@@ -2194,7 +2195,7 @@ const App = {
const rss = stats.by_type.rss_feed || { count: 0, articles: 0 };
const web = stats.by_type.web_source || { count: 0, articles: 0 };
const excluded = this._blacklistOnly.length;
const excluded = this._myExclusions.length;
bar.innerHTML = `
<span class="sources-stat-item"><span class="sources-stat-value">${rss.count}</span> RSS-Feeds</span>
@@ -2221,12 +2222,12 @@ const App = {
const excludedDomains = new Set();
const excludedNotes = {};
// Blacklist-Domains sammeln
this._blacklistOnly.forEach(s => {
const domain = (s.domain || s.name || '').toLowerCase();
// User-Ausschlüsse sammeln
this._myExclusions.forEach(e => {
const domain = (e.domain || '').toLowerCase();
if (domain) {
excludedDomains.add(domain);
excludedNotes[domain] = s.notes || '';
excludedNotes[domain] = e.notes || '';
}
});
@@ -2238,10 +2239,10 @@ const App = {
});
// Ausgeschlossene Domains die keine Feeds haben auch als Gruppe
this._blacklistOnly.forEach(s => {
const domain = (s.domain || s.name || '').toLowerCase();
this._myExclusions.forEach(e => {
const domain = (e.domain || '').toLowerCase();
if (domain && !groups.has(domain)) {
groups.set(domain, [s]);
groups.set(domain, []);
}
});
@@ -2249,6 +2250,7 @@ const App = {
let filteredGroups = [];
for (const [domain, feeds] of groups) {
const isExcluded = excludedDomains.has(domain);
const isGlobal = feeds.some(f => f.is_global);
// Typ-Filter
if (typeFilter === 'excluded' && !isExcluded) continue;
@@ -2271,7 +2273,7 @@ const App = {
if (!groupText.includes(search)) continue;
}
filteredGroups.push({ domain, feeds, isExcluded });
filteredGroups.push({ domain, feeds, isExcluded, isGlobal });
}
if (filteredGroups.length === 0) {
@@ -2286,7 +2288,7 @@ const App = {
});
list.innerHTML = filteredGroups.map(g =>
UI.renderSourceGroup(g.domain, g.feeds, g.isExcluded, excludedNotes[g.domain] || '')
UI.renderSourceGroup(g.domain, g.feeds, g.isExcluded, excludedNotes[g.domain] || '', g.isGlobal)
).join('');
// Erweiterte Gruppen wiederherstellen
@@ -2440,11 +2442,11 @@ const App = {
* Domain direkt ausschließen (aus der Gruppenliste).
*/
async blockDomainDirect(domain) {
if (!await confirmDialog(`"${domain}" wirklich ausschließen? Alle Feeds dieser Domain werden deaktiviert.`)) return;
if (!await confirmDialog(`"${domain}" wirklich ausschließen? Artikel dieser Domain werden bei deinen Recherchen ignoriert.`)) return;
try {
await API.blockDomain(domain);
UI.showToast(`${domain} gesperrt.`, 'success');
UI.showToast(`${domain} ausgeschlossen.`, 'success');
await this.loadSources();
this.updateSidebarStats();
} catch (err) {
@@ -2560,7 +2562,7 @@ const App = {
// Prüfen ob Domain ausgeschlossen ist
const inputDomain = url.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0].toLowerCase();
const isBlocked = inputDomain && this._blacklistOnly.some(s => (s.domain || '').toLowerCase() === inputDomain);
const isBlocked = inputDomain && this._myExclusions.some(e => (e.domain || '').toLowerCase() === inputDomain);
if (isBlocked) {
if (!await confirmDialog(`"${inputDomain}" ist ausgeschlossen. Trotzdem hinzufügen? Der Ausschluss wird dabei aufgehoben.`)) return;

Datei anzeigen

@@ -529,7 +529,7 @@ const UI = {
/**
* Domain-Gruppe rendern (aufklappbar mit Feeds).
*/
renderSourceGroup(domain, feeds, isExcluded, excludedNotes) {
renderSourceGroup(domain, feeds, isExcluded, excludedNotes, isGlobal) {
const catLabel = this._categoryLabels[feeds[0]?.category] || feeds[0]?.category || '';
const feedCount = feeds.filter(f => f.source_type !== 'excluded').length;
const hasMultiple = feedCount > 1;
@@ -547,7 +547,6 @@ const UI = {
<span class="source-excluded-badge">Ausgeschlossen</span>
<div class="source-group-actions">
<button class="btn btn-small btn-secondary" onclick="App.unblockDomain('${escapedDomain}')">Ausschluss aufheben</button>
<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">&times;</button>
</div>
</div>
</div>`;
@@ -571,8 +570,8 @@ const UI = {
<span class="source-feed-name">${this.escape(feed.name)}</span>
<span class="source-type-badge type-${feed.source_type}">${typeLabel}</span>
<span class="source-feed-url" title="${this.escape(feed.url || '')}">${this.escape(urlDisplay)}</span>
<button class="source-edit-btn" onclick="App.editSource(${feed.id})" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</button>
<button class="source-delete-btn" onclick="App.deleteSingleFeed(${feed.id})" title="Löschen" aria-label="Löschen">&times;</button>
${!feed.is_global ? `<button class="source-edit-btn" onclick="App.editSource(${feed.id})" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</button>
<button class="source-delete-btn" onclick="App.deleteSingleFeed(${feed.id})" title="Löschen" aria-label="Löschen">&times;</button>` : '<span class="source-global-badge">Grundquelle</span>'}
</div>`;
});
feedRows += '</div>';
@@ -591,9 +590,9 @@ const UI = {
<span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span>
${feedCountBadge}
<div class="source-group-actions" onclick="event.stopPropagation()">
${!hasMultiple && feeds[0]?.id ? `<button class="source-edit-btn" onclick="App.editSource(${feeds[0].id})" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</button>` : ''}
${!isGlobal && !hasMultiple && feeds[0]?.id ? `<button class="source-edit-btn" onclick="App.editSource(${feeds[0].id})" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</button>` : ''}
<button class="btn btn-small btn-secondary" onclick="App.blockDomainDirect('${escapedDomain}')">Ausschließen</button>
<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">&times;</button>
${!isGlobal ? `<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">&times;</button>` : ''}
</div>
</div>
${feedRows}