From 1aa902bdce05e4e88e863c568c816fea28c5b247 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Mon, 13 Jul 2026 23:55:58 +0200 Subject: [PATCH] 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 --- public/css/styles.css | 227 ++++++++++++++++++ scripts/jobsuche-runner.js | 0 server.js | 2 + views/partials/header.ejs | 468 ++++++++++++++++++++++++++----------- 4 files changed, 559 insertions(+), 138 deletions(-) mode change 100644 => 100755 scripts/jobsuche-runner.js diff --git a/public/css/styles.css b/public/css/styles.css index 9d384fa..f1a7826 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -7,6 +7,233 @@ 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 */ html { scroll-behavior: smooth; diff --git a/scripts/jobsuche-runner.js b/scripts/jobsuche-runner.js old mode 100644 new mode 100755 diff --git a/server.js b/server.js index 41efbd4..7ab0791 100644 --- a/server.js +++ b/server.js @@ -111,6 +111,8 @@ app.use(async (req, res, next) => { const user = await loadSessionUser(cookies[SESSION_COOKIE]); req.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 // 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 diff --git a/views/partials/header.ejs b/views/partials/header.ejs index a9290e7..7e68c56 100644 --- a/views/partials/header.ejs +++ b/views/partials/header.ejs @@ -1,137 +1,334 @@ -
-
-
- - - - -

Bewerbungs-Tracker

+<% + // Current path (res.locals.pfad, set in the auth middleware) drives the active + // state. The old header had none — you could never tell which section you were + // in. Copied into a differently named local: re-declaring `pfad` here would + // shadow the EJS local under `with(locals)`. + var aktPfad = (typeof pfad !== 'undefined' && pfad) ? String(pfad) : '/'; + + // 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() : '?'; +%> +