source: Klonkt/src/views/pages/account.ejs@ 746d98a

main
Last change on this file since 746d98a was 746d98a, checked in by Robin <roboburr@…>, 7 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: 18.5 KB
Line 
1<div class="container ax-page">
2
3 <header class="ax-header">
4 <a href="/" class="ax-back" aria-label="<%= t('acct.back_home') %>">←</a>
5 <div>
6 <h1><%= t('acct.title') %></h1>
7 <p class="ax-tagline"><%= t('acct.subtitle') %></p>
8 </div>
9 </header>
10
11 <% if (success) { %><div class="ax-flash ax-flash-ok"><%= success %></div><% } %>
12 <% if (error) { %><div class="ax-flash ax-flash-err"><%= error %></div><% } %>
13
14 <% if (isViewer) { %>
15 <div class="ax-viewer-note" role="note">
16 <span class="ax-viewer-ico" aria-hidden="true">👁️</span>
17 <div>
18 <strong><%= t('acct.viewer_mode') %></strong>
19 <span><%= t('acct.viewer_note') %></span>
20 </div>
21 </div>
22 <% } %>
23
24 <%# ── PROFILE ────────────────────────────────────────────── %>
25 <section class="ax-card">
26 <div class="ax-card-title"><%= t('acct.profile') %></div>
27
28 <div class="ax-profile-id">
29 <span class="ax-profile-avatar ax-profile-avatar-static">
30 <% var _av = (typeof siteAvatar !== 'undefined' ? siteAvatar : null); %>
31 <% if (_av) { %>
32 <img src="<%= avatar(_av, 128) %>" alt="">
33 <% } else { %>
34 <span><%= account.username.charAt(0).toUpperCase() %></span>
35 <% } %>
36 </span>
37 <div class="ax-profile-meta">
38 <div class="ax-profile-name"><%= account.username %></div>
39 <div class="ax-profile-role"><%= account.role %> · <%= account.email %></div>
40 <% if (account.created_at) { %>
41 <div class="ax-profile-joined"><%= t('acct.member_since') %> <%= formatDate(account.created_at) %></div>
42 <% } %>
43 <% if (canMutate) { %>
44 <div class="ax-profile-joined"><%- t('acct.photo_site_hint') %></div>
45 <% } %>
46 </div>
47 </div>
48
49 <% if (canMutate) { %>
50 <form action="/account/profile" method="post" class="ax-form ax-form-bio">
51 <label class="ax-field">
52 <span><%= t('acct.username') %></span>
53 <input type="text" name="username" value="<%= account.username %>" minlength="2" maxlength="30" pattern="[A-Za-z0-9_\-]{2,30}" autocomplete="username">
54 </label>
55 <label class="ax-field">
56 <span><%= t('acct.email') %></span>
57 <input type="email" name="email" value="<%= account.email || '' %>" maxlength="254" autocomplete="email" placeholder="<%= t('acct.email_ph') %>">
58 </label>
59 <label class="ax-field">
60 <span><%= t('acct.bio') %></span>
61 <textarea name="bio" rows="3" maxlength="500" placeholder="<%= t('acct.bio_ph') %>"><%= account.bio || '' %></textarea>
62 </label>
63 <div class="ax-form-actions">
64 <button type="submit" class="ax-btn ax-btn-primary"><%= t('acct.save') %></button>
65 </div>
66 </form>
67 <% } else { %>
68 <div class="ax-form ax-form-bio">
69 <label class="ax-field">
70 <span><%= t('acct.bio') %></span>
71 <textarea rows="3" disabled placeholder="<%= t('acct.bio_empty') %>"><%= account.bio || '' %></textarea>
72 </label>
73 </div>
74 <% } %>
75 </section>
76
77 <%# ── PERSONAL LANGUAGE ──────────────────────────────────── %>
78 <% if (canMutate) { %>
79 <section class="ax-card">
80 <div class="ax-card-title"><%= t('acct.lang_label') %></div>
81 <form action="/account/lang" method="post" class="ax-form">
82 <label class="ax-field">
83 <span><%= t('acct.lang_label') %> <small><%= t('acct.lang_hint') %></small></span>
84 <select name="lang">
85 <% langs.forEach(function(l){ var sel = (account && account.lang) ? (account.lang === l.code) : l.active; %>
86 <option value="<%= l.code %>"<%= sel ? ' selected' : '' %>><%= l.name %></option>
87 <% }); %>
88 </select>
89 </label>
90 <div class="ax-form-actions">
91 <button type="submit" class="ax-btn ax-btn-primary"><%= t('acct.save') %></button>
92 </div>
93 </form>
94 </section>
95 <% } %>
96
97 <%# ── SITE (owner may update their site name) ─────────── %>
98 <% if (typeof editableSite !== 'undefined' && editableSite && canMutate) { %>
99 <section class="ax-card">
100 <div class="ax-card-title"><%= t('acct.site') %></div>
101 <form action="/account/site" method="post" class="ax-form">
102 <label class="ax-field">
103 <span><%= t('acct.site_name') %> <small><%= t('acct.site_name_hint') %></small></span>
104 <input type="text" name="site_title" value="<%= editableSite.title || '' %>" maxlength="200" required>
105 </label>
106 <label class="ax-field">
107 <span><%= t('acct.tagline') %> <small><%= t('acct.tagline_hint') %></small></span>
108 <input type="text" name="site_tagline" value="<%= editableSite.tagline || '' %>" maxlength="200">
109 </label>
110 <div class="ax-form-actions">
111 <button type="submit" class="ax-btn ax-btn-primary"><%= t('acct.site_save') %></button>
112 </div>
113 </form>
114 </section>
115 <% } %>
116
117 <%# ── PROFIELLINKS ───────────────────────────────────────────
118 Sinds 30-9 hier, bij je profiel, en niet meer op de Appearance-pagina: ze
119 horen bij je naam, bio en foto. In de wardmodus is dat profiel het enige
120 dat een bezoeker van de site ziet. %>
121 <% if (typeof editableSite !== 'undefined' && editableSite && canMutate) { %>
122 <section class="ax-card" id="links">
123 <div class="ax-card-title"><%= t('asite.links') %></div>
124 <p class="form-hint"><%= t('asite.links_hint') %></p>
125 <form action="/account/links" method="post" class="ax-form">
126 <div id="profile-links-rows">
127 <% (profileLinks || []).forEach(function (l) { %>
128 <div class="profile-link-row">
129 <select name="profile_link_platform[]" aria-label="<%= t('acct.link_platform') %>">
130 <% platforms.forEach(function (p) { %>
131 <option value="<%= p.key %>" <%= l.platform === p.key ? 'selected' : '' %>><%= p.label %></option>
132 <% }); %>
133 </select>
134 <input type="text" name="profile_link_url[]" value="<%= l.url %>" placeholder="https://…" required aria-label="<%= t('acct.link_url') %>">
135 <button type="button" class="ax-btn pl-remove" aria-label="<%= t('acct.link_remove') %>">×</button>
136 </div>
137 <% }); %>
138 </div>
139 <template id="profile-link-template">
140 <div class="profile-link-row">
141 <select name="profile_link_platform[]" aria-label="<%= t('acct.link_platform') %>">
142 <% platforms.forEach(function (p) { %>
143 <option value="<%= p.key %>"><%= p.label %></option>
144 <% }); %>
145 </select>
146 <input type="text" name="profile_link_url[]" placeholder="https://…" required aria-label="<%= t('acct.link_url') %>">
147 <button type="button" class="ax-btn pl-remove" aria-label="<%= t('acct.link_remove') %>">×</button>
148 </div>
149 </template>
150 <div class="ax-form-actions">
151 <button type="button" id="profile-link-add" class="ax-btn"><%= t('asite.link_add') %></button>
152 <button type="submit" class="ax-btn ax-btn-primary"><%= t('acct.links_save') %></button>
153 </div>
154 </form>
155 </section>
156 <style>
157 .profile-link-row { display: grid; grid-template-columns: 1fr auto; gap: .5rem; margin-bottom: .5rem; }
158 .profile-link-row select { padding: .45rem .6rem; grid-column: 1 / -1; }
159 .profile-link-row input { min-width: 0; }
160 @media (min-width: 520px) {
161 .profile-link-row { grid-template-columns: 160px 1fr auto; }
162 .profile-link-row select { grid-column: auto; }
163 }
164 </style>
165 <% } %>
166
167 <%# ── PASSWORD ──────────────────────────────────────────── %>
168 <%# In viewer mode there is no password section (nothing to change). %>
169 <%# ── CONNECTED APPS (OAuth C2S) ─────────────────────────── %>
170 <section class="ax-card">
171 <div class="ax-card-title"><%= t('acct.oauth_apps') %></div>
172 <p class="ax-tagline" style="margin:0 0 .6rem"><%= t('acct.oauth_hint') %></p>
173 <% if (!authorizations || !authorizations.length) { %>
174 <p class="ax-oauth-empty"><%= t('acct.oauth_none') %></p>
175 <% } else { %>
176 <ul class="ax-oauth-list">
177 <% authorizations.forEach(function(a){ %>
178 <li class="ax-oauth-item">
179 <div class="ax-oauth-info">
180 <strong><%= a.client_name || t('acct.oauth_unknown_app') %></strong>
181 <span class="ax-oauth-meta">@<%= a.site_slug %> · <%= a.scope || 'c2s' %> · <%= t('acct.oauth_last_used') %> <%= a.last_used_at ? formatDate(a.last_used_at) : t('acct.oauth_never') %></span>
182 </div>
183 <form action="/account/oauth/revoke" method="post">
184 <input type="hidden" name="token_hash" value="<%= a.token_hash %>">
185 <button type="submit" class="ax-btn ax-btn-danger"><%= t('acct.oauth_revoke') %></button>
186 </form>
187 </li>
188 <% }); %>
189 </ul>
190 <% } %>
191 </section>
192
193 <% if (canMutate) { %>
194 <% if (hasPassword) { %>
195 <section class="ax-card">
196 <div class="ax-card-title"><%= t('acct.password_change') %></div>
197 <form action="/account/password" method="post" class="ax-form">
198 <label class="ax-field">
199 <span><%= t('acct.password_current') %></span>
200 <input type="password" name="current" required autocomplete="current-password">
201 </label>
202 <div class="ax-row ax-row-2">
203 <label class="ax-field">
204 <span><%= t('acct.password_new') %> <small><%= t('acct.password_min') %></small></span>
205 <input type="password" name="new_password" required minlength="8" autocomplete="new-password">
206 </label>
207 <label class="ax-field">
208 <span><%= t('acct.password_confirm') %></span>
209 <input type="password" name="confirm" required minlength="8" autocomplete="new-password">
210 </label>
211 </div>
212 <div class="ax-form-actions">
213 <button type="submit" class="ax-btn ax-btn-primary"><%= t('acct.password_change') %></button>
214 </div>
215 </form>
216 </section>
217
218 <% } else { %>
219 <section class="ax-card">
220 <div class="ax-card-title"><%= t('acct.login') %></div>
221 <p class="ax-tagline" style="margin:0"><%= t('acct.login_google_only', { email: account.email }) %></p>
222 </section>
223 <% } %>
224 <% } %>
225
226</div>
227
228<style>
229.ax-page { max-width: 720px; margin: 1.5rem auto 4rem; padding: 0 1rem; }
230.ax-header { display: flex; align-items: flex-start; gap: 0.75rem; margin-bottom: 1.5rem; }
231.ax-header h1 { font-family: var(--font-display, serif); font-size: 1.75rem; margin: 0 0 0.25rem; }
232.ax-tagline { color: var(--ink-muted, var(--ink-soft)); margin: 0; font-size: 0.9rem; }
233.ax-back {
234 display: inline-flex; align-items: center; justify-content: center;
235 width: 40px; height: 40px;
236 border: 1px solid var(--rule); border-radius: 8px;
237 background: var(--paper); color: var(--ink); text-decoration: none;
238 font-size: 1.1rem; flex-shrink: 0; transition: border-color 120ms;
239}
240.ax-back:hover { border-color: var(--accent); }
241
242.ax-flash { padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
243.ax-flash-ok { background: rgba(40,160,90,.15); color: #2a9d5e; border: 1px solid rgba(40,160,90,.3); }
244.ax-flash-err { background: rgba(200,60,60,.15); color: #c33; border: 1px solid rgba(200,60,60,.3); }
245
246/* ─── Kijker-modus notice ──────────────────────────────────────── */
247.ax-viewer-note {
248 display: flex; align-items: flex-start; gap: 0.75rem;
249 padding: 0.85rem 1rem; margin-bottom: 1rem;
250 border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--rule));
251 border-left: 3px solid var(--accent);
252 border-radius: 10px;
253 background: color-mix(in srgb, var(--accent) 8%, var(--paper));
254}
255.ax-viewer-ico { font-size: 1.1rem; line-height: 1.4; }
256.ax-viewer-note strong { display: block; font-size: 0.92rem; }
257.ax-viewer-note span { color: var(--ink-muted, var(--ink-soft)); font-size: 0.85rem; }
258.ax-profile-avatar-static { flex-shrink: 0; }
259.ax-form-bio textarea:disabled { opacity: 0.7; cursor: default; }
260
261/* ─── Cards ────────────────────────────────────────────────────── */
262.ax-card {
263 background: var(--paper);
264 border: 1px solid var(--rule);
265 border-radius: 12px;
266 padding: 1.25rem;
267 margin-bottom: 1rem;
268}
269.ax-card-title {
270 font-family: var(--font-display, serif);
271 font-size: 1.15rem;
272 font-weight: 600;
273 margin-bottom: 1rem;
274}
275
276/* ─── Form fields ──────────────────────────────────────────────── */
277.ax-form { display: flex; flex-direction: column; gap: 0.85rem; }
278.ax-form-bio { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--rule); }
279.ax-row { display: grid; gap: 0.85rem; }
280.ax-row-2 { grid-template-columns: 1fr 1fr; }
281@media (max-width: 600px) { .ax-row-2 { grid-template-columns: 1fr; } }
282
283.ax-field { display: flex; flex-direction: column; gap: 0.35rem; min-width: 0; }
284.ax-field > span {
285 font-size: 0.8rem; font-weight: 600;
286 color: var(--ink-soft);
287 display: flex; align-items: baseline; gap: 0.4rem; flex-wrap: wrap;
288}
289.ax-field > span small { font-weight: 400; color: var(--ink-muted); font-size: 0.95em; }
290.ax-field input[type="text"],
291.ax-field input[type="email"],
292.ax-field input[type="password"],
293.ax-field input[type="url"],
294.ax-field select,
295.ax-field textarea {
296 width: 100%;
297 box-sizing: border-box;
298 padding: 0.6rem 0.75rem;
299 border: 1px solid var(--rule);
300 border-radius: 6px;
301 background: var(--paper-2);
302 color: var(--ink);
303 font-family: var(--font-ui, system-ui), sans-serif;
304 font-size: 0.95rem;
305 -webkit-appearance: none; appearance: none;
306 transition: border-color 120ms;
307}
308.ax-field textarea { resize: vertical; min-height: 70px; font-family: inherit; }
309.ax-field input:focus, .ax-field select:focus, .ax-field textarea:focus {
310 outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent);
311}
312
313/* ─── File upload ─────────────────────────────────────────────── */
314.ax-file-control {
315 display: flex; align-items: center; gap: 0.5rem;
316 flex-wrap: wrap;
317 position: relative;
318 min-height: 40px;
319}
320.ax-file-control input[type="file"] {
321 position: absolute; inset: 0;
322 opacity: 0; cursor: pointer;
323 z-index: 2;
324}
325.ax-file-btn { pointer-events: none; }
326.ax-file-name {
327 font-size: 0.85rem;
328 color: var(--ink);
329 word-break: break-all;
330 flex: 1; min-width: 0;
331}
332.ax-file-name[data-empty] { color: var(--ink-muted, var(--ink-soft)); }
333
334.ax-form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.25rem; }
335
336/* ─── Buttons ──────────────────────────────────────────────────── */
337.ax-btn {
338 display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
339 padding: 0.55rem 1rem;
340 border: 1px solid var(--rule); background: var(--paper-2);
341 color: var(--ink);
342 font-family: var(--font-ui, system-ui), sans-serif;
343 font-size: 0.9rem; font-weight: 500;
344 text-decoration: none; border-radius: 6px;
345 cursor: pointer; min-height: 40px;
346 transition: background 120ms, border-color 120ms;
347 -webkit-tap-highlight-color: transparent;
348}
349.ax-btn:hover { border-color: var(--accent); }
350.ax-btn-secondary { background: var(--paper-2); }
351.ax-btn-primary, a.ax-btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
352.ax-btn-primary:hover, a.ax-btn-primary:hover { opacity: 0.92; border-color: var(--accent); color: #fff; }
353
354/* ─── Profile ID row ──────────────────────────────────────────── */
355.ax-profile-id {
356 display: flex; align-items: center; gap: 1rem;
357 padding-bottom: 1rem;
358 border-bottom: 1px solid var(--rule);
359 margin-bottom: 1rem;
360}
361.ax-avatar-form { margin: 0; flex-shrink: 0; }
362.ax-avatar-wrap {
363 position: relative; display: block;
364 width: 84px; height: 84px; cursor: pointer;
365}
366.ax-profile-avatar {
367 width: 84px; height: 84px;
368 border-radius: 50%;
369 overflow: hidden;
370 background: var(--paper-2); /* neutral so transparent avatars blend */
371 color: var(--accent); /* accent only for the fallback initial */
372 display: inline-flex; align-items: center; justify-content: center;
373 font-family: var(--font-display, serif);
374 font-size: 1.7rem; font-weight: 600;
375 border: 1px solid var(--rule);
376 transition: border-color 120ms;
377}
378.ax-avatar-wrap:hover .ax-profile-avatar { border-color: var(--accent); }
379.ax-profile-avatar img { width: 100%; height: 100%; object-fit: cover; }
380.ax-avatar-edit {
381 position: absolute; right: -2px; bottom: -2px;
382 width: 28px; height: 28px; border-radius: 50%;
383 background: var(--accent); color: #fff;
384 display: flex; align-items: center; justify-content: center;
385 border: 3px solid var(--paper);
386 transition: transform 120ms;
387}
388.ax-avatar-wrap:hover .ax-avatar-edit { transform: scale(1.08); }
389.ax-avatar-remove { margin-top: 0.45rem; }
390.ax-linkbtn {
391 background: none; border: 0; padding: 0; cursor: pointer;
392 color: var(--ink-muted, var(--ink-soft)); font-size: 0.8rem; text-decoration: underline;
393 font-family: var(--font-ui, system-ui), sans-serif;
394}
395.ax-linkbtn:hover { color: #c33; }
396.ax-profile-meta { min-width: 0; }
397.ax-profile-name { font-weight: 600; font-size: 1.1rem; }
398.ax-profile-role,
399.ax-profile-joined {
400 color: var(--ink-muted, var(--ink-soft));
401 font-size: 0.85rem;
402}
403
404@media (max-width: 480px) {
405 .ax-profile-id { flex-direction: column; align-items: flex-start; gap: 0.75rem; text-align: left; }
406}
407
408.ax-oauth-empty { margin: 0; color: var(--ink-soft, #888); }
409.ax-oauth-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .6rem; }
410.ax-oauth-item { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .7rem .85rem; border-radius: 12px; background: var(--paper-2, rgba(0,0,0,.04)); }
411.ax-oauth-info { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
412.ax-oauth-info strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
413.ax-oauth-meta { font-size: .8rem; color: var(--ink-soft, #888); }
414@media (max-width: 480px) {
415 .ax-oauth-item { flex-direction: column; align-items: stretch; }
416}
417</style>
Note: See TracBrowser for help on using the repository browser.