source: Klonkt/src/views/partials/bottom-tab.ejs@ fee35fa

main
Last change on this file since fee35fa was 441f5d6, checked in by roboburr <roboburr@…>, 3 months ago

Login navigation via htmx (audio no longer jumps when opening login)

Navigating TO the login page now goes via htmx instead of a full load → the
audio player stays alive (no jump). The login itself (form submit) deliberately
remains a full load (choice: only the navigate-to-login is smooth).

For this to work, on-auth also needed to work via htmx:

  • pcmsNav now applies ALL page-classes sent along (not just the first one), and on-auth is in PAGE_CLASSES → the login focus screen gets its styling via htmx.
  • body.on-auth .bottom-tab { display:none } → hide the tab from the previous page (which stays alive under htmx nav), just as clean as a full-load login.
  • Login links (bottom-tab + topnav) get hx-get.

CSS cache-buster v9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@…>

  • Property mode set to 100644
File size: 12.7 KB
RevLine 
[7bc636b]1<%
2// Bottom-tab navigation — mobile-only (hidden ≥768px via CSS).
3//
4// Tabs:
5// logged-out: Home · Zoek · Inloggen (3 tabs)
6// logged-in: Home · Zoek · [Plus] · [Prutter] · Profiel
7// The Plus tab is a FAB-style center action (accent bg, slightly raised).
8// It only appears for users with create-post permission.
9// Prutter only appears when the site has DMs enabled.
10
11const _siteUrlBase = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
12const _path = (typeof currentPath !== 'undefined' && currentPath) ? currentPath : '/';
[8afbdd6]13const _canPost = !!(user && (typeof canMutate === 'undefined' || canMutate)
14 && permissions && permissions.canCreatePost && permissions.canCreatePost(user, site));
[8aa85d0]15const _hasPrutter = !!(user && site && site.enable_prutter && (typeof tenancy !== 'undefined' && tenancy === 'hub') && (typeof premiumUnlocked === 'undefined' || premiumUnlocked));
[7bc636b]16const _ownProfile = user ? ('/users/' + user.username) : null;
[9915786]17const _isHub = (typeof tenancy !== 'undefined' && tenancy === 'hub');
18// Home gaat in hub-modus ALTIJD naar de hub-root (/), niet naar de sub-home van
19// een artiest. In solo/circle is _siteUrlBase leeg, dus dat blijft '/'.
20const _homeHref = (_isHub ? '' : _siteUrlBase) + '/';
[7bc636b]21
22// Strip site prefix for cleaner matching
23function _normalize(p) {
24 if (_siteUrlBase && p.indexOf(_siteUrlBase) === 0) return p.slice(_siteUrlBase.length) || '/';
25 return p;
26}
27const _p = _normalize(_path);
28
29let _active = null;
[9915786]30// In hub is Home alleen actief op de échte hub-root, niet op een artiest-sub-home.
31if (_isHub ? (_path === '/') : (_p === '/' || _p === '')) _active = 'home';
[7bc636b]32else if (_p.indexOf('/search') === 0 || _p.indexOf('/tag/') === 0 || _p.indexOf('/type/') === 0) _active = 'search';
33else if (_p === '/posts/new' || /^\/posts\/[^/]+\/edit$/.test(_p)) _active = 'create';
34else if (_p.indexOf('/prutter') === 0) _active = 'prutter';
35else if (_p.indexOf('/account') === 0 || (_ownProfile && _p.indexOf(_ownProfile) === 0)) _active = 'profile';
36else if (_p.indexOf('/auth/login') === 0) _active = 'login';
37%>
38
39<nav class="bottom-tab" aria-label="Hoofdnavigatie">
40
41 <!-- Home -->
[09ac0a5]42 <%# htmx-navigatie (geen full reload): de persistente audioplayer in document.body
43 overleeft dan, dus de muziek verspringt niet bij het navigeren. Op mobiel is de
44 top-balk toch verborgen, dus een context-reset via full-load is hier niet nodig. %>
[7bc636b]45 <a class="bottom-tab-item<%= _active === 'home' ? ' is-active' : '' %>"
[9915786]46 href="<%= _homeHref %>"
[09ac0a5]47 hx-get="<%= _homeHref %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
48 hx-push-url="<%= _homeHref %>" hx-indicator="#pcms-loading"
[7bc636b]49 aria-label="Home" <%= _active === 'home' ? 'aria-current="page"' : '' %>>
50 <svg class="bottom-tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
51 <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
52 <polyline points="9 22 9 12 15 12 15 22"/>
53 </svg>
54 <span class="bottom-tab-label">Home</span>
55 </a>
56
57 <!-- Zoek -->
58 <button type="button" class="bottom-tab-item<%= _active === 'search' ? ' is-active' : '' %>"
59 id="bottom-tab-search-toggle" aria-label="Zoeken">
60 <svg class="bottom-tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
61 <circle cx="11" cy="11" r="7"/>
62 <line x1="21" y1="21" x2="16.65" y2="16.65"/>
63 </svg>
64 <span class="bottom-tab-label">Zoek</span>
65 </button>
66
67 <!-- Plus (FAB-style, only when user can post) -->
68 <% if (_canPost) { %>
69 <a class="bottom-tab-item bottom-tab-fab<%= _active === 'create' ? ' is-active' : '' %>"
[b94c08e]70 href="<%= _siteUrlBase %>/posts/new"
71 hx-get="<%= _siteUrlBase %>/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
72 hx-push-url="<%= _siteUrlBase %>/posts/new" hx-indicator="#pcms-loading"
[7bc636b]73 aria-label="Nieuwe post">
74 <svg class="bottom-tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
75 <line x1="12" y1="5" x2="12" y2="19"/>
76 <line x1="5" y1="12" x2="19" y2="12"/>
77 </svg>
78 <span class="bottom-tab-label">Schrijven</span>
79 </a>
80 <% } %>
81
82 <!-- Prutter (DMs) -->
83 <% if (_hasPrutter) { %>
84 <a class="bottom-tab-item<%= _active === 'prutter' ? ' is-active' : '' %>"
85 href="<%= _siteUrlBase %>/prutter"
86 aria-label="Prutter berichten" <%= _active === 'prutter' ? 'aria-current="page"' : '' %>>
87 <svg class="bottom-tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
88 <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
89 </svg>
90 <span class="bottom-tab-label">Prutter</span>
91 <% if (typeof unreadDmCount !== 'undefined' && unreadDmCount > 0) { %>
92 <span class="bottom-tab-badge" aria-label="<%= unreadDmCount %> ongelezen"><%= unreadDmCount > 99 ? '99+' : unreadDmCount %></span>
93 <% } %>
94 </a>
95 <% } %>
96
97 <!-- Profiel / Inloggen -->
98 <% if (user) { %>
99 <button type="button"
100 class="bottom-tab-item<%= _active === 'profile' ? ' is-active' : '' %>"
101 data-profile-sheet-toggle
102 aria-label="Profiel menu" aria-haspopup="dialog">
103 <% if (user.avatar_url) { %>
104 <img class="bottom-tab-avatar bottom-tab-avatar-img" src="<%= user.avatar_url %>" alt="">
105 <% } else { %>
106 <span class="bottom-tab-avatar"><%= user.username.charAt(0).toUpperCase() %></span>
107 <% } %>
108 <span class="bottom-tab-label">Profiel</span>
109 </button>
110 <% } else { %>
111 <a class="bottom-tab-item<%= _active === 'login' ? ' is-active' : '' %>"
112 href="/auth/login"
[441f5d6]113 hx-get="/auth/login?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
114 hx-push-url="/auth/login" hx-indicator="#pcms-loading"
[7bc636b]115 aria-label="Inloggen">
116 <svg class="bottom-tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
117 <path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
118 <polyline points="10 17 15 12 10 7"/>
119 <line x1="15" y1="12" x2="3" y2="12"/>
120 </svg>
121 <span class="bottom-tab-label">Inloggen</span>
122 </a>
123 <% } %>
124
125</nav>
126
127<style>
128/* ============================================================
129 BOTTOM-TAB NAVIGATION (mobile-first)
130 - Visible by default (mobile)
131 - Hidden ≥768px (desktop uses .masthead instead)
132 - Sticks to bottom of viewport, respects safe-area-inset
133 ============================================================ */
134
135.bottom-tab {
136 position: fixed;
137 bottom: 0;
138 left: 0;
139 right: 0;
140 /* z-index 1050: above mini-player (1000), below open sheet (1100).
141 This makes the FAB (Schrijven knop) overlay the mini-player when
142 they overlap visually. */
143 z-index: 1050;
144 display: flex;
145 align-items: stretch;
146 justify-content: space-around;
147 background: color-mix(in srgb, var(--paper) 92%, transparent);
148 backdrop-filter: blur(16px) saturate(140%);
149 -webkit-backdrop-filter: blur(16px) saturate(140%);
150 border-top: 1px solid var(--rule);
151 padding-bottom: env(safe-area-inset-bottom, 0);
152 font-family: var(--font-ui);
153 /* Subtle shadow so the bar feels "lifted" above content */
154 box-shadow: 0 -2px 12px color-mix(in srgb, var(--ink) 5%, transparent);
155}
156
157.bottom-tab-item {
158 flex: 1 1 0;
159 min-height: 56px;
160 /* Tap target ≥44px guaranteed (icon-row 24 + label 12 + padding) */
161 display: flex;
162 flex-direction: column;
163 align-items: center;
164 justify-content: center;
165 gap: 2px;
166 padding: 6px 4px;
167 border: 0;
168 background: transparent;
169 color: var(--ink-muted);
170 text-decoration: none;
171 cursor: pointer;
172 font-family: inherit;
173 position: relative;
174 transition: color 120ms ease, transform 120ms ease;
175 -webkit-tap-highlight-color: transparent;
176}
177
178.bottom-tab-icon {
179 width: 24px;
180 height: 24px;
181 flex-shrink: 0;
182}
183
184.bottom-tab-label {
185 font-size: 10px;
186 font-weight: 500;
187 letter-spacing: 0.01em;
188 line-height: 1;
189 white-space: nowrap;
190}
191
192/* Pressed/active feedback — replaces hover for touch */
193.bottom-tab-item:active {
194 transform: scale(0.92);
195 color: var(--ink);
196}
197
198/* Selected tab */
199.bottom-tab-item.is-active {
200 color: var(--accent);
201}
202.bottom-tab-item.is-active::before {
203 content: '';
204 position: absolute;
205 top: 0;
206 left: 50%;
207 transform: translateX(-50%);
208 width: 28px;
209 height: 2px;
210 background: var(--accent);
211 border-radius: 0 0 2px 2px;
212}
213
214/* Hover only on devices that actually have it */
215@media (hover: hover) {
216 .bottom-tab-item:hover { color: var(--ink); }
217 .bottom-tab-item.is-active:hover { color: var(--accent); }
218}
219
220/* ── FAB-style Plus tab ───────────────────────────────
221 The FAB icon is taken OUT of the flex flow with position:absolute so it
222 cannot inflate the bar height or shift the label baseline. The label
223 stays in flex flow with a margin-top equal to (icon + gap) to mimic
224 a regular icon's contribution — that way center-alignment matches the
225 other tabs exactly. */
226.bottom-tab-fab {
227 position: relative;
228}
229.bottom-tab-fab .bottom-tab-icon {
230 position: absolute;
231 left: 50%;
232 top: -12px; /* overhang above the bar */
233 transform: translateX(-50%);
234 width: 44px;
235 height: 44px;
236 padding: 10px;
237 background: var(--accent);
238 color: #fff;
239 border-radius: 50%;
240 box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 35%, transparent);
241 transition: transform 120ms ease, box-shadow 120ms ease;
242 margin: 0; /* clear any inherited margin */
243}
244.bottom-tab-fab:active .bottom-tab-icon {
245 transform: translateX(-50%) scale(0.92);
246 box-shadow: 0 2px 6px color-mix(in srgb, var(--accent) 35%, transparent);
247}
248.bottom-tab-fab .bottom-tab-label {
249 /* Mimic the space a regular 24px icon + 2px gap would occupy, so the
250 label sits at the same vertical position as labels in sibling tabs. */
251 margin-top: 26px;
252 color: var(--ink-muted);
253}
254.bottom-tab-fab.is-active .bottom-tab-label { color: var(--accent); }
255.bottom-tab-fab.is-active::before { display: none; } /* FAB doesn't need top-bar indicator */
256
257/* ── Avatar in profile tab ──────────────────────────── */
258.bottom-tab-avatar {
259 display: inline-flex;
260 align-items: center;
261 justify-content: center;
262 width: 24px;
263 height: 24px;
264 border-radius: 50%;
265 background: var(--paper-2);
266 color: var(--ink-soft);
267 font-size: 11px;
268 font-weight: 600;
269 line-height: 1;
270 overflow: hidden;
271 object-fit: cover;
272}
273.bottom-tab-avatar-img {
274 /* When rendered as <img>, the same dimensions + object-fit give the
275 same circular crop as the letter span. */
276 display: block;
277}
278/* Active-state used to tint the avatar with --accent. With user-uploaded
279 avatars that override looks broken (orange wash over the photo). The
280 active state is already signalled by the label color, which is enough. */
281
282/* ── Unread badge ───────────────────────────────────── */
283.bottom-tab-badge {
284 position: absolute;
285 top: 4px;
286 left: calc(50% + 8px);
287 min-width: 18px;
288 height: 18px;
289 padding: 0 5px;
290 border-radius: 9px;
291 background: var(--accent);
292 color: #fff;
293 font-size: 10px;
294 font-weight: 700;
295 line-height: 18px;
296 text-align: center;
297 border: 2px solid var(--paper);
298 pointer-events: none;
299}
300
301/* ============================================================
302 HIDE on desktop — top-nav takes over
303 ============================================================ */
304@media (min-width: 768px) {
305 .bottom-tab { display: none; }
306}
307
308/* ============================================================
309 When bottom-tab is present, body needs bottom padding so
310 content doesn't sit underneath it. Applied via body class.
311 ============================================================ */
312@media (max-width: 767px) {
313 body.has-bottom-tab .pcms-main {
314 /* 56 (tab height) + safe-area + breathing room */
315 padding-bottom: calc(72px + env(safe-area-inset-bottom, 0));
316 }
317 /* The .has-audio-player override (extra padding for stacked bars) lives
318 in audio.css — keeps the audio-related rules together. */
319}
320</style>
321
322<script>
323// Wire the bottom-tab search button into the existing #search-overlay
324(function() {
325 const btn = document.getElementById('bottom-tab-search-toggle');
326 const overlay = document.getElementById('search-overlay');
327 if (btn && overlay) {
328 btn.addEventListener('click', function() {
329 overlay.hidden = false;
330 const inp = overlay.querySelector('input');
331 if (inp) inp.focus();
332 });
333 }
334})();
335</script>
Note: See TracBrowser for help on using the repository browser.