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

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

feat(images): route all remaining avatars through avatar() (downscaled)

Wrap every avatar/profile-photo <img> src in avatar(): feed/comment/follow + nav, profile
header/sheet/bottom-tab, account, EPK hero, admin user list, site-edit preview. Local avatars
go through the thumb route, remote ones through the signed proxy. Covers (thumb()) untouched.

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