Top-Navigation: neu strukturiert, mit Aktiv-Zustand und Dropdowns

Die Leiste war eine flache Reihe aus sieben gleichrangigen Pillen. Zwei
Probleme: sie zeigte nie an, wo man gerade ist (es gab schlicht keinen
Aktiv-Zustand), und auf dem Handy fielen nur die Beschriftungen weg - uebrig
blieben sieben unbeschriftete Icons.

Struktur folgt jetzt dem tatsaechlichen Ablauf (finden -> bewerben ->
antworten) statt der Reihenfolge, in der die Seiten entstanden sind:

  Bewerbungen | Jobangebote | Stellensuche v | Postfach | Vorlagen

"Stellensuche" buendelt als Dropdown, was Angebote erzeugt und filtert:
Suchprofil & Laeufe, Uebernommene Angebote, Blacklist - jeweils mit einer Zeile,
die erklaert, wozu der Punkt gut ist. Einstellungen, Benutzerverwaltung und
Abmelden wandern ins Konto-Menue rechts, wo Systemkram hingehoert. Die
taeglichen Wege (Bewerbungen, Jobangebote, Postfach) bleiben ein Klick weit weg.

Gestaltung: Die Leiste behaelt in beiden Themes ihr dunkles Navy - sie ist der
Rahmen des Werkzeugs und die einzige Flaeche, die sich beim Navigieren nie
aendert. Alles darin ist bewusst still (Slate auf Navy); der einzige Akzent ist
der aktive Punkt, markiert durch eine schmale leuchtende Schiene an der
Unterkante. Beim Hover deutet sich dieselbe Schiene gedaempft an.

Zaehler (ungelesene Post, offene Angebote) haengen jetzt an Klassen statt IDs,
weil sie in Leiste UND Mobilmenue erscheinen. Ein Controller bedient alle
Dropdowns: Klick oeffnet, andere schliessen, Escape und Klick daneben schliessen,
aria-expanded bleibt ehrlich. Fokus ist sichtbar, prefers-reduced-motion wird
respektiert, das Mobilmenue zeigt alle Punkte ausgeschrieben und gruppiert.

Der aktive Pfad kommt aus res.locals.pfad (Auth-Middleware). Die Navigation liegt
weiterhin in genau einem Partial - sie ist damit auf allen Seiten identisch;
nur die Login-Seite hat bewusst keine.

Geprueft mit echtem Browser (Screenshots): hell/dunkel, Dropdowns, Mobilmenue,
und der Aktiv-Zustand je Seite - Unterseiten wie /blacklist oder /jobsuche heben
korrekt "Stellensuche" hervor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 23:55:58 +02:00
co-authored by Claude Opus 4.8
parent 9418750061
commit 1aa902bdce
4 changed files with 559 additions and 138 deletions
+227
View File
@@ -7,6 +7,233 @@
box-sizing: border-box; box-sizing: border-box;
} }
/* ============================================
Top navigation (views/partials/header.ejs)
============================================
The bar keeps its deep navy in both themes: it frames the light content area
like the chrome of a tool, and it is the one surface that never changes as you
move through the app. Everything in it is quiet — slate on navy — except the
active item, which is the single accent: a thin, glowing sky rail. That rail is
the whole point, since the previous header had no active state at all.
The dropdown panels follow the theme (white / slate) so they read as content. */
.nav-item {
position: relative;
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
letter-spacing: -0.01em;
color: rgb(148 163 184); /* slate-400 */
white-space: nowrap;
transition: color 150ms ease, background-color 150ms ease;
}
button.nav-item, .icon-btn { cursor: pointer; }
.nav-item:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.06);
}
.nav-item.is-active {
color: #fff;
}
.nav-item:focus-visible {
outline: none;
box-shadow: 0 0 0 2px #0b1220, 0 0 0 4px rgba(56, 189, 248, 0.7);
}
/* The rail: sits on the header's bottom edge, lights up for the active section
and previews faintly on hover. */
.nav-rail {
position: absolute;
left: 0.75rem;
right: 0.75rem;
bottom: -1px;
height: 2px;
border-radius: 9999px;
background-color: transparent;
transform: scaleX(0);
transition: transform 200ms ease, background-color 200ms ease;
}
.nav-item:hover .nav-rail {
background-color: rgba(148, 163, 184, 0.45);
transform: scaleX(1);
}
.nav-item.is-active .nav-rail {
background-color: rgb(56 189 248); /* sky-400 */
box-shadow: 0 0 12px rgba(56, 189, 248, 0.8);
transform: scaleX(1);
}
/* Counters shown inline next to a nav label.
`:not(.hidden)` is deliberate: a bare `display: inline-flex` would fight
Tailwind's `.hidden` (same specificity, load order decides) and the badge would
show even at count 0. This way "hidden" always wins. */
.nav-badge:not(.hidden) {
display: inline-flex;
}
.nav-badge {
align-items: center;
justify-content: center;
min-width: 1.125rem;
height: 1.125rem;
padding: 0 0.3rem;
border-radius: 9999px;
font-size: 0.625rem;
font-weight: 700;
line-height: 1;
color: #fff;
}
.nav-badge--red { background-color: rgb(244 63 94); } /* rose-500 */
.nav-badge--green { background-color: rgb(16 185 129); } /* emerald-500 */
/* Icon-only controls in the bar (bell, burger). */
.icon-btn {
position: relative;
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
color: rgb(148 163 184);
transition: color 150ms ease, background-color 150ms ease;
}
.icon-btn:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.08);
}
.icon-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px #0b1220, 0 0 0 4px rgba(56, 189, 248, 0.7);
}
/* Count badge floating on an icon button. Same `:not(.hidden)` reason as above. */
.dot-badge:not(.hidden) {
display: inline-flex;
}
.dot-badge {
position: absolute;
top: -0.125rem;
right: -0.125rem;
align-items: center;
justify-content: center;
min-width: 1.125rem;
height: 1.125rem;
padding: 0 0.25rem;
border-radius: 9999px;
font-size: 0.625rem;
font-weight: 700;
line-height: 1;
color: #fff;
box-shadow: 0 0 0 2px #0b1220;
}
.dot-badge--red { background-color: rgb(244 63 94); }
/* Dropdown panels. */
.dd-panel {
position: absolute;
right: 0;
top: calc(100% + 0.5rem);
z-index: 50;
padding: 0.375rem;
border-radius: 0.75rem;
background-color: #fff;
box-shadow: 0 20px 40px -12px rgba(0, 0, 0, 0.35);
border: 1px solid rgba(15, 23, 42, 0.08);
animation: dd-in 140ms ease-out;
}
.dark .dd-panel {
background-color: rgb(30 41 59); /* slate-800 */
border-color: rgba(255, 255, 255, 0.08);
box-shadow: 0 20px 40px -12px rgba(0, 0, 0, 0.6);
}
/* Menus that hang off a left-aligned nav item open to the left edge. */
nav .dd-panel { right: auto; left: 0; }
@keyframes dd-in {
from { opacity: 0; transform: translateY(-4px) scale(0.98); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.dd-panel { animation: none; }
.nav-rail { transition: none; }
}
.dd-item {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
color: rgb(51 65 85); /* slate-700 */
transition: background-color 150ms ease, color 150ms ease;
}
.dd-item:hover { background-color: rgb(241 245 249); } /* slate-100 */
.dark .dd-item { color: rgb(203 213 225); }
.dark .dd-item:hover { background-color: rgba(255, 255, 255, 0.06); }
.dd-item:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.8);
}
.dd-item.is-active { background-color: rgb(239 246 255); color: rgb(29 78 216); } /* blue-50/700 */
.dark .dd-item.is-active { background-color: rgba(56, 189, 248, 0.12); color: rgb(125 211 252); }
.dd-item--danger { color: rgb(190 18 60); } /* rose-700 */
.dd-item--danger:hover { background-color: rgb(255 241 242); }
.dark .dd-item--danger { color: rgb(253 164 175); }
.dark .dd-item--danger:hover { background-color: rgba(244, 63, 94, 0.12); }
.dd-icon {
width: 1.125rem;
height: 1.125rem;
flex-shrink: 0;
margin-top: 0.075rem;
opacity: 0.7;
}
.dd-title {
display: block;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.35;
}
.dd-desc {
display: block;
margin-top: 0.075rem;
font-size: 0.75rem;
line-height: 1.35;
color: rgb(100 116 139); /* slate-500 */
}
.dark .dd-desc { color: rgb(148 163 184); }
.dd-item.is-active .dd-desc { color: inherit; opacity: 0.75; }
/* Mobile menu. */
.m-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.9375rem;
font-weight: 500;
color: rgb(148 163 184);
transition: background-color 150ms ease, color 150ms ease;
}
.m-item:hover { background-color: rgba(255, 255, 255, 0.06); color: #fff; }
.m-item.is-active {
color: #fff;
background-color: rgba(56, 189, 248, 0.12);
box-shadow: inset 2px 0 0 rgb(56 189 248);
}
.m-label {
padding: 0 0.75rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.12em;
color: rgb(100 116 139);
}
/* Smooth scrolling */ /* Smooth scrolling */
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
Regular → Executable
View File
+2
View File
@@ -111,6 +111,8 @@ app.use(async (req, res, next) => {
const user = await loadSessionUser(cookies[SESSION_COOKIE]); const user = await loadSessionUser(cookies[SESSION_COOKIE]);
req.user = user; req.user = user;
res.locals.user = user; res.locals.user = user;
// The header highlights the section you are in; it needs the current path.
res.locals.pfad = req.path;
// Warm this user's cfg rows before anything reads them: config.get() is // Warm this user's cfg rows before anything reads them: config.get() is
// synchronous and answers from the per-user cache, so a user whose rows were // synchronous and answers from the per-user cache, so a user whose rows were
// never loaded would silently read as "unconfigured". Until now the process // never loaded would silently read as "unconfigured". Until now the process
+330 -138
View File
@@ -1,137 +1,334 @@
<header class="bg-gradient-to-r from-blue-700 to-blue-900 dark:from-gray-800 dark:to-gray-900 shadow-lg"> <%
<div class="container mx-auto px-4 py-4"> // Current path (res.locals.pfad, set in the auth middleware) drives the active
<div class="flex items-center justify-between"> // state. The old header had none — you could never tell which section you were
<a href="/" class="flex items-center space-x-3"> // in. Copied into a differently named local: re-declaring `pfad` here would
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> // shadow the EJS local under `with(locals)`.
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path> var aktPfad = (typeof pfad !== 'undefined' && pfad) ? String(pfad) : '/';
</svg>
<h1 class="text-xl font-bold text-white">Bewerbungs-Tracker</h1> // Bewerbungen owns the dashboard and every single application page.
var istBewerbungen = (aktPfad === '/' || aktPfad.indexOf('/bewerbung') === 0);
// Offene Angebote only — "übernommen" lives under Stellensuche, with the rest
// of the search machinery.
var istAngebote = (aktPfad === '/jobangebote');
var istPostfach = (aktPfad.indexOf('/postfach') === 0);
var istVorlagen = (aktPfad.indexOf('/vorlagen') === 0);
// The search machinery: how offers get found, what was taken, what is blocked.
var istSuchprofil = (aktPfad.indexOf('/jobsuche') === 0);
var istUebernommen = (aktPfad.indexOf('/jobangebote/uebernommen') === 0);
var istBlacklist = (aktPfad.indexOf('/blacklist') === 0);
var istStellensuche = istSuchprofil || istUebernommen || istBlacklist;
var istEinstellungen = (aktPfad.indexOf('/einstellungen') === 0);
var istAdmin = (aktPfad.indexOf('/admin') === 0);
var angemeldet = (typeof user !== 'undefined' && user);
var initiale = angemeldet ? String(user.username || '?').charAt(0).toUpperCase() : '?';
%>
<header id="siteHeader"
class="sticky top-0 z-50 bg-[#0b1220] text-slate-300 border-b border-white/5 transition-shadow duration-300">
<!-- A single cool glow anchored at the brand, instead of the old full-width
left-to-right gradient sweep. Gives the bar depth without shouting. -->
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(900px_circle_at_0%_-40%,rgba(56,189,248,0.16),transparent_60%)]"></div>
<div class="relative container mx-auto px-4">
<div class="flex h-16 items-center gap-3">
<!-- Brand -->
<a href="/" class="group flex shrink-0 items-center gap-2.5 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0b1220]">
<span class="grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-sky-400 to-blue-600 shadow-lg shadow-blue-900/40 ring-1 ring-white/10">
<svg class="h-5 w-5 text-white" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
</span>
<span class="truncate text-[15px] font-semibold tracking-tight text-white">
Bewerbungs-Tracker
</span>
</a> </a>
<div class="flex items-center space-x-4"> <!-- Primary navigation (desktop). Order follows the actual workflow:
<!-- Postfach (unlinked incoming e-mails) link --> finden -> bewerben -> antworten. -->
<a href="/postfach" <nav class="ml-4 hidden items-center gap-1 lg:flex" aria-label="Hauptnavigation">
class="relative flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
title="E-Mails im Postfach, die noch keiner Bewerbung zugeordnet sind"> <a href="/" class="nav-item <%= istBewerbungen ? 'is-active' : '' %>">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <span>Bewerbungen</span>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path> <span class="nav-rail"></span>
</svg>
<span class="hidden sm:inline">Postfach</span>
<span id="unassignedBadge" class="hidden absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-red-500 text-white text-[10px] font-bold ring-2 ring-blue-800 dark:ring-gray-900">0</span>
</a> </a>
<!-- Jobangebote (ingested via third-party REST API) link --> <a href="/jobangebote" class="nav-item <%= istAngebote ? 'is-active' : '' %>">
<a href="/jobangebote" <span>Jobangebote</span>
id="jobangeboteLink" <span class="js-badge-angebote nav-badge nav-badge--green hidden">0</span>
class="relative flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium" <span class="nav-rail"></span>
title="Über die REST-API eingespielte Jobangebote">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
<span class="hidden sm:inline">Jobangebote</span>
<span id="jobangeboteBadge" class="hidden absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-green-500 text-white text-[10px] font-bold ring-2 ring-blue-800 dark:ring-gray-900">0</span>
</a> </a>
<!-- Jobsuche (per-user search profile + runs) link --> <!-- Stellensuche: everything that produces or filters offers. -->
<a href="/jobsuche" <div class="relative">
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium" <button type="button" data-dd="ddSuche" aria-expanded="false" aria-haspopup="true"
title="Eigenes Suchprofil und Suchläufe verwalten"> class="nav-item <%= istStellensuche ? 'is-active' : '' %>">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <span>Stellensuche</span>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path> <svg class="dd-chevron h-3.5 w-3.5 text-slate-500 transition-transform duration-200" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
</svg> <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path>
<span class="hidden sm:inline">Jobsuche</span>
</a>
<!-- Blacklist (blocked job offers) link -->
<a href="/blacklist"
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
title="Geblockte Jobangebote (Blacklist) verwalten">
<svg class="w-5 h-5" 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>
<span class="hidden sm:inline">Blacklist</span>
</a>
<!-- Vorlagen (base documents) link -->
<a href="/vorlagen"
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
title="Basis-Unterlagen für die KI-Generierung verwalten">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<span class="hidden sm:inline">Vorlagen</span>
</a>
<!-- Settings (formerly .env — now in the database) link -->
<a href="/einstellungen"
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
title="Einstellungen (Ollama, E-Mail, Kalender, REST-API)">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span class="hidden sm:inline">Einstellungen</span>
</a>
<% if (typeof user !== 'undefined' && user && user.is_admin) { %>
<!-- Admin: user management -->
<a href="/admin"
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
title="Benutzer verwalten">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-2a4 4 0 100-8 4 4 0 000 8z"></path>
</svg>
<span class="hidden sm:inline">Benutzer</span>
</a>
<% } %>
<!-- Notification bell: unread received e-mails (incl. auto-assigned replies) -->
<div class="relative" id="notifWrap">
<button id="notifBtn" type="button"
class="relative flex items-center p-2 rounded-full bg-white/20 hover:bg-white/30 transition-colors text-white"
aria-label="Benachrichtigungen" title="Ungelesene E-Mails">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path>
</svg> </svg>
<span id="notifBadge" class="hidden absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-red-500 text-white text-[10px] font-bold ring-2 ring-blue-800 dark:ring-gray-900">0</span> <span class="nav-rail"></span>
</button> </button>
<div id="notifPanel" class="hidden absolute right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] max-h-96 overflow-y-auto rounded-lg bg-white dark:bg-gray-800 shadow-2xl ring-1 ring-black/10 dark:ring-white/10 z-50 text-left">
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-100 dark:border-gray-700 sticky top-0 bg-white dark:bg-gray-800"> <div id="ddSuche" class="dd-panel hidden w-80">
<span class="text-sm font-semibold text-gray-800 dark:text-white">Benachrichtigungen</span> <a href="/jobsuche" class="dd-item <%= istSuchprofil ? 'is-active' : '' %>">
<button id="notifMarkAll" type="button" class="hidden text-xs text-blue-600 dark:text-blue-400 hover:underline">Alle gelesen</button> <svg class="dd-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
</div> <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
<ul id="notifList" class="divide-y divide-gray-100 dark:divide-gray-700"></ul> </svg>
<div id="notifEmpty" class="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">Keine ungelesenen Nachrichten</div> <span>
<span class="dd-title">Suchprofil &amp; Läufe</span>
<span class="dd-desc">Wo und wann automatisch gesucht wird</span>
</span>
</a>
<a href="/jobangebote/uebernommen" class="dd-item <%= istUebernommen ? 'is-active' : '' %>">
<svg class="dd-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"></path>
</svg>
<span>
<span class="dd-title">Übernommene Angebote</span>
<span class="dd-desc">Bereits in eine Bewerbung überführt</span>
</span>
</a>
<a href="/blacklist" class="dd-item <%= istBlacklist ? 'is-active' : '' %>">
<svg class="dd-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" 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>
<span>
<span class="dd-title">Blacklist</span>
<span class="dd-desc">Taucht in keinem Suchlauf wieder auf</span>
</span>
</a>
</div> </div>
</div> </div>
<% if (typeof user !== 'undefined' && user) { %> <a href="/postfach" class="nav-item <%= istPostfach ? 'is-active' : '' %>">
<!-- Signed-in user + logout --> <span>Postfach</span>
<div class="flex items-center gap-2 pl-1"> <span class="js-badge-postfach nav-badge nav-badge--red hidden">0</span>
<span class="text-white/90 text-sm font-medium hidden sm:inline"><%= user.username %><% if (user.is_admin) { %> <span class="text-white/60">(Admin)</span><% } %></span> <span class="nav-rail"></span>
<a href="/logout" </a>
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
title="Abmelden"> <a href="/vorlagen" class="nav-item <%= istVorlagen ? 'is-active' : '' %>">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <span>Vorlagen</span>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path> <span class="nav-rail"></span>
</a>
</nav>
<!-- Right cluster: notifications + account -->
<div class="ml-auto flex items-center gap-1.5">
<!-- Notifications -->
<div class="relative">
<button id="notifBtn" type="button" data-dd="notifPanel" aria-expanded="false"
class="icon-btn" aria-label="Benachrichtigungen" title="Ungelesene E-Mails">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path>
</svg> </svg>
<span class="hidden sm:inline">Abmelden</span> <span id="notifBadge" class="dot-badge dot-badge--red hidden">0</span>
</a> </button>
<div id="notifPanel" class="dd-panel hidden w-80 max-w-[calc(100vw-2rem)] p-0">
<div class="sticky top-0 flex items-center justify-between rounded-t-xl border-b border-gray-100 bg-white px-4 py-2.5 dark:border-white/5 dark:bg-slate-800">
<span class="text-sm font-semibold text-gray-800 dark:text-white">Benachrichtigungen</span>
<button id="notifMarkAll" type="button" class="hidden text-xs font-medium text-blue-600 hover:underline dark:text-sky-400">Alle gelesen</button>
</div>
<ul id="notifList" class="max-h-96 divide-y divide-gray-100 overflow-y-auto dark:divide-white/5"></ul>
<div id="notifEmpty" class="px-4 py-10 text-center text-sm text-gray-400 dark:text-slate-500">
Keine ungelesenen Nachrichten
</div>
</div>
</div>
<% if (angemeldet) { %>
<!-- Account -->
<div class="relative">
<button type="button" data-dd="ddUser" aria-expanded="false" aria-haspopup="true"
class="flex items-center gap-2 rounded-lg py-1.5 pl-1.5 pr-2 text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0b1220]">
<span class="grid h-7 w-7 place-items-center rounded-full bg-white/10 text-[11px] font-semibold text-white ring-1 ring-white/15">
<%= initiale %>
</span>
<span class="hidden max-w-[10rem] truncate font-medium md:inline"><%= user.username %></span>
<svg class="dd-chevron hidden h-3.5 w-3.5 text-slate-500 transition-transform duration-200 md:block" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div id="ddUser" class="dd-panel hidden w-60">
<div class="border-b border-gray-100 px-3 pb-2.5 pt-1 dark:border-white/5">
<p class="truncate text-sm font-semibold text-gray-800 dark:text-white"><%= user.username %></p>
<p class="text-xs text-gray-500 dark:text-slate-400">
<%= user.is_admin ? 'Administrator' : 'Benutzer' %>
</p>
</div>
<div class="pt-1">
<a href="/einstellungen" class="dd-item <%= istEinstellungen ? 'is-active' : '' %>">
<svg class="dd-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span class="dd-title">Einstellungen</span>
</a>
<% if (user.is_admin) { %>
<a href="/admin" class="dd-item <%= istAdmin ? 'is-active' : '' %>">
<svg class="dd-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-2a4 4 0 100-8 4 4 0 000 8z"></path>
</svg>
<span class="dd-title">Benutzer verwalten</span>
</a>
<% } %>
</div>
<div class="mt-1 border-t border-gray-100 pt-1 dark:border-white/5">
<a href="/logout" class="dd-item dd-item--danger">
<svg class="dd-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
</svg>
<span class="dd-title">Abmelden</span>
</a>
</div>
</div>
</div> </div>
<% } %> <% } %>
<!-- Mobile menu -->
<button type="button" id="burgerBtn" aria-expanded="false" aria-controls="mobileNav"
class="icon-btn lg:hidden" aria-label="Menü">
<svg id="burgerOpen" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
<svg id="burgerClose" class="hidden h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Mobile navigation: the same structure, spelled out. The old header just
dropped the labels and left seven unlabelled icons. -->
<div id="mobileNav" class="hidden border-t border-white/5 bg-[#0b1220] lg:hidden">
<nav class="container mx-auto space-y-4 px-4 py-4" aria-label="Hauptnavigation (mobil)">
<div class="space-y-0.5">
<a href="/" class="m-item <%= istBewerbungen ? 'is-active' : '' %>">Bewerbungen</a>
<a href="/jobangebote" class="m-item <%= istAngebote ? 'is-active' : '' %>">
Jobangebote
<span class="js-badge-angebote nav-badge nav-badge--green ml-auto hidden">0</span>
</a>
<a href="/postfach" class="m-item <%= istPostfach ? 'is-active' : '' %>">
Postfach
<span class="js-badge-postfach nav-badge nav-badge--red ml-auto hidden">0</span>
</a>
<a href="/vorlagen" class="m-item <%= istVorlagen ? 'is-active' : '' %>">Vorlagen</a>
</div>
<div class="space-y-0.5">
<p class="m-label">Stellensuche</p>
<a href="/jobsuche" class="m-item <%= istSuchprofil ? 'is-active' : '' %>">Suchprofil &amp; Läufe</a>
<a href="/jobangebote/uebernommen" class="m-item <%= istUebernommen ? 'is-active' : '' %>">Übernommene Angebote</a>
<a href="/blacklist" class="m-item <%= istBlacklist ? 'is-active' : '' %>">Blacklist</a>
</div>
<% if (angemeldet) { %>
<div class="space-y-0.5">
<p class="m-label">Konto</p>
<p class="px-3 pb-1 text-sm text-white">
<%= user.username %>
<span class="text-slate-500">· <%= user.is_admin ? 'Administrator' : 'Benutzer' %></span>
</p>
<a href="/einstellungen" class="m-item <%= istEinstellungen ? 'is-active' : '' %>">Einstellungen</a>
<% if (user.is_admin) { %>
<a href="/admin" class="m-item <%= istAdmin ? 'is-active' : '' %>">Benutzer verwalten</a>
<% } %>
<a href="/logout" class="m-item text-rose-300 hover:bg-rose-500/10 hover:text-rose-200">Abmelden</a>
</div>
<% } %>
</nav>
</div>
<script> <script>
// Notification bell: unread received e-mails (new replies + orphan mail). // --- Dropdowns (Stellensuche, Konto, Benachrichtigungen) ---------------
// One controller for all of them: click toggles, opening one closes the
// others, Escape and outside-click close, aria-expanded stays truthful.
(function () {
var trigger = Array.prototype.slice.call(document.querySelectorAll('[data-dd]'));
function panelOf(btn) { return document.getElementById(btn.getAttribute('data-dd')); }
function close(btn) {
var p = panelOf(btn);
if (!p) return;
p.classList.add('hidden');
btn.setAttribute('aria-expanded', 'false');
var chev = btn.querySelector('.dd-chevron');
if (chev) chev.classList.remove('rotate-180');
}
function closeAll(ausser) {
trigger.forEach(function (b) { if (b !== ausser) close(b); });
}
trigger.forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
var p = panelOf(btn);
if (!p) return;
var offen = !p.classList.contains('hidden');
closeAll(btn);
if (offen) { close(btn); return; }
p.classList.remove('hidden');
btn.setAttribute('aria-expanded', 'true');
var chev = btn.querySelector('.dd-chevron');
if (chev) chev.classList.add('rotate-180');
btn.dispatchEvent(new CustomEvent('dd:open'));
});
});
document.addEventListener('click', function (e) {
trigger.forEach(function (btn) {
var p = panelOf(btn);
if (p && !p.classList.contains('hidden') && !p.contains(e.target) && !btn.contains(e.target)) {
close(btn);
}
});
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeAll(null);
});
// --- Mobile menu ---
var burger = document.getElementById('burgerBtn');
var mob = document.getElementById('mobileNav');
if (burger && mob) {
burger.addEventListener('click', function (e) {
e.stopPropagation();
var offen = mob.classList.toggle('hidden');
burger.setAttribute('aria-expanded', String(!offen));
document.getElementById('burgerOpen').classList.toggle('hidden', !offen);
document.getElementById('burgerClose').classList.toggle('hidden', offen);
});
}
// Subtle lift once the page is scrolled — the bar reads as floating
// above the content rather than welded to it.
var head = document.getElementById('siteHeader');
function onScroll() {
if (!head) return;
head.classList.toggle('shadow-xl', window.scrollY > 4);
head.classList.toggle('shadow-black/30', window.scrollY > 4);
}
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
})();
// --- Notification bell: unread received e-mails ------------------------
(function () { (function () {
var btn = document.getElementById('notifBtn'); var btn = document.getElementById('notifBtn');
var panel = document.getElementById('notifPanel');
var badge = document.getElementById('notifBadge'); var badge = document.getElementById('notifBadge');
var list = document.getElementById('notifList'); var list = document.getElementById('notifList');
var empty = document.getElementById('notifEmpty'); var empty = document.getElementById('notifEmpty');
var markAll = document.getElementById('notifMarkAll'); var markAll = document.getElementById('notifMarkAll');
if (!btn) return; if (!btn) return;
function esc(s) { var d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; } function esc(s) { var d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
function fmtDate(d) { function fmtDate(d) {
if (!d) return ''; if (!d) return '';
@@ -154,15 +351,15 @@
var li = document.createElement('li'); var li = document.createElement('li');
var a = document.createElement('a'); var a = document.createElement('a');
a.href = it.url; a.href = it.url;
a.className = 'block px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/60 transition-colors'; a.className = 'block px-4 py-3 transition-colors hover:bg-gray-50 dark:hover:bg-white/5';
a.innerHTML = a.innerHTML =
'<div class="flex items-center justify-between gap-2">' '<div class="flex items-center justify-between gap-2">'
+ '<span class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + esc(it.from) + '</span>' + '<span class="truncate text-sm font-medium text-gray-800 dark:text-gray-100">' + esc(it.from) + '</span>'
+ '<span class="text-[11px] text-gray-400 shrink-0">' + esc(fmtDate(it.date)) + '</span>' + '<span class="shrink-0 text-[11px] text-gray-400">' + esc(fmtDate(it.date)) + '</span>'
+ '</div>' + '</div>'
+ '<div class="text-sm text-gray-700 dark:text-gray-200 truncate">' + esc(it.subject) + '</div>' + '<div class="truncate text-sm text-gray-700 dark:text-gray-200">' + esc(it.subject) + '</div>'
+ (it.snippet ? '<div class="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">' + esc(it.snippet) + '</div>' : '') + (it.snippet ? '<div class="mt-0.5 truncate text-xs text-gray-500 dark:text-slate-400">' + esc(it.snippet) + '</div>' : '')
+ (it.kontext ? '<div class="text-[11px] text-blue-600 dark:text-blue-400 truncate mt-0.5">' + esc(it.kontext) + '</div>' : ''); + (it.kontext ? '<div class="mt-0.5 truncate text-[11px] text-blue-600 dark:text-sky-400">' + esc(it.kontext) + '</div>' : '');
li.appendChild(a); list.appendChild(li); li.appendChild(a); list.appendChild(li);
}); });
} }
@@ -170,16 +367,8 @@
fetch('/api/notifications', { cache: 'no-store' }) fetch('/api/notifications', { cache: 'no-store' })
.then(function (r) { return r.json(); }).then(render).catch(function () { /* ignore */ }); .then(function (r) { return r.json(); }).then(render).catch(function () { /* ignore */ });
} }
btn.addEventListener('click', function (e) {
e.stopPropagation(); btn.addEventListener('dd:open', load); // refresh whenever the panel opens
panel.classList.toggle('hidden');
if (!panel.classList.contains('hidden')) load();
});
document.addEventListener('click', function (e) {
if (!panel.classList.contains('hidden') && !panel.contains(e.target) && !btn.contains(e.target)) {
panel.classList.add('hidden');
}
});
markAll.addEventListener('click', function (e) { markAll.addEventListener('click', function (e) {
e.preventDefault(); e.stopPropagation(); e.preventDefault(); e.stopPropagation();
fetch('/api/emails/mark-all-read', { method: 'POST' }) fetch('/api/emails/mark-all-read', { method: 'POST' })
@@ -188,19 +377,22 @@
load(); load();
setInterval(load, 60000); setInterval(load, 60000);
})(); })();
// Populate the Postfach badge with the count of unlinked incoming e-mails.
// --- Counters: unlinked mail + open job offers -------------------------
// Class-based, because each count now shows in both the desktop bar and
// the mobile menu.
(function () { (function () {
fetch('/api/emails/unassigned-count').then(function (r) { return r.json(); }).then(function (d) { function setzen(selektor, count) {
var b = document.getElementById('unassignedBadge'); if (!(count > 0)) return;
if (b && d && d.count > 0) { b.textContent = d.count; b.classList.remove('hidden'); } document.querySelectorAll(selektor).forEach(function (el) {
}).catch(function () { /* ignore — badge stays hidden */ }); el.textContent = count > 99 ? '99+' : count;
})(); el.classList.remove('hidden');
// Jobangebote badge: count of open offers ingested via the REST API. });
(function () { }
fetch('/jobangebote/anzahl-offen').then(function (r) { return r.json(); }).then(function (d) { fetch('/api/emails/unassigned-count').then(function (r) { return r.json(); })
var b = document.getElementById('jobangeboteBadge'); .then(function (d) { setzen('.js-badge-postfach', d && d.count); }).catch(function () { /* ignore */ });
if (b && d && d.count > 0) { b.textContent = d.count; b.classList.remove('hidden'); } fetch('/jobangebote/anzahl-offen').then(function (r) { return r.json(); })
}).catch(function () { /* ignore */ }); .then(function (d) { setzen('.js-badge-angebote', d && d.count); }).catch(function () { /* ignore */ });
})(); })();
</script> </script>
</header> </header>