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

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

feat(meldingen): notifications — reply on comment, comment on post, like on post

For every logged-in user (Google visitors/fans and admin). Bell icon in the
header with unread counter + notifications page /notifications (via user menu
desktop + profile sheet mobile; badge also on the mobile Profile tab). Opening =
read. Triggers in comments (reply→comment author, top-level→post author) and
like (→post author); notify() skips notifying yourself. Table notifications
+ NotificationService. NL/EN/DE.

Co-Authored-By: Claude <noreply@…>

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