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

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