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

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

Circles: Circle nav link (desktop + mobile) when tenancy=circle

Without a menu entry /cirkel was unreachable in the UI — now a 'Circle' link
next to Archive (desktop) + in the bottom tab (mobile), only in circle mode.

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

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