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

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

fix: all site-scoped links use siteUrlBase in hub mode

Follow-up on the feed fix (612b102). The same class of bug was still present in:

  • post.ejs: tag, edit, delete links + comment forms + comment delete + login-next -> now with siteUrlBase prefix
  • bottom-tab.ejs: "Write" FAB (/posts/new)
  • home.ejs: empty-state "Write your first post"
  • profile-sheet.ejs: "New post"
  • search.ejs: search form action

Without the prefix these fell back to the primary site in hub (404 /
wrong site). In solo siteUrlBase is empty -> behavior unchanged.

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

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