source: Klonkt/src/views/partials/topnav.ejs@ 74c5abc

main
Last change on this file since 74c5abc was a995698, checked in by Robin Genis <roboburr@…>, 2 months ago

fix(ui): hide Fediverse + Notifications nav when AP/federation is off

The fedi nav item + the notifications bell (topnav + mobile profile sheet) were gated only on
canManageFedi, so they stayed visible with federation switched off. Now also gated on apEnabled,
consistent with the rest of the fedi UI which already hides when off. (The profile-sheet
Notifications item was previously ungated entirely.)

  • Property mode set to 100644
File size: 29.1 KB
Line 
1<%
2// Top nav — RESPONSIVE.
3//
4// Mobile (<768px):
5// - Brand centered, small theme-toggle right.
6// - All other actions live in the bottom-tab nav.
7// - Sticky top, blur background, safe-area-top respected.
8//
9// Desktop (≥768px):
10// - Full v9 masthead: brand left, action row right
11// (Archief / Search / Install / Theme / User-menu).
12// - bottom-tab is hidden via CSS, so all nav must live here.
13
14const _siteUrlBase = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
15const _isAdmin = typeof bodyClass !== 'undefined' && bodyClass.indexOf('on-admin') >= 0;
16// Fediverse pages (on-special) get the same top-nav "back to site" treatment as admin.
17const _isSpecial = typeof bodyClass !== 'undefined' && bodyClass.indexOf('on-special') >= 0;
18const _hasSearch = !site || site.show_search === undefined || site.show_search;
19const _canPost = user && (typeof canMutate === 'undefined' || canMutate)
20 && permissions && permissions.canCreatePost && permissions.canCreatePost(user, site);
21%>
22
23<header class="masthead">
24 <div class="container masthead-inner">
25
26 <!-- Brand block (always visible) -->
27 <div class="masthead-brand">
28 <% if (_isAdmin || _isSpecial) { %>
29 <a href="<%= _siteUrlBase %>/" class="nav-link nav-link-admin"><%= t('nav.back_to_site') %></a>
30 <% } else if (typeof tenancy !== 'undefined' && tenancy === 'hub') { %>
31 <%# Hub: the brand navigates to the hub home via HTMX. The chrome is always
32 rendered in hub mode and hidden on the landing via body.on-hub (CSS), so htmx
33 can switch without a full reload → the audio player stays alive. %>
34 <a class="nav-hub-home" href="/" aria-label="Home" title="Home"
35 hx-get="/?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/" hx-indicator="#pcms-loading">
36 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
37 </a>
38 <% } else if (site) { %>
39 <a class="site-title" href="<%= _siteUrlBase %>/"
40 hx-get="<%= _siteUrlBase %>/?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
41 hx-push-url="<%= _siteUrlBase %>/" hx-indicator="#pcms-loading"><%= site.title %></a>
42 <% } else { %>
43 <a class="site-title" href="/">Klonkt</a>
44 <% } %>
45 </div>
46
47 <!-- Mobile: theme toggle ONLY for logged-out users.
48 Logged-in users get the toggle inside the profile sheet
49 (bottom-tab → Profiel → Thema). All other actions in bottom-tab. -->
50 <% if (!user) { %>
51 <div class="top-nav-mobile">
52 <button type="button" class="nav-btn" id="theme-toggle-mobile" aria-label="Thema wisselen" title="Thema wisselen">
53 <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
54 </button>
55 </div>
56 <% } %>
57
58 <!-- Desktop-only: full action row -->
59 <nav class="top-nav top-nav-desktop" aria-label="Hoofdnavigatie">
60
61 <% if (!_isAdmin && !_isSpecial) { %>
62 <a class="nav-link" href="<%= _siteUrlBase %>/archive"
63 hx-get="<%= _siteUrlBase %>/archive?partial=1" hx-target="#pcms-main"
64 hx-swap="innerHTML" hx-push-url="<%= _siteUrlBase %>/archive"
65 hx-indicator="#pcms-loading">
66 <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 8v13H3V8"/><rect x="1" y="3" width="22" height="5" rx="1"/><line x1="10" y1="12" x2="14" y2="12"/></svg>
67 <span class="nav-label"><%= t('nav.archive') %></span>
68 </a>
69 <% } %>
70
71 <% if (_hasSearch) { %>
72 <button type="button" class="nav-btn" id="search-toggle" aria-label="<%= t('nav.search') %>" title="<%= t('nav.search') %>">
73 <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
74 </button>
75 <% } %>
76
77 <button type="button" class="nav-btn" id="pwa-install-btn" hidden aria-label="App installeren" title="App installeren">
78 <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
79 </button>
80
81 <button type="button" class="nav-btn" id="theme-toggle" aria-label="Thema wisselen" title="Thema wisselen">
82 <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
83 </button>
84
85 <% if (typeof langs !== 'undefined' && langs && langs.length > 1) {
86 var _curLang = (langs.find(function(l){ return l.active; }) || { code: 'nl' }).code;
87 var _langR = (typeof currentPath !== 'undefined' && currentPath) ? currentPath : '/';
88 %>
89 <details class="lang-menu">
90 <summary class="nav-btn lang-toggle" aria-label="<%= t('nav.language') %>" title="<%= t('nav.language') %>">
91 <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
92 <span class="lang-code"><%= _curLang.toUpperCase() %></span>
93 <svg class="lang-caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
94 </summary>
95 <div class="lang-dropdown" role="menu">
96 <% langs.forEach(function(l){ %>
97 <a class="lang-item<%= l.active ? ' is-active' : '' %>" role="menuitem" href="/lang/<%= l.code %>?r=<%= encodeURIComponent(_langR) %>"><span class="lang-item-code"><%= l.code.toUpperCase() %></span><%= l.name %></a>
98 <% }); %>
99 </div>
100 </details>
101 <% } %>
102
103 <% if (user && typeof canManageFedi !== 'undefined' && canManageFedi && apEnabled) { %>
104 <a class="nav-btn nav-notif" href="/notifications" aria-label="<%= t('notif.title') %>" title="<%= t('notif.title') %>"
105 hx-get="/notifications?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/notifications" hx-indicator="#pcms-loading">
106 <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
107 <% if (typeof notifUnread !== 'undefined' && notifUnread > 0) { %><span class="notif-badge"><%= notifUnread > 9 ? '9+' : notifUnread %></span><% } %>
108 </a>
109 <% } %>
110
111 <% if (user) { %>
112 <details class="user-menu">
113 <summary class="nav-btn user-toggle" aria-label="Gebruikersmenu">
114 <% if (user.avatar_url) { %>
115 <img class="user-avatar user-avatar-img" src="<%= user.avatar_url %>" alt="">
116 <% } else { %>
117 <span class="user-avatar"><%= user.username.charAt(0).toUpperCase() %></span>
118 <% } %>
119 </summary>
120 <div class="user-dropdown" role="menu">
121 <div class="user-dropdown-header">
122 <% if (user.avatar_url) { %>
123 <img class="udh-avatar" src="<%= user.avatar_url %>" alt="">
124 <% } else { %>
125 <span class="udh-avatar udh-avatar-fallback"><%= user.username.charAt(0).toUpperCase() %></span>
126 <% } %>
127 <div class="udh-meta">
128 <span class="udh-name"><%= user.username %></span>
129 <% if (user.role === 'god') { %>
130 <span class="udh-role">God</span>
131 <% } else if (user.role === 'admin' || user.role === 'moderator') { %>
132 <span class="udh-role"><%= user.role.charAt(0).toUpperCase() + user.role.slice(1) %></span>
133 <% } %>
134 </div>
135 </div>
136 <div class="user-dropdown-list">
137 <a href="/account" role="menuitem" class="udi"
138 hx-get="/account?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
139 hx-push-url="/account" hx-indicator="#pcms-loading">
140 <svg class="udi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
141 <span><%= t('nav.account') %></span>
142 </a>
143 <% if (typeof canSeeBeheer !== 'undefined' && canSeeBeheer) { %>
144 <a href="/admin" role="menuitem" class="udi"
145 hx-get="/admin?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
146 hx-push-url="/admin" hx-indicator="#pcms-loading">
147 <svg class="udi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3h7v7H3z"/><path d="M14 3h7v7h-7z"/><path d="M14 14h7v7h-7z"/><path d="M3 14h7v7H3z"/></svg>
148 <span><%= t('nav.admin') %></span>
149 </a>
150 <% } %>
151 <% if (_canPost) { %>
152 <a href="<%= _siteUrlBase %>/posts/new" role="menuitem" class="udi"
153 hx-get="<%= _siteUrlBase %>/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
154 hx-push-url="<%= _siteUrlBase %>/posts/new" hx-indicator="#pcms-loading">
155 <svg class="udi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
156 <span><%= t('nav.new_post') %></span>
157 </a>
158 <% } %>
159 <% if (typeof canManageFedi !== 'undefined' && canManageFedi && apEnabled) { %>
160 <a href="/news" role="menuitem" class="udi"
161 hx-get="/news?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
162 hx-push-url="/news" hx-indicator="#pcms-loading">
163 <svg class="udi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
164 <span><%= t('nav.fediverse') %></span>
165 </a>
166 <% } %>
167 </div>
168 <div class="user-dropdown-divider" aria-hidden="true"></div>
169 <div class="user-dropdown-list">
170 <a href="/auth/logout" role="menuitem" class="udi udi-danger">
171 <svg class="udi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
172 <span><%= t('nav.logout') %></span>
173 </a>
174 </div>
175 </div>
176 </details>
177 <% } else { %>
178 <a class="nav-link" href="/auth/login"
179 hx-get="/auth/login?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
180 hx-push-url="/auth/login" hx-indicator="#pcms-loading">
181 <span class="nav-label"><%= t('nav.login') %></span>
182 </a>
183 <% } %>
184
185 </nav>
186 </div>
187</header>
188
189<!-- Search overlay (shared by mobile bottom-tab + desktop top-nav) -->
190<div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search">
191 <div class="container">
192 <form class="search-overlay-form" method="get" action="<%= _siteUrlBase %>/search">
193 <input type="search" name="q" placeholder="<%= t('search.placeholder') %>" autocomplete="off">
194 <button type="submit" class="nav-btn" aria-label="<%= t('search.button') %>">
195 <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
196 </button>
197 <button type="button" class="nav-btn" id="search-close" aria-label="<%= t('imed.cancel') %>">
198 <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
199 </button>
200 </form>
201 <div id="search-suggest" class="search-suggest" data-lenis-prevent></div>
202 </div>
203</div>
204
205<script>
206(function() {
207 // Wired ONCE. This chrome (topnav) is re-inserted out-of-band during htmx navigation
208 // → without this guard the script would stack EXTRA listeners on every navigation,
209 // causing the theme toggle to fire 2× (or more) = no net change ("toggle stops working").
210 // Everything below uses event delegation on body/document, so it also works for
211 // buttons that appear after this run (OOB).
212 if (window.__pcmsChromeWired) return;
213 window.__pcmsChromeWired = true;
214
215 function toggleTheme() {
216 var cur = document.documentElement.getAttribute('data-theme') || 'dark';
217 var next = cur === 'dark' ? 'light' : 'dark';
218 document.documentElement.setAttribute('data-theme', next);
219 try { localStorage.setItem('pcms-theme', next); } catch (e) {}
220 }
221 function overlay() { return document.getElementById('search-overlay'); }
222 function openSearch() { var o = overlay(); if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); } }
223 function closeSearch() { var o = overlay(); if (o) { o.hidden = true; var b = document.getElementById('search-suggest'); if (b) b.innerHTML = ''; } }
224
225 document.body.addEventListener('click', function(e) {
226 if (e.target.closest('#theme-toggle, #theme-toggle-mobile, #theme-toggle-footer')) { toggleTheme(); return; }
227 if (e.target.closest('#search-toggle')) { openSearch(); return; }
228 if (e.target.closest('#search-close')) { closeSearch(); return; }
229 // Close open dropdowns (user menu + language picker) on click outside.
230 document.querySelectorAll('.user-menu[open], .lang-menu[open]').forEach(function(d) {
231 if (!d.contains(e.target)) d.removeAttribute('open');
232 });
233 });
234
235 document.addEventListener('keydown', function(e) {
236 if (e.key === 'Escape') { var o = overlay(); if (o && !o.hidden) closeSearch(); }
237 });
238
239 // ── Live results while typing ───────────────────────────────────
240 var _ST = {
241 posts: '<%= t('search.section_posts') %>', tracks: '<%= t('search.section_tracks') %>',
242 events: '<%= t('search.section_events') %>', pages: '<%= t('search.section_pages') %>',
243 all: '<%= t('search.suggest_all') %>', none: '<%= t('search.suggest_empty') %>'
244 };
245 function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
246 function renderSuggest(box, d, q, ov) {
247 var html = '';
248 function grp(title, items, fmt) {
249 if (!items || !items.length) return;
250 html += '<div class="ss-group"><div class="ss-title">' + esc(title) + '</div>' + items.map(fmt).join('') + '</div>';
251 }
252 grp(_ST.posts, d.posts, function(p){ return '<a class="ss-item" href="' + esc(p.url) + '">' + esc(p.title) + '</a>'; });
253 grp(_ST.tracks, d.tracks, function(tk){ var sub = tk.artist ? ' <span class="ss-sub">' + esc(tk.artist) + '</span>' : ''; return tk.url ? '<a class="ss-item" href="' + esc(tk.url) + '">' + esc(tk.title) + sub + '</a>' : '<span class="ss-item ss-noclick">' + esc(tk.title) + sub + '</span>'; });
254 grp(_ST.events, d.events, function(ev){ return '<a class="ss-item" href="' + esc(ev.url) + '">' + esc(ev.where || ev.when) + ' <span class="ss-sub">' + esc(ev.when) + '</span></a>'; });
255 grp(_ST.pages, d.pages, function(pg){ return '<a class="ss-item" href="' + esc(pg.url) + '">' + esc(pg.label) + '</a>'; });
256 var hasAny = (d.posts && d.posts.length) || (d.tracks && d.tracks.length) || (d.events && d.events.length) || (d.pages && d.pages.length);
257 if (!hasAny) { box.innerHTML = '<div class="ss-empty">' + esc(_ST.none) + '</div>'; return; }
258 var action = (ov && ov.getAttribute('data-action')) || '/search';
259 html += '<a class="ss-all" href="' + esc(action) + '?q=' + encodeURIComponent(q) + '">' + esc(_ST.all) + '</a>';
260 box.innerHTML = html;
261 }
262 var _sTimer;
263 document.addEventListener('input', function(e) {
264 var inp = e.target.closest && e.target.closest('#search-overlay input[name="q"]');
265 if (!inp) return;
266 var box = document.getElementById('search-suggest');
267 if (!box) return;
268 var q = inp.value.trim();
269 clearTimeout(_sTimer);
270 if (q.length < 2) { box.innerHTML = ''; return; }
271 _sTimer = setTimeout(function() {
272 var ov = document.getElementById('search-overlay');
273 var url = (ov && ov.getAttribute('data-suggest')) || '/search/suggest';
274 fetch(url + '?q=' + encodeURIComponent(q))
275 .then(function(r){ return r.ok ? r.json() : null; })
276 .then(function(d){ if (d) renderSuggest(box, d, q, ov); })
277 .catch(function(){});
278 }, 200);
279 });
280 // Click on a suggestion → close the overlay (the link/boost handles navigation).
281 document.addEventListener('click', function(e) {
282 if (e.target.closest && e.target.closest('#search-suggest a')) { var o = overlay(); if (o) o.hidden = true; }
283 });
284})();
285</script>
286
287<style>
288/* ============================================================
289 TOP-NAV — responsive
290 Mobile: brand centered, small theme btn right, NOTHING else.
291 Desktop (≥768px): full v9 layout — brand left, action row right.
292 ============================================================ */
293
294.masthead-inner {
295 /* Override v9's flex-end → space-between so brand is on the left */
296 justify-content: space-between !important;
297 gap: 1rem;
298}
299.masthead-brand .site-title,
300.masthead-brand .nav-link-admin {
301 display: inline-block;
302 vertical-align: middle;
303}
304
305/* Site title is suppressed everywhere — the profile-header carries the brand
306 on content pages, and admin pages have their own "← Terug naar site" link
307 (a different selector, .nav-link-admin, which stays visible). The masthead
308 becomes a thin utility strip with just theme-toggle / search / etc. */
309.masthead-brand .site-title {
310 display: none;
311}
312
313/* Hub-home brand IS visible — the home button on artist/hub pages. */
314.masthead-brand .nav-hub-home {
315 display: inline-flex; align-items: center; justify-content: center;
316 width: 40px; height: 40px;
317 color: var(--ink); text-decoration: none;
318 border-radius: 8px;
319 transition: color 120ms, background 120ms;
320}
321.masthead-brand .nav-hub-home:hover { color: var(--accent); background: var(--paper-2); }
322.masthead-brand .nav-hub-home svg { width: 22px; height: 22px; }
323
324/* On mobile, hide the entire masthead except for admin pages. The brand
325 appears in the profile-header on content pages, and the bottom-tab
326 provides all navigation. Admin keeps it because the "← Terug naar site"
327 link is the only way back to the public site from /admin/*. */
328@media (max-width: 767px) {
329 body:not(.on-admin) .masthead { display: none; }
330 /* The masthead is gone on mobile, so it can't carry the iOS safe-area top inset
331 (env() resolves to 0 in the home-screen PWA; --ios-safe-top is set by JS in shell).
332 Pad the body instead so the profile-header clears the Dynamic Island / notch. */
333 body:not(.on-admin) { padding-top: var(--ios-safe-top, 0px); }
334}
335
336/* On mobile, center the brand in the masthead */
337@media (max-width: 767px) {
338 .masthead-inner {
339 justify-content: center !important;
340 position: relative;
341 }
342 .masthead-brand {
343 text-align: center;
344 }
345 /* Lone theme button for logged-out users — minimal, icon-only,
346 no border/background to avoid the iOS sticky-hover look. */
347 .top-nav-mobile {
348 position: absolute;
349 right: 0;
350 top: 50%;
351 transform: translateY(-50%);
352 }
353 .top-nav-mobile .nav-btn {
354 width: 44px;
355 height: 44px;
356 padding: 0;
357 background: transparent !important;
358 border-color: transparent !important;
359 color: var(--ink-soft);
360 display: inline-flex;
361 align-items: center;
362 justify-content: center;
363 }
364 .top-nav-mobile .nav-btn:active {
365 transform: scale(0.92);
366 color: var(--ink);
367 }
368 /* Suppress sticky hover on touch — only show feedback for real hover devices */
369 @media (hover: none) {
370 .top-nav-mobile .nav-btn:hover {
371 background: transparent !important;
372 border-color: transparent !important;
373 color: var(--ink-soft);
374 }
375 }
376 .top-nav-desktop {
377 display: none !important;
378 }
379}
380
381/* On desktop, hide the mobile-only theme button */
382@media (min-width: 768px) {
383 .top-nav-mobile { display: none; }
384}
385
386/* ── Admin "back" link styling (inherited from v9 patterns) ── */
387.nav-link-admin {
388 background: var(--accent);
389 color: #fff !important;
390 padding: .5rem .85rem;
391 border-radius: 6px;
392 text-decoration: none;
393 font-family: var(--font-ui);
394 font-size: .875rem;
395 font-weight: 500;
396}
397.nav-link-admin:hover { opacity: .9; color: #fff !important; }
398
399/* ── Tap-target enforcement for all nav buttons on touch ── */
400@media (hover: none) {
401 .nav-btn, .nav-link {
402 min-height: 44px;
403 min-width: 44px;
404 }
405 .nav-btn:active {
406 transform: scale(0.95);
407 background: var(--paper-2);
408 }
409}
410
411/* ── User dropdown (desktop only — mobile uses bottom-tab → /account) ── */
412.user-menu { position: relative; list-style: none; }
413.user-menu > summary {
414 list-style: none; cursor: pointer;
415 padding: .25rem; border-radius: 6px;
416 display: inline-flex; align-items: center;
417 border: 1px solid transparent;
418 transition: all var(--transition);
419}
420.user-menu > summary::-webkit-details-marker { display: none; }
421.user-menu > summary::marker { content: ''; }
422.user-menu > summary:hover { background: var(--paper-2); border-color: var(--rule); }
423.user-avatar {
424 display: inline-flex; align-items: center; justify-content: center;
425 width: 28px; height: 28px; border-radius: 50%;
426 background: var(--paper-2); /* neutral so transparent avatars blend */
427 color: var(--accent); /* fallback initial color */
428 font-family: var(--font-ui); font-weight: 600; font-size: .8rem;
429 pointer-events: none;
430 overflow: hidden;
431}
432.user-avatar-img {
433 /* When the pill renders as <img> instead of <span>, fill it edge-to-edge. */
434 object-fit: cover;
435}
436.user-dropdown {
437 position: absolute; top: calc(100% + .55rem); right: 0;
438 min-width: 220px;
439 padding: .35rem;
440 /* Frosted glass: transparent + heavy blur so the frosted effect is clearly visible. */
441 background: color-mix(in srgb, var(--paper) 60%, transparent);
442 backdrop-filter: blur(34px) saturate(190%);
443 -webkit-backdrop-filter: blur(34px) saturate(190%);
444 border: 1px solid var(--rule);
445 border-radius: 12px;
446 box-shadow:
447 0 1px 2px color-mix(in srgb, var(--ink) 8%, transparent),
448 0 8px 24px color-mix(in srgb, var(--ink) 14%, transparent),
449 0 16px 48px color-mix(in srgb, var(--ink) 10%, transparent);
450 z-index: 200;
451 font-family: var(--font-ui);
452 transform-origin: top right;
453}
454/* Open animation — only fires when <details open> matches, so closing the
455 dropdown doesn't replay the entry animation. */
456.user-menu[open] > .user-dropdown {
457 animation: ud-fade-in .14s cubic-bezier(.2, .8, .25, 1);
458}
459@keyframes ud-fade-in {
460 from { opacity: 0; transform: translateY(-6px) scale(.98); }
461 to { opacity: 1; transform: translateY(0) scale(1); }
462}
463
464/* Identity header — small avatar + username/role badge */
465.user-dropdown-header {
466 display: flex; align-items: center; gap: .65rem;
467 padding: .55rem .65rem .65rem;
468 border-bottom: 1px solid color-mix(in srgb, var(--rule) 65%, transparent);
469 margin-bottom: .35rem;
470}
471.udh-avatar {
472 width: 36px; height: 36px; border-radius: 50%;
473 object-fit: cover;
474 background: var(--paper-2);
475 display: inline-flex; align-items: center; justify-content: center;
476 flex-shrink: 0;
477}
478.udh-avatar-fallback {
479 color: var(--accent); font-weight: 700; font-size: .95rem;
480}
481.udh-meta { display: flex; flex-direction: column; min-width: 0; line-height: 1.2; }
482.udh-name {
483 font-weight: 600; font-size: .92rem; color: var(--ink);
484 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 140px;
485}
486.udh-role {
487 font-size: .68rem; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
488 color: var(--accent); opacity: .85; margin-top: .12rem;
489}
490
491/* Item list group */
492.user-dropdown-list { display: flex; flex-direction: column; gap: 1px; }
493.user-dropdown-divider {
494 height: 1px;
495 margin: .35rem .25rem;
496 background: color-mix(in srgb, var(--rule) 60%, transparent);
497}
498
499/* Items — icon + label, rounded inner highlight on hover (macOS feel) */
500.user-dropdown .udi {
501 display: flex; align-items: center; gap: .65rem;
502 padding: .5rem .65rem;
503 color: var(--ink); text-decoration: none;
504 font-size: .88rem; font-weight: 500;
505 border-radius: 7px;
506 transition: background var(--transition), color var(--transition), transform var(--transition);
507}
508.user-dropdown .udi:hover,
509.user-dropdown .udi:focus-visible {
510 background: color-mix(in srgb, var(--accent) 10%, var(--paper-2));
511 color: var(--accent);
512 outline: none;
513}
514.user-dropdown .udi:active { transform: scale(.98); }
515.user-dropdown .udi-icon {
516 width: 16px; height: 16px;
517 opacity: .7;
518 flex-shrink: 0;
519 transition: opacity var(--transition);
520}
521.user-dropdown .udi:hover .udi-icon,
522.user-dropdown .udi:focus-visible .udi-icon { opacity: 1; }
523
524/* Logout: subtle danger tint on hover, never on rest. Uses a fixed red so
525 it always reads as a destructive action even if the user's accent is red. */
526.user-dropdown .udi-danger:hover,
527.user-dropdown .udi-danger:focus-visible {
528 background: color-mix(in srgb, #dc2626 14%, var(--paper-2));
529 color: #dc2626;
530}
531
532/* ── Search overlay ── */
533.search-overlay {
534 position: fixed; top: 0; left: 0; right: 0;
535 background: color-mix(in srgb, var(--paper) 96%, transparent);
536 backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
537 border-bottom: 1px solid var(--rule);
538 padding: 1rem 0; z-index: 200;
539 padding-top: calc(1rem + env(safe-area-inset-top, 0));
540 box-shadow: 0 8px 24px color-mix(in srgb, var(--ink) 8%, transparent);
541}
542.search-overlay-form { display: flex; gap: .5rem; align-items: center; }
543.search-overlay-form input {
544 flex: 1; padding: .85rem 1rem;
545 min-height: 44px;
546 border: 1px solid var(--rule); border-radius: 8px;
547 font-family: var(--font-ui); font-size: 1rem;
548 background: var(--paper); color: var(--ink);
549}
550.search-overlay-form input:focus {
551 outline: 2px solid var(--accent); outline-offset: 0;
552}
553.search-overlay-form .nav-btn {
554 width: 44px; height: 44px;
555 display: inline-flex; align-items: center; justify-content: center;
556}
557/* Live-resultaten dropdown */
558.search-suggest { margin-top: .6rem; max-height: 60vh; overflow-y: auto; display: flex; flex-direction: column; gap: .25rem; }
559.search-suggest:empty { display: none; }
560.ss-group { padding: .25rem 0; }
561.ss-title { font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--ink-soft, var(--ink-muted)); padding: .35rem .25rem .2rem; }
562.ss-item { display: block; padding: .55rem .6rem; border-radius: 8px; color: var(--ink); text-decoration: none; font-size: .95rem; }
563.ss-item:hover { background: var(--paper-2); color: var(--accent); }
564.ss-item.ss-noclick { opacity: .7; cursor: default; }
565.ss-sub { color: var(--ink-soft, var(--ink-muted)); font-size: .85em; }
566.ss-empty { padding: .8rem .6rem; color: var(--ink-soft, var(--ink-muted)); font-size: .9rem; }
567.ss-all { display: block; margin-top: .35rem; padding: .55rem .6rem; border-top: 1px solid var(--rule); color: var(--accent); font-weight: 600; text-decoration: none; font-size: .9rem; }
568.ss-all:hover { text-decoration: underline; }
569/* Notification bell + unread badge */
570.nav-notif { position: relative; }
571.notif-badge {
572 position: absolute; top: 1px; right: 1px;
573 min-width: 16px; height: 16px; padding: 0 4px; box-sizing: border-box;
574 display: inline-flex; align-items: center; justify-content: center;
575 background: #e0245e; color: #fff; font-size: 10px; font-weight: 700;
576 border-radius: 999px; line-height: 1; pointer-events: none;
577}
578</style>
Note: See TracBrowser for help on using the repository browser.