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

main
Last change on this file since dbde7f8 was 09ac0a5, checked in by roboburr <roboburr@…>, 3 months ago

Hub audio: mobile Home back on htmx (persistent player survives → no jumping)

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

  • Property mode set to 100644
File size: 12.6 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"
113 aria-label="Inloggen">
114 <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">
115 <path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
116 <polyline points="10 17 15 12 10 7"/>
117 <line x1="15" y1="12" x2="3" y2="12"/>
118 </svg>
119 <span class="bottom-tab-label">Inloggen</span>
120 </a>
121 <% } %>
122
123</nav>
124
125<style>
126/* ============================================================
127 BOTTOM-TAB NAVIGATION (mobile-first)
128 - Visible by default (mobile)
129 - Hidden ≥768px (desktop uses .masthead instead)
130 - Sticks to bottom of viewport, respects safe-area-inset
131 ============================================================ */
132
133.bottom-tab {
134 position: fixed;
135 bottom: 0;
136 left: 0;
137 right: 0;
138 /* z-index 1050: above mini-player (1000), below open sheet (1100).
139 This makes the FAB (Schrijven knop) overlay the mini-player when
140 they overlap visually. */
141 z-index: 1050;
142 display: flex;
143 align-items: stretch;
144 justify-content: space-around;
145 background: color-mix(in srgb, var(--paper) 92%, transparent);
146 backdrop-filter: blur(16px) saturate(140%);
147 -webkit-backdrop-filter: blur(16px) saturate(140%);
148 border-top: 1px solid var(--rule);
149 padding-bottom: env(safe-area-inset-bottom, 0);
150 font-family: var(--font-ui);
151 /* Subtle shadow so the bar feels "lifted" above content */
152 box-shadow: 0 -2px 12px color-mix(in srgb, var(--ink) 5%, transparent);
153}
154
155.bottom-tab-item {
156 flex: 1 1 0;
157 min-height: 56px;
158 /* Tap target ≥44px guaranteed (icon-row 24 + label 12 + padding) */
159 display: flex;
160 flex-direction: column;
161 align-items: center;
162 justify-content: center;
163 gap: 2px;
164 padding: 6px 4px;
165 border: 0;
166 background: transparent;
167 color: var(--ink-muted);
168 text-decoration: none;
169 cursor: pointer;
170 font-family: inherit;
171 position: relative;
172 transition: color 120ms ease, transform 120ms ease;
173 -webkit-tap-highlight-color: transparent;
174}
175
176.bottom-tab-icon {
177 width: 24px;
178 height: 24px;
179 flex-shrink: 0;
180}
181
182.bottom-tab-label {
183 font-size: 10px;
184 font-weight: 500;
185 letter-spacing: 0.01em;
186 line-height: 1;
187 white-space: nowrap;
188}
189
190/* Pressed/active feedback — replaces hover for touch */
191.bottom-tab-item:active {
192 transform: scale(0.92);
193 color: var(--ink);
194}
195
196/* Selected tab */
197.bottom-tab-item.is-active {
198 color: var(--accent);
199}
200.bottom-tab-item.is-active::before {
201 content: '';
202 position: absolute;
203 top: 0;
204 left: 50%;
205 transform: translateX(-50%);
206 width: 28px;
207 height: 2px;
208 background: var(--accent);
209 border-radius: 0 0 2px 2px;
210}
211
212/* Hover only on devices that actually have it */
213@media (hover: hover) {
214 .bottom-tab-item:hover { color: var(--ink); }
215 .bottom-tab-item.is-active:hover { color: var(--accent); }
216}
217
218/* ── FAB-style Plus tab ───────────────────────────────
219 The FAB icon is taken OUT of the flex flow with position:absolute so it
220 cannot inflate the bar height or shift the label baseline. The label
221 stays in flex flow with a margin-top equal to (icon + gap) to mimic
222 a regular icon's contribution — that way center-alignment matches the
223 other tabs exactly. */
224.bottom-tab-fab {
225 position: relative;
226}
227.bottom-tab-fab .bottom-tab-icon {
228 position: absolute;
229 left: 50%;
230 top: -12px; /* overhang above the bar */
231 transform: translateX(-50%);
232 width: 44px;
233 height: 44px;
234 padding: 10px;
235 background: var(--accent);
236 color: #fff;
237 border-radius: 50%;
238 box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 35%, transparent);
239 transition: transform 120ms ease, box-shadow 120ms ease;
240 margin: 0; /* clear any inherited margin */
241}
242.bottom-tab-fab:active .bottom-tab-icon {
243 transform: translateX(-50%) scale(0.92);
244 box-shadow: 0 2px 6px color-mix(in srgb, var(--accent) 35%, transparent);
245}
246.bottom-tab-fab .bottom-tab-label {
247 /* Mimic the space a regular 24px icon + 2px gap would occupy, so the
248 label sits at the same vertical position as labels in sibling tabs. */
249 margin-top: 26px;
250 color: var(--ink-muted);
251}
252.bottom-tab-fab.is-active .bottom-tab-label { color: var(--accent); }
253.bottom-tab-fab.is-active::before { display: none; } /* FAB doesn't need top-bar indicator */
254
255/* ── Avatar in profile tab ──────────────────────────── */
256.bottom-tab-avatar {
257 display: inline-flex;
258 align-items: center;
259 justify-content: center;
260 width: 24px;
261 height: 24px;
262 border-radius: 50%;
263 background: var(--paper-2);
264 color: var(--ink-soft);
265 font-size: 11px;
266 font-weight: 600;
267 line-height: 1;
268 overflow: hidden;
269 object-fit: cover;
270}
271.bottom-tab-avatar-img {
272 /* When rendered as <img>, the same dimensions + object-fit give the
273 same circular crop as the letter span. */
274 display: block;
275}
276/* Active-state used to tint the avatar with --accent. With user-uploaded
277 avatars that override looks broken (orange wash over the photo). The
278 active state is already signalled by the label color, which is enough. */
279
280/* ── Unread badge ───────────────────────────────────── */
281.bottom-tab-badge {
282 position: absolute;
283 top: 4px;
284 left: calc(50% + 8px);
285 min-width: 18px;
286 height: 18px;
287 padding: 0 5px;
288 border-radius: 9px;
289 background: var(--accent);
290 color: #fff;
291 font-size: 10px;
292 font-weight: 700;
293 line-height: 18px;
294 text-align: center;
295 border: 2px solid var(--paper);
296 pointer-events: none;
297}
298
299/* ============================================================
300 HIDE on desktop — top-nav takes over
301 ============================================================ */
302@media (min-width: 768px) {
303 .bottom-tab { display: none; }
304}
305
306/* ============================================================
307 When bottom-tab is present, body needs bottom padding so
308 content doesn't sit underneath it. Applied via body class.
309 ============================================================ */
310@media (max-width: 767px) {
311 body.has-bottom-tab .pcms-main {
312 /* 56 (tab height) + safe-area + breathing room */
313 padding-bottom: calc(72px + env(safe-area-inset-bottom, 0));
314 }
315 /* The .has-audio-player override (extra padding for stacked bars) lives
316 in audio.css — keeps the audio-related rules together. */
317}
318</style>
319
320<script>
321// Wire the bottom-tab search button into the existing #search-overlay
322(function() {
323 const btn = document.getElementById('bottom-tab-search-toggle');
324 const overlay = document.getElementById('search-overlay');
325 if (btn && overlay) {
326 btn.addEventListener('click', function() {
327 overlay.hidden = false;
328 const inp = overlay.querySelector('input');
329 if (inp) inp.focus();
330 });
331 }
332})();
333</script>
Note: See TracBrowser for help on using the repository browser.