Jobangebote: Favoriten (Stern) und Startseiten-Sektion ganz oben
Stellen lassen sich per Stern merken — unabhängig vom Status (offen / uebernommen / abgelehnt). Neue Spalte jobangebote.favorit (PRAGMA-Migration), JSON-Toggle-Endpunkt /jobangebote/:id/favorit, Stern-Button auf den Karten sowie eine "Gemerkte Stellen"-Sektion ganz oben auf der Startseite. Toggle laeuft per fetch mit optimistischem UI; beim Entfernen auf der Startseite verschwindet die Zeile sofort. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1438,6 +1438,16 @@ async function initializeDatabase() {
|
||||
await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_url_norm ON jobangebote(url_norm)');
|
||||
await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_firma_slug ON jobangebote(firma_slug)');
|
||||
|
||||
// Favoriten-Merkmal (Stern): 0 = nicht gemerkt, 1 = favorisiert. Favoriten
|
||||
// erscheinen oben auf der Startseite — unabhängig vom Status (offen /
|
||||
// uebernommen / abgelehnt). Spalte wird nur bei Bedarf angelegt.
|
||||
{
|
||||
const jobSpalten = await dbAll('PRAGMA table_info(jobangebote)');
|
||||
if (!jobSpalten.some((s) => s.name === 'favorit')) {
|
||||
await exec('ALTER TABLE jobangebote ADD COLUMN favorit INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
}
|
||||
|
||||
// Blacklist of job offers that must never (re)appear in the list (per user).
|
||||
await exec(`
|
||||
CREATE TABLE IF NOT EXISTS jobangebote_blacklist (
|
||||
@@ -1744,6 +1754,17 @@ initializeDatabase().then(async () => {
|
||||
};
|
||||
}
|
||||
|
||||
// Gemerkte Stellen (Favoriten) für die Sektion ganz oben auf der
|
||||
// Startseite — unabhängig vom Angebot-Status (offen/uebernommen/…).
|
||||
const favoriten = await dbAll(
|
||||
`SELECT j.id, j.firma, j.stelle, j.ort, j.status, j.quelle_url,
|
||||
j.verknuepfte_bewerbung_id, j.gehalt, j.favorit
|
||||
FROM jobangebote j
|
||||
WHERE j.user_id = ? AND j.favorit = 1
|
||||
ORDER BY j.updated_at DESC, j.id DESC`,
|
||||
[U]
|
||||
);
|
||||
|
||||
res.render('index', {
|
||||
applications,
|
||||
statistics: {
|
||||
@@ -1760,7 +1781,8 @@ initializeDatabase().then(async () => {
|
||||
artOptions: ART_OPTIONS,
|
||||
statusOptions: STATUS_OPTIONS,
|
||||
labelOptions: LABEL_OPTIONS,
|
||||
setupWizard
|
||||
setupWizard,
|
||||
favoriten
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
@@ -3602,6 +3624,22 @@ initializeDatabase().then(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Favorit umschalten (Stern). JSON-Antwort, damit der Client den Stern
|
||||
// sofort umschalten kann, ohne die Seite neu zu laden. Favoriten sind
|
||||
// unabhängig vom Angebot-Status sichtbar — auch „uebernommen“ bleibt merkbar.
|
||||
app.post('/jobangebote/:id/favorit', async (req, res) => {
|
||||
try {
|
||||
const angebot = await dbGet('SELECT favorit FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||
if (!angebot) return res.status(404).json({ error: 'Jobangebot nicht gefunden' });
|
||||
const neu = angebot.favorit ? 0 : 1;
|
||||
await dbRun('UPDATE jobangebote SET favorit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', [neu, req.params.id, uid()]);
|
||||
res.json({ success: true, favorit: neu });
|
||||
} catch (error) {
|
||||
console.error('Error toggling favorite:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// Blacklist management page — see what is blocked and remove entries.
|
||||
app.get('/blacklist', async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
<main class="flex-1 container mx-auto px-4 py-8">
|
||||
<% var zeigeDashboard = !(typeof setupWizard !== 'undefined' && setupWizard && setupWizard.aktiv); %>
|
||||
<%- include('partials/favoriten') %>
|
||||
<% if (!zeigeDashboard) { %>
|
||||
<%- include('partials/einrichtung') %>
|
||||
<% } %>
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="shrink-0 flex flex-col gap-2 self-start">
|
||||
<%- include('partials/favorit-button', { j: j }) %>
|
||||
<% if (j.verknuepfte_bewerbung_id) { %>
|
||||
<a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>"
|
||||
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
||||
@@ -297,5 +298,7 @@
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<%- include('partials/favorit-script') %>
|
||||
</body>
|
||||
</html>
|
||||
@@ -100,6 +100,7 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="shrink-0 flex flex-col gap-2 self-start">
|
||||
<%- include('partials/favorit-button', { j: j }) %>
|
||||
<% if (j.verknuepfte_bewerbung_id) { %>
|
||||
<a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>"
|
||||
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
||||
@@ -140,5 +141,7 @@
|
||||
if (cy) cy.textContent = new Date().getFullYear();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<%- include('partials/favorit-script') %>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<%
|
||||
// Stern-Button zum Merken/Entfernen eines Jobangebots. Lokale `j` muss das
|
||||
// Angebot (mindestens id + favorit) sein. Zustand wird über die Klasse
|
||||
// .is-fav gesteuert (definiert in partials/favorit-script). Das Skript dort
|
||||
// bedient Klicks per Delegation — also nur einmal pro Seite einbinden.
|
||||
var favAktiv = !!(typeof j !== 'undefined' && j && j.favorit);
|
||||
%>
|
||||
<button type="button" class="fav-star<%= favAktiv ? ' is-fav' : '' %> inline-flex items-center justify-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors"
|
||||
data-favorit="<%= j.id %>" data-aktiv="<%= favAktiv ? 1 : 0 %>"
|
||||
title="<%= favAktiv ? 'Favorit entfernen' : 'Als Favorit merken' %>"
|
||||
aria-pressed="<%= favAktiv ? 'true' : 'false' %>">
|
||||
<svg class="fav-ico h-4 w-4" viewBox="0 0 24 24" fill="<%= favAktiv ? 'currentColor' : 'none' %>" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79-5.2-2.73-5.2 2.73.99-5.79-4.21-4.1 5.82-.85 2.6-5.27z"/>
|
||||
</svg>
|
||||
<span class="fav-label"><%= favAktiv ? 'Gemerkt' : 'Merken' %></span>
|
||||
</button>
|
||||
@@ -0,0 +1,76 @@
|
||||
<style>
|
||||
/* Stern-Button: neutral grau, gemerkt amber. Klasse .is-fav steuert den
|
||||
Zustand; das Skript unten schaltet sie per Klick um (optimistisch). */
|
||||
.fav-star { color: rgb(107 114 128); } /* gray-500 */
|
||||
.fav-star:hover { background-color: rgb(243 244 246); } /* gray-100 */
|
||||
.dark .fav-star { color: rgb(209 213 219); }
|
||||
.dark .fav-star:hover { background-color: rgb(55 65 81); } /* gray-700 */
|
||||
.fav-star.is-fav {
|
||||
color: rgb(217 119 6); /* amber-600 */
|
||||
background-color: rgb(255 251 235); /* amber-50 */
|
||||
border-color: rgb(252 211 77); /* amber-300 */
|
||||
}
|
||||
.dark .fav-star.is-fav {
|
||||
color: rgb(252 211 77); /* amber-300 */
|
||||
background-color: rgba(245, 158, 11, 0.10);
|
||||
border-color: rgba(251, 191, 36, 0.40);
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
if (window.__favStarBound) return;
|
||||
window.__favStarBound = true;
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-favorit]');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
var id = btn.getAttribute('data-favorit');
|
||||
var aktiv = btn.getAttribute('data-aktiv') === '1';
|
||||
|
||||
// Optimistisch umschalten, sofort sichtbar.
|
||||
setzeBtn(btn, !aktiv);
|
||||
|
||||
fetch('/jobangebote/' + encodeURIComponent(id) + '/favorit', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'fetch' }
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, json: j }; }); })
|
||||
.then(function (r) {
|
||||
if (r.ok && r.json && r.json.success) {
|
||||
setzeBtn(btn, !!r.json.favorit);
|
||||
// Auf der Startseite: beim Entfernen die Zeile ausblenden
|
||||
// und die ganze Sektion einräumen, wenn sie leer wird.
|
||||
if (!r.json.favorit) entferneFavoritZeile(btn);
|
||||
} else {
|
||||
setzeBtn(btn, aktiv); // Server hat abgelehnt -> zurückrollen
|
||||
if (r.json && r.json.error) console.warn('Favorit:', r.json.error);
|
||||
}
|
||||
})
|
||||
.catch(function () { setzeBtn(btn, aktiv); });
|
||||
});
|
||||
|
||||
function setzeBtn(btn, aktiv) {
|
||||
btn.setAttribute('data-aktiv', aktiv ? '1' : '0');
|
||||
btn.setAttribute('aria-pressed', aktiv ? 'true' : 'false');
|
||||
btn.setAttribute('title', aktiv ? 'Favorit entfernen' : 'Als Favorit merken');
|
||||
btn.classList.toggle('is-fav', !!aktiv);
|
||||
var ico = btn.querySelector('.fav-ico');
|
||||
if (ico) ico.setAttribute('fill', aktiv ? 'currentColor' : 'none');
|
||||
var lbl = btn.querySelector('.fav-label');
|
||||
if (lbl) lbl.textContent = aktiv ? 'Gemerkt' : 'Merken';
|
||||
}
|
||||
|
||||
function entferneFavoritZeile(btn) {
|
||||
var item = btn.closest('[data-fav-item]');
|
||||
if (!item) return; // nicht auf der Startseite -> nur optisch umschalten
|
||||
var card = document.getElementById('favoritenCard');
|
||||
item.remove();
|
||||
if (card && !card.querySelectorAll('[data-fav-item]').length) {
|
||||
card.style.transition = 'opacity 0.2s ease';
|
||||
card.style.opacity = '0';
|
||||
setTimeout(function () { card.remove(); }, 180);
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,72 @@
|
||||
<%
|
||||
// Gemerkte Stellen — Sektion ganz oben auf der Startseite. Zeigt alle
|
||||
// favorisierten Jobangebote unabhängig von ihrem Status (offen/uebernommen/…).
|
||||
// Wird nur gerendert, wenn mindestens ein Favorit vorhanden ist.
|
||||
function favStatusBadge(status) {
|
||||
if (status === 'offen') return 'bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-200';
|
||||
if (status === 'uebernommen') return 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-200';
|
||||
if (status === 'abgelehnt') return 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-200';
|
||||
return 'bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-200';
|
||||
}
|
||||
function favStatusLabel(status) {
|
||||
if (status === 'offen') return 'Offen';
|
||||
if (status === 'uebernommen') return 'Übernommen';
|
||||
if (status === 'abgelehnt') return 'Abgelehnt';
|
||||
return status || '—';
|
||||
}
|
||||
%>
|
||||
<% if (favoriten && favoriten.length) { %>
|
||||
<%- include('favorit-script') %>
|
||||
<section id="favoritenCard" class="bg-white dark:bg-gray-800 rounded-lg shadow-md mb-8 overflow-hidden">
|
||||
<div class="flex flex-wrap items-center gap-2 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<svg class="h-5 w-5 text-amber-500" viewBox="0 0 24 24" fill="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79-5.2-2.73-5.2 2.73.99-5.79-4.21-4.1 5.82-.85 2.6-5.27z"/></svg>
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Gemerkte Stellen</h2>
|
||||
<span class="ml-auto inline-flex items-center gap-1.5 rounded-full bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700 dark:bg-amber-500/10 dark:text-amber-300">
|
||||
<%= favoriten.length %>
|
||||
</span>
|
||||
</div>
|
||||
<ul class="divide-y divide-gray-100 dark:divide-gray-700/60">
|
||||
<% favoriten.forEach(function(j){ %>
|
||||
<li data-fav-item class="flex flex-wrap items-start justify-between gap-3 px-6 py-4">
|
||||
<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 <%= favStatusBadge(j.status) %>"><%= favStatusLabel(j.status) %></span>
|
||||
<% if (j.gehalt) { %>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"><%= j.gehalt %></span>
|
||||
<% } %>
|
||||
</div>
|
||||
<% if (j.quelle_url) { %>
|
||||
<a href="<%= safeUrl(j.quelle_url) %>" target="_blank" rel="noopener noreferrer"
|
||||
class="text-base font-semibold text-gray-800 dark:text-gray-100 break-words hover:underline">
|
||||
<%= j.firma %>
|
||||
</a>
|
||||
<% } else { %>
|
||||
<p class="text-base font-semibold text-gray-800 dark:text-gray-100 break-words"><%= j.firma %></p>
|
||||
<% } %>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-200 break-words"><%= j.stelle %></p>
|
||||
<% if (j.ort) { %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5"><%= j.ort %></p>
|
||||
<% } %>
|
||||
<% if (j.verknuepfte_bewerbung_id) { %>
|
||||
<p class="mt-1.5">
|
||||
<a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>" class="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
Zur Bewerbung
|
||||
</a>
|
||||
</p>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="shrink-0 flex flex-col gap-2 self-start items-end">
|
||||
<% if (j.verknuepfte_bewerbung_id) { %>
|
||||
<a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>"
|
||||
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
||||
Bewerbung
|
||||
</a>
|
||||
<% } %>
|
||||
<%- include('favorit-button', { j: j }) %>
|
||||
</div>
|
||||
</li>
|
||||
<% }); %>
|
||||
</ul>
|
||||
</section>
|
||||
<% } %>
|
||||
Reference in New Issue
Block a user