source: Klonkt/src/views/partials/profile-sheet.ejs@ 3f4049e

main
Last change on this file since 3f4049e 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: 15.9 KB
Line 
1<%
2// Profile bottom sheet — mobile-only.
3//
4// Triggered by tapping any element with [data-profile-sheet-toggle]
5// (the Profiel tab in bottom-tab.ejs sets this).
6//
7// UX matches .audio-sheet for consistency: slide-up from bottom,
8// drag-to-dismiss, blur backdrop, safe-area-aware. Hidden ≥768px
9// (desktop uses the user dropdown in top-nav).
10//
11// Extension point: add more <li> blocks in any of the menu groups.
12
13if (!user) return;
14
15const _isAdmin = (user.role === 'god' || user.role === 'admin');
16const _canPost = !!((typeof canMutate === 'undefined' || canMutate)
17 && typeof permissions !== 'undefined'
18 && permissions
19 && permissions.canCreatePost
20 && permissions.canCreatePost(user, site));
21const _roleLabel = (user.role === 'god' || user.role === 'admin') ? 'beheerder'
22 : (user.role === 'editor') ? 'redacteur'
23 : '';
24// Site-scoped link (post-aanmaken) heeft in hub de /user/<slug>-prefix nodig.
25const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
26%>
27
28<div class="profile-sheet" id="profile-sheet" aria-hidden="true" role="dialog" aria-label="Profiel menu">
29 <div class="profile-sheet-backdrop" id="profile-sheet-backdrop"></div>
30 <div class="profile-sheet-panel">
31
32 <div class="profile-sheet-drag-zone" id="profile-sheet-drag-zone">
33 <button type="button" class="profile-sheet-handle" id="profile-sheet-close" aria-label="Sluiten"></button>
34 </div>
35
36 <!-- Identity header -->
37 <div class="profile-sheet-header">
38 <% if (user.avatar_url) { %>
39 <img class="profile-sheet-avatar profile-sheet-avatar-img" src="<%= user.avatar_url %>" alt="" aria-hidden="true">
40 <% } else { %>
41 <div class="profile-sheet-avatar" aria-hidden="true"><%= user.username.charAt(0).toUpperCase() %></div>
42 <% } %>
43 <div class="profile-sheet-meta">
44 <div class="profile-sheet-name"><%= user.username %></div>
45 <% if (_roleLabel) { %><div class="profile-sheet-role"><%= _roleLabel %></div><% } %>
46 </div>
47 </div>
48
49 <!-- Group 1: primary destinations -->
50 <ul class="profile-sheet-menu" role="menu">
51 <li role="none">
52 <a href="/account" class="profile-sheet-item" role="menuitem" data-close-sheet>
53 <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
54 <span class="psi-label">Account</span>
55 <svg class="psi-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
56 </a>
57 </li>
58 <li role="none">
59 <a href="/notifications" class="profile-sheet-item" role="menuitem" data-close-sheet>
60 <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
61 <span class="psi-label"><%= t('nav.notifications') %><% if (typeof notifUnread !== 'undefined' && notifUnread > 0) { %> (<%= notifUnread %>)<% } %></span>
62 <svg class="psi-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
63 </a>
64 </li>
65 <li role="none">
66 <a href="/favorieten" class="profile-sheet-item" role="menuitem" data-close-sheet
67 hx-get="/favorieten?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
68 hx-push-url="/favorieten" hx-indicator="#pcms-loading">
69 <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
70 <span class="psi-label">Favorieten</span>
71 <svg class="psi-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
72 </a>
73 </li>
74 <% if (typeof canSeeBeheer !== 'undefined' && canSeeBeheer) { %>
75 <li role="none">
76 <a href="/admin" class="profile-sheet-item" role="menuitem" data-close-sheet>
77 <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z"/></svg>
78 <span class="psi-label">Beheer</span>
79 <svg class="psi-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
80 </a>
81 </li>
82 <% } %>
83 <% if (_canPost) { %>
84 <li role="none">
85 <a href="<%= _base %>/posts/new" class="profile-sheet-item" role="menuitem" data-close-sheet
86 hx-get="<%= _base %>/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
87 hx-push-url="<%= _base %>/posts/new" hx-indicator="#pcms-loading">
88 <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
89 <span class="psi-label">Nieuwe post</span>
90 <svg class="psi-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
91 </a>
92 </li>
93 <% } %>
94 </ul>
95
96 <div class="profile-sheet-divider" role="separator"></div>
97
98 <!-- Group 2: preferences (inline toggles) -->
99 <ul class="profile-sheet-menu" role="menu">
100 <li role="none">
101 <button type="button" class="profile-sheet-item" id="profile-sheet-theme" role="menuitemcheckbox">
102 <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
103 <span class="psi-label">Thema</span>
104 <span class="psi-meta" id="profile-sheet-theme-state">Donker</span>
105 </button>
106 </li>
107 </ul>
108
109 <div class="profile-sheet-divider" role="separator"></div>
110
111 <!-- Group 3: destructive actions -->
112 <ul class="profile-sheet-menu" role="menu">
113 <li role="none">
114 <a href="/auth/logout" class="profile-sheet-item is-destructive" role="menuitem">
115 <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
116 <span class="psi-label">Uitloggen</span>
117 </a>
118 </li>
119 </ul>
120
121 </div>
122</div>
123
124<style>
125/* ============================================================
126 PROFILE SHEET — mobile bottom-sheet menu
127 Hidden ≥768px (desktop uses .user-menu dropdown in top-nav).
128 ============================================================ */
129
130.profile-sheet {
131 position: fixed;
132 inset: 0;
133 z-index: 1100; /* same as audio-sheet — modal layer */
134 pointer-events: none;
135 opacity: 0;
136 transition: opacity .2s ease;
137}
138.profile-sheet.is-open {
139 pointer-events: auto;
140 opacity: 1;
141}
142
143@media (min-width: 768px) {
144 /* Desktop has its own user-menu dropdown */
145 .profile-sheet { display: none !important; }
146}
147
148body.profile-sheet-locked { overflow: hidden; }
149
150.profile-sheet-backdrop {
151 position: absolute;
152 inset: 0;
153 background: color-mix(in srgb, var(--ink, #000) 35%, transparent);
154 backdrop-filter: blur(6px);
155 -webkit-backdrop-filter: blur(6px);
156}
157
158.profile-sheet-panel {
159 position: absolute;
160 left: 0; right: 0; bottom: 0;
161 background: var(--paper);
162 color: var(--ink);
163 border-top-left-radius: 22px;
164 border-top-right-radius: 22px;
165 box-shadow: 0 -20px 60px rgba(0, 0, 0, .25);
166 padding-bottom: max(.75rem, env(safe-area-inset-bottom, 0));
167 transform: translateY(100%);
168 transition: transform .3s cubic-bezier(.22, .9, .3, 1);
169 display: flex;
170 flex-direction: column;
171 max-height: 85vh;
172 overflow-y: auto;
173 overscroll-behavior: contain;
174}
175.profile-sheet.is-open .profile-sheet-panel {
176 transform: translateY(var(--pcms-drag-y, 0px));
177}
178.profile-sheet-panel.is-dragging { transition: none; will-change: transform; }
179
180[data-theme="dark"] .profile-sheet-panel {
181 box-shadow: 0 -20px 60px rgba(0, 0, 0, .5);
182}
183
184/* ── Drag handle zone ───────────────────────────── */
185.profile-sheet-drag-zone {
186 flex-shrink: 0;
187 padding: 12px 16px 4px;
188 /* MUST be 'none' so JS pointer events get the gesture (see audio-sheet) */
189 touch-action: none;
190 user-select: none;
191 -webkit-user-select: none;
192 cursor: grab;
193}
194.profile-sheet-drag-zone:active { cursor: grabbing; }
195@media (hover: hover) and (pointer: fine) {
196 /* On desktop the sheet isn't shown anyway, but be safe */
197 .profile-sheet-drag-zone { touch-action: auto; cursor: default; }
198}
199
200.profile-sheet-handle {
201 display: block;
202 margin: 0 auto;
203 width: 44px;
204 height: 4px;
205 border-radius: 2px;
206 background: color-mix(in srgb, var(--ink, #000) 25%, transparent);
207 border: 0;
208 padding: 0;
209 position: relative;
210}
211.profile-sheet-handle::before {
212 /* Larger invisible tap target on the handle itself */
213 content: "";
214 position: absolute;
215 inset: -16px -32px;
216}
217
218/* ── Identity header ────────────────────────────── */
219.profile-sheet-header {
220 display: flex;
221 align-items: center;
222 gap: 14px;
223 padding: 8px 20px 18px;
224}
225.profile-sheet-avatar {
226 width: 48px;
227 height: 48px;
228 display: flex;
229 align-items: center;
230 justify-content: center;
231 border-radius: 50%;
232 background: var(--paper-2); /* neutral so transparent avatars blend */
233 color: var(--accent); /* fallback initial color */
234 font-family: var(--font-ui);
235 font-weight: 700;
236 font-size: 20px;
237 flex-shrink: 0;
238 overflow: hidden;
239}
240.profile-sheet-avatar-img {
241 /* When rendered as <img>, fill the round container edge-to-edge. */
242 object-fit: cover;
243}
244.profile-sheet-meta { min-width: 0; }
245.profile-sheet-name {
246 font-family: var(--font-display, var(--font-ui));
247 font-size: 1.2rem;
248 font-weight: 600;
249 line-height: 1.2;
250 color: var(--ink);
251}
252.profile-sheet-role {
253 font-family: var(--font-ui);
254 font-size: .82rem;
255 color: var(--ink-soft);
256 margin-top: 3px;
257 text-transform: lowercase;
258 letter-spacing: .02em;
259}
260
261/* ── Menu list ──────────────────────────────────── */
262.profile-sheet-menu {
263 list-style: none;
264 margin: 0;
265 padding: 0;
266}
267.profile-sheet-divider {
268 height: 1px;
269 background: var(--rule);
270 margin: 4px 20px;
271}
272.profile-sheet-item {
273 display: flex;
274 align-items: center;
275 gap: 14px;
276 width: 100%;
277 min-height: 52px;
278 padding: 12px 20px;
279 background: transparent;
280 border: 0;
281 color: var(--ink);
282 text-decoration: none;
283 font-family: var(--font-ui);
284 font-size: 1rem;
285 text-align: left;
286 cursor: pointer;
287 -webkit-tap-highlight-color: transparent;
288 transition: background .12s ease, transform .12s ease;
289}
290.profile-sheet-item:active {
291 background: var(--paper-2);
292 transform: scale(0.99);
293}
294@media (hover: hover) {
295 .profile-sheet-item:hover { background: var(--paper-2); }
296}
297
298.psi-icon {
299 width: 22px;
300 height: 22px;
301 flex-shrink: 0;
302 color: var(--ink-soft);
303}
304.psi-label {
305 flex: 1;
306 min-width: 0;
307}
308.psi-chev {
309 width: 16px;
310 height: 16px;
311 color: var(--ink-muted);
312 flex-shrink: 0;
313}
314.psi-meta {
315 font-size: .85rem;
316 color: var(--ink-soft);
317 font-family: var(--font-ui);
318}
319
320.profile-sheet-item.is-destructive { color: #dc2626; }
321.profile-sheet-item.is-destructive .psi-icon { color: currentColor; }
322[data-theme="dark"] .profile-sheet-item.is-destructive { color: #f87171; }
323</style>
324
325<script>
326(function() {
327 const sheet = document.getElementById('profile-sheet');
328 if (!sheet) return;
329
330 const backdrop = document.getElementById('profile-sheet-backdrop');
331 const panel = sheet.querySelector('.profile-sheet-panel');
332 const closeBtn = document.getElementById('profile-sheet-close');
333 const dragZone = document.getElementById('profile-sheet-drag-zone');
334 const themeBtn = document.getElementById('profile-sheet-theme');
335 const themeLbl = document.getElementById('profile-sheet-theme-state');
336
337 function openSheet() {
338 sheet.classList.add('is-open');
339 sheet.setAttribute('aria-hidden', 'false');
340 document.body.classList.add('profile-sheet-locked');
341 syncTheme();
342 }
343 function closeSheet() {
344 sheet.classList.remove('is-open');
345 sheet.setAttribute('aria-hidden', 'true');
346 document.body.classList.remove('profile-sheet-locked');
347 panel.style.removeProperty('--pcms-drag-y');
348 }
349
350 // Open: any element with [data-profile-sheet-toggle]
351 document.addEventListener('click', function(e) {
352 const trigger = e.target.closest('[data-profile-sheet-toggle]');
353 if (trigger) {
354 e.preventDefault();
355 openSheet();
356 }
357 });
358
359 // Close: backdrop tap, handle tap, ESC, or any [data-close-sheet] item
360 if (backdrop) backdrop.addEventListener('click', closeSheet);
361 if (closeBtn) closeBtn.addEventListener('click', closeSheet);
362 document.addEventListener('keydown', (e) => {
363 if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
364 });
365
366 // Menu items that navigate: close synchronously before nav fires
367 sheet.querySelectorAll('[data-close-sheet]').forEach((el) => {
368 el.addEventListener('click', closeSheet);
369 });
370
371 // Drag-down-to-close on touch.
372 // Uses --pcms-drag-y custom property rather than overwriting the panel's
373 // transform string. This composes correctly with any horizontal centering
374 // (currently none here, but the pattern matches audio-sheet for safety).
375 let startY = 0, lastY = 0, dragging = false;
376 function onDown(e) {
377 if (e.pointerType !== 'touch') return;
378 if (panel.scrollTop > 0) return;
379 startY = lastY = e.clientY;
380 dragging = true;
381 panel.classList.add('is-dragging');
382 }
383 function onMove(e) {
384 if (!dragging) return;
385 lastY = e.clientY;
386 const dy = Math.max(0, lastY - startY);
387 panel.style.setProperty('--pcms-drag-y', dy + 'px');
388 }
389 function onUp() {
390 if (!dragging) return;
391 dragging = false;
392 panel.classList.remove('is-dragging');
393 const dy = lastY - startY;
394 if (dy > 80) closeSheet();
395 else panel.style.removeProperty('--pcms-drag-y');
396 }
397 if (window.PointerEvent && dragZone) {
398 dragZone.addEventListener('pointerdown', onDown);
399 document.addEventListener('pointermove', onMove);
400 document.addEventListener('pointerup', onUp);
401 document.addEventListener('pointercancel', onUp);
402 }
403
404 // Theme toggle inside sheet — syncs label
405 function syncTheme() {
406 if (!themeLbl) return;
407 const t = document.documentElement.getAttribute('data-theme') || 'dark';
408 themeLbl.textContent = t === 'dark' ? 'Donker' : 'Licht';
409 }
410 if (themeBtn) {
411 themeBtn.addEventListener('click', function() {
412 const cur = document.documentElement.getAttribute('data-theme') || 'dark';
413 const next = cur === 'dark' ? 'light' : 'dark';
414 document.documentElement.setAttribute('data-theme', next);
415 try { localStorage.setItem('pcms-theme', next); } catch (e) {}
416 syncTheme();
417 });
418 }
419})();
420</script>
Note: See TracBrowser for help on using the repository browser.