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() : '?'; +%> +