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

main
Last change on this file since 5b22ea9 was 7bc636b, checked in by Robin <robin@…>, 4 months ago

Initial commit — PrutFolio v1 source (pulled from Hetzner /srv/prutfolio)

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