source: Klonkt/src/views/partials/bottom-tab.ejs@ 746d98a

main
Last change on this file since 746d98a was 746d98a, checked in by Robin <roboburr@…>, 3 hours ago

Wardmodus: een besloten Klonkt met alleen het profiel openbaar

Robin, 30-9: "Ward Profile mode gaat actief wanneer ward gekoppeld is, dus
wanneer de persoon guardian(s) heeft. Een besloten Klonkt met enkel het profiel
van de ward publiek. Wij denken dat dit het beste valt bij emancipation om de
functie weer naar normaal Klonkt te brengen."

AFGELEID, NIET OPGESLAGEN. Heeft de site geaccepteerde guardians, dan staat de
modus aan; is de set leeg, dan uit. listGuardians telt alleen geaccepteerde
guardianships, dus een voorstel zet niets dicht, en de set leegmaken is
emancipatie (FEP-633c 3.4) -- precies waar het ontwerp de weg terug legt. Een
schakelaar zou een tweede waarheid naast de guardianship zijn.

STANDAARD DICHT (middleware/ward-profile.js, in server.js direct na de
siteresolver en de schrijfpoort). Een bezoeker ziet op / het profiel en verder
niets: een pagina gaat terug naar het profiel, al het andere is 404, een POST
bereikt zijn handler niet, en een htmx-navigatie krijgt HX-Redirect in plaats
van een brokstuk. Wat niet op de lijst staat komt er niet langs, zodat een
nieuwe route niet vanzelf een kind openzet.

Open blijft: inloggen, account, beheer, het guardianpaneel, /lang, de bestanden
van de pagina zelf, en /audio/stream -- die bewaakt elk bestand zelf, en een
track die de ward op de fediverse opende moet daar speelbaar blijven. De
federatie (ActivityPub, WebFinger, OAuth voor Shaer, /media, /assets, /og) hangt
al VOOR de poort; de paden staan toch op de lijst, voor als de volgorde ooit
omgaat, want dan zou een ward stil van de fediverse verdwijnen.

Wie alles ziet: de ward zelf, en god, kijker en sitebeheer. Een andere
ingelogde gebruiker is gewoon een bezoeker.

DE PROFIELPAGINA (pages/ward-profile.ejs) mag verzorgd, want dit is alles wat
een bezoeker ziet: foto met een ring in de accentkleur, de naam groot, de
handle op een pil, de bio, de links als brede knoppen in hun merkkleur, een
volgknop die de bestaande fediverse-dialoog opent (een volgverzoek aan een ward
gaat eerst langs de guardians), en een neutrale regel dat berichten alleen
voor volgers zijn. Nergens "kind" of "ward": dat de account guardians heeft
staat al openbaar op de fediverse. Dezelfde bronnen als de profielkop, zodat
het profiel op geen twee plekken iets anders kan zeggen. De chrome biedt een
bezoeker niets aan wat dicht is: geen zoekknop, geen archief, geen
Lezen/Grid-schakelaar.

DE PROFIELLINKS verhuizen van Appearance naar het account (/account#links),
bij naam, bio en foto. Twee vallen onderweg, allebei getoetst:

  • Appearance schreef profile_links altijd, uit de formuliervelden. Met de velden weg zou elke keer opslaan daar de links wissen. Die pagina schrijft ze niet meer.
  • ownedSite haalde profile_links niet op, dus de accountpagina toonde nul links en opslaan wiste ze. Gevonden bij het bekijken, niet door een toets; nu wel.

buildProfileLinks staat in PlatformIcons.js, en de rijen-knoppen in een eigen
module (mod/profile-links.js).

Getoetst: gewone Klonkt zonder guardians, een voorstel zet niets dicht,
gekoppeld alleen het profiel, pagina's terug, 404 voor wat geen pagina is, geen
handler voor een POST, HX-Redirect, wat open moet blijft open, ward en beheer
zien alles, een andere ingelogde is een bezoeker, emancipatie maakt het weer
gewoon, de links bij het account en Appearance wist ze niet. Tegenproef per
zaak. Volledige suite 1325 groen.

Nagekeken in de browser op 375 en 1280 pixels: het profiel past op een
telefoonscherm, knoppen van 55 pixels, geen overloop, de volgknop opent de
dialoog; de accountpagina toont de bestaande links en voegt er een toe.

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

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