Offers are now de-duplicated by normalized URL (tracking params stripped)
in addition to (quelle, external_id), so the same posting never lands
twice — even re-scraped under a new id. Deleting an offer (web or API)
auto-blacklists it, so it can never reappear.
lib/blacklist.js provides shared normalization + matching. Manual entries
can block a URL, a whole domain, a company, or a company+title posting
(gender-marker tolerant). New /blacklist page lists and manages entries.
REST API: GET/POST /joboffers/blacklist, DELETE /joboffers/blacklist/{id};
POST /joboffers returns 409 when blacklisted; DELETE /joboffers/{id}
auto-blacklists (opt out with ?blacklist=false). Swagger updated with the
new paths and schemas.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
197 lines
13 KiB
Plaintext
197 lines
13 KiB
Plaintext
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<%- include('partials/head') %>
|
|
</head>
|
|
<body class="min-h-screen transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
|
<%- include('partials/header') %>
|
|
|
|
<main class="container mx-auto px-4 py-8 max-w-5xl">
|
|
<!-- Back link -->
|
|
<a href="/jobangebote" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
|
|
</svg>
|
|
Zurück zu den Jobangeboten
|
|
</a>
|
|
|
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
|
<div class="flex flex-wrap items-start justify-between gap-3 mb-2">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Blacklist</h2>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 max-w-2xl">
|
|
Angebote auf der Blacklist werden nie (erneut) in die Jobangebote
|
|
aufgenommen. Ein <strong>gelöschtes</strong> Jobangebot landet automatisch
|
|
hier, damit dieselbe Stelle nicht doppelt auftaucht. Du kannst auch manuell
|
|
eine URL, eine ganze Domain, eine Firma oder eine konkrete Firma + Stelle
|
|
blockieren. Einträge lassen sich jederzeit wieder entfernen.
|
|
</p>
|
|
</div>
|
|
<span class="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-red-50 dark:bg-red-900/40 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path></svg>
|
|
<%= eintraege.length %> Eintrag/Einträge
|
|
</span>
|
|
</div>
|
|
|
|
<% if (eintraege && eintraege.length) { %>
|
|
<ul class="mt-4 space-y-3">
|
|
<% eintraege.forEach(function(e){
|
|
var typLabel = { url: 'URL', domain: 'Domain', firma: 'Firma', firma_stelle: 'Firma + Stelle', auto: 'Automatisch (gelöscht)' }[e.typ] || e.typ;
|
|
var typClass = e.typ === 'auto'
|
|
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-200'
|
|
: 'bg-purple-100 text-purple-800 dark:bg-purple-900/50 dark:text-purple-200';
|
|
var wert = '';
|
|
if (e.typ === 'url') wert = e.quelle_url || e.url_norm || '';
|
|
else if (e.typ === 'domain') wert = e.domain || '';
|
|
else if (e.typ === 'firma') wert = e.firma || e.firma_norm || '';
|
|
else if (e.typ === 'firma_stelle') wert = [e.firma, e.stelle].filter(Boolean).join(' · ');
|
|
else wert = [e.firma, e.stelle].filter(Boolean).join(' · ') || e.quelle_url || e.url_norm || e.domain || '';
|
|
%>
|
|
<li class="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 px-4 py-3">
|
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex flex-wrap items-center gap-2 mb-1">
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium <%= typClass %>"><%= typLabel %></span>
|
|
<% if (e.typ === 'auto' && e.quelle_url) { %>
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300">URL erfasst</span>
|
|
<% } %>
|
|
<% if (e.external_id) { %>
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300">ext. ID</span>
|
|
<% } %>
|
|
</div>
|
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 break-all"><%= wert || '(kein Wert)' %></p>
|
|
<% if (e.typ === 'auto' && e.quelle_url && wert !== e.quelle_url) { %>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 break-all mt-0.5"><%= e.quelle_url %></p>
|
|
<% } %>
|
|
<% if (e.grund) { %>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Grund: <%= e.grund %></p>
|
|
<% } %>
|
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
|
Hinzugefügt: <%= e.created_at ? new Date(e.created_at + 'Z').toLocaleString('de-DE') : '' %>
|
|
</p>
|
|
</div>
|
|
<form action="/blacklist/<%= e.id %>/delete" method="POST"
|
|
onsubmit="return confirm('Blacklist-Eintrag entfernen? Das Angebot kann danach wieder eingespielt werden.');">
|
|
<button type="submit"
|
|
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
|
Entfernen
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</li>
|
|
<% }); %>
|
|
</ul>
|
|
<% } else { %>
|
|
<div class="text-center py-10">
|
|
<svg class="w-14 h-14 mx-auto text-gray-300 dark:text-gray-600 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
</svg>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">Die Blacklist ist leer.</p>
|
|
</div>
|
|
<% } %>
|
|
</div>
|
|
|
|
<!-- Add a manual blacklist entry -->
|
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
|
<h3 class="text-base font-semibold text-gray-800 dark:text-white mb-4">Eintrag manuell hinzufügen</h3>
|
|
<form action="/blacklist" method="POST" class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="bl-typ">Typ</label>
|
|
<select id="bl-typ" name="typ"
|
|
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm">
|
|
<option value="url">URL blockieren</option>
|
|
<option value="domain">Ganze Domain blockieren</option>
|
|
<option value="firma">Firma komplett blockieren</option>
|
|
<option value="firma_stelle">Firma + Stelle blockieren</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div data-when="url domain firma">
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="bl-wert">
|
|
<span data-label="url">URL</span>
|
|
<span data-label="domain" class="hidden">Domain (z. B. beispiel.de)</span>
|
|
<span data-label="firma" class="hidden">Firmenname</span>
|
|
</label>
|
|
<input id="bl-wert" name="wert" type="text" autocomplete="off"
|
|
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm"
|
|
placeholder="https://…">
|
|
</div>
|
|
|
|
<div data-when="firma_stelle" class="hidden grid gap-4 sm:grid-cols-3">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="bl-firma">Firma</label>
|
|
<input id="bl-firma" name="firma" type="text"
|
|
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="bl-stelle">Stelle</label>
|
|
<input id="bl-stelle" name="stelle" type="text"
|
|
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="bl-ort">Ort <span class="text-gray-400">(optional)</span></label>
|
|
<input id="bl-ort" name="ort" type="text"
|
|
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm">
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="bl-grund">Grund <span class="text-gray-400">(optional)</span></label>
|
|
<input id="bl-grund" name="grund" type="text"
|
|
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm"
|
|
placeholder="z. B. Zeitarbeit, unseriös, kein Interesse …">
|
|
</div>
|
|
|
|
<button type="submit"
|
|
class="inline-flex items-center gap-2 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
|
|
Zur Blacklist hinzufügen
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</main>
|
|
|
|
<%- include('partials/footer') %>
|
|
|
|
<script>
|
|
(function () {
|
|
const root = document.documentElement;
|
|
const dm = localStorage.getItem('darkMode');
|
|
if (dm === 'enabled' || (!dm && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
root.classList.add('dark');
|
|
}
|
|
const toggle = document.getElementById('darkModeToggle');
|
|
const sun = document.getElementById('sunIcon');
|
|
const moon = document.getElementById('moonIcon');
|
|
function sync() {
|
|
const d = root.classList.contains('dark');
|
|
if (sun) sun.classList.toggle('hidden', d);
|
|
if (moon) moon.classList.toggle('hidden', !d);
|
|
}
|
|
sync();
|
|
if (toggle) toggle.addEventListener('click', () => {
|
|
root.classList.toggle('dark');
|
|
localStorage.setItem('darkMode', root.classList.contains('dark') ? 'enabled' : 'disabled');
|
|
sync();
|
|
});
|
|
const cy = document.getElementById('currentYear');
|
|
if (cy) cy.textContent = new Date().getFullYear();
|
|
|
|
// Show only the fields relevant to the chosen blacklist type.
|
|
const typ = document.getElementById('bl-typ');
|
|
function applyTyp() {
|
|
const v = typ.value;
|
|
document.querySelectorAll('[data-when]').forEach(function (el) {
|
|
el.classList.toggle('hidden', el.getAttribute('data-when').split(' ').indexOf(v) === -1);
|
|
});
|
|
document.querySelectorAll('[data-label]').forEach(function (el) {
|
|
el.classList.toggle('hidden', el.getAttribute('data-label') !== v);
|
|
});
|
|
}
|
|
if (typ) { typ.addEventListener('change', applyTyp); applyTyp(); }
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|