source: Klonkt/src/views/pages/connect.ejs@ f926d13

main
Last change on this file since f926d13 was f926d13, checked in by Robin <roboburr@…>, 4 weeks ago

De volglijst als bestand uploaden, niet alleen plakken

Robins vraag bij de echte verhuizing: hij downloadt een CSV en moet hem dan met de
hand openen en de inhoud overplakken. Dat is een omweg die niemand hoort te lopen.

Nu een bestandskiezer naast het plakveld. Multer met memoryStorage, want dit is een
lijstje adressen van een paar kilobyte dat na het lezen niets te zoeken heeft op de
server; op schijf schrijven zou alleen rommel achterlaten. Een gekozen bestand wint
van het plakveld: wie een bestand aanwijst bedoelt dat.

De BOM gaat eraf. Excel zet die voor een CSV, en zonder dat eraf te halen plakt hij
aan het eerste adres vast en is de eerste regel stil onbruikbaar.

En twee redirects in deze route wezen nog naar /following. Dat is de oude pagina
waar niets meer naartoe linkt (zie 822d16d), dus je kwam na een import op een
scherm waar je zelf niet meer weg kon. Beide naar /connect.

Changed files:
src/routes/posts.js

  • multer memoryStorage, single('csvfile'), 512kb, 1 bestand
  • bestand wint van plakveld, BOM eraf
  • drie redirects van /following naar /connect

src/views/pages/connect.ejs

  • bestandskiezer met accept=".csv,text/csv,text/plain", formulier op multipart

src/services/i18n.js

  • tl.move_import_file in nl, en en de; het plakveld-label ingekort tot "Of plak de lijst hier"

test/following-csv.test.js

  • test 14: een echt multipart-verzoek met een BOM ervoor, plus de controle dat het plakveld ernaast blijft werken

remarks: 927 groen in UTC en Europe/Amsterdam. De uploadweg is met een echte
multipart-POST tegen een draaiende express+multer getest, niet alleen beredeneerd.
De pagina apart gerenderd: bestandskiezer en multipart-enctype aanwezig, geen
onvertaalde sleutels.

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

  • Property mode set to 100644
File size: 11.3 KB
Line 
1<div class="tl-wrap">
2 <%- include('../partials/fedi-tabs', { active: 'connect' }) %>
3 <h1 class="tl-title"><%= t('tl.connect') %></h1>
4 <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
5 <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
6
7 <%# FEP-633c §2 + §3.6: who guards this account, and how available each of
8 them is. Connect is the "who am I connected to" page, and a guardian is
9 the one connection a ward should never have to hunt for. Availability in
10 buddy-list language on the responsibility axis; the labels are UI, the
11 wire keeps the spec terms. Never public: this page is the owner's. %>
12 <% if (typeof myGuardians !== 'undefined' && myGuardians.length) { %>
13 <section class="conn-guardians">
14 <h2 class="conn-gtitle">&#128737; <%= t('msg.guardians_label') %></h2>
15 <ul class="conn-glist">
16 <% myGuardians.forEach(function(g){
17 var _st = g.availability === 'away' ? 'away' : (g.availability === 'dormant' ? 'dormant' : 'active');
18 var _lbl = _st === 'away' ? t('msg.g_away', { date: g.awayUntil ? formatDate(g.awayUntil) : '?' })
19 : (_st === 'dormant' ? t('msg.g_offline') : t('msg.g_available')); %>
20 <li class="conn-guardian">
21 <span class="conn-gdot conn-gdot--<%= _st %>" aria-hidden="true"></span>
22 <span class="conn-ghandle"><%= g.handle %></span>
23 <span class="conn-gstate"><%= _lbl %></span>
24 </li>
25 <% }); %>
26 </ul>
27 </section>
28 <% } %>
29
30 <form method="post" action="/news/follow" class="tl-follow">
31 <input type="text" name="handle" placeholder="@naam@server.social of site.com" autocomplete="off" spellcheck="false" required>
32 <button type="submit" class="btn btn-primary"><%= t('tl.follow_btn') %></button>
33 <label class="tl-follow-ab" title="<%= t('tl.autoboost_hint') %>"><input type="checkbox" name="auto_boost" value="1"> &#10024; <%= t('tl.autoboost_follow') %></label>
34 </form>
35
36 <%# Je volglijst meenemen. Hoort HIER en niet op /following: de tabbalk stuurt
37 'following' door naar Connect en er linkt niets meer naar die oude pagina.
38 Wie verhuist moet dit kunnen vinden zonder een URL uit zijn hoofd te typen. %>
39 <details class="tl-move">
40 <summary><%= t('tl.move_title') %></summary>
41 <p class="tl-move-hint"><%= t('tl.move_hint') %></p>
42 <p><a class="btn" href="/news/following.csv" download>&#11015; <%= t('tl.move_export') %></a></p>
43 <form method="post" action="/news/following/import" enctype="multipart/form-data">
44 <label class="tl-move-lbl" for="tl-move-file"><%= t('tl.move_import_file') %></label>
45 <input type="file" id="tl-move-file" name="csvfile" accept=".csv,text/csv,text/plain">
46
47 <label class="tl-move-lbl" for="tl-move-csv"><%= t('tl.move_import_lbl') %></label>
48 <textarea id="tl-move-csv" name="csv" rows="5" spellcheck="false"
49 placeholder="Account address,Show boosts,Notify on new posts,Languages,Featured"></textarea>
50
51 <button type="submit" class="btn btn-primary"><%= t('tl.move_import') %></button>
52 </form>
53 </details>
54
55 <%
56 var conns = connections || [];
57 var reachable = conns.filter(function (c) { return !c.unreachable; });
58 var unreachable = conns.filter(function (c) { return c.unreachable; });
59 // Display fields for one connection: name/handle (fallback: derive from actor_uri).
60 function connView(c) {
61 var host = '', user = '';
62 try { var u = new URL(c.actor_uri); host = u.hostname; user = (u.pathname.split('/').filter(Boolean).pop() || ''); } catch (e) {}
63 var handle = c.handle || (user ? ('@' + user + '@' + host) : c.actor_uri);
64 var display = c.name || handle;
65 return { handle: handle, display: display,
66 initial: String(display || '?').replace(/^@/, '').charAt(0).toUpperCase(),
67 profileUrl: c.url || c.actor_uri };
68 }
69 function arrow(dir) { return dir === 'mutual' ? '↔' : (dir === 'following' ? '→' : '←'); }
70 %>
71
72 <% if (reachable.length) { %>
73 <ul class="tl-foll-list">
74 <% reachable.forEach(function (c) { var v = connView(c); %>
75 <li class="tl-foll">
76 <span class="tl-foll-av"><% if (c.icon) { %><img src="<%= avatar(c.icon, 96) %>" alt=""><% } else { %><%= v.initial %><% } %></span>
77 <span class="tl-foll-meta">
78 <a href="<%= v.profileUrl %>" target="_blank" rel="nofollow noopener"><%= v.display %></a>
79 <span class="conn-sub">
80 <span class="conn-dir conn-dir--<%= c.direction %>"><%= arrow(c.direction) %> <%= t('tl.dir_' + c.direction) %></span>
81 <% if (c.follower && c.last_delivery_at) { %><span class="conn-deliv">&middot; <%= t('tl.last_delivery') %>: <%= formatDateTime(c.last_delivery_at) %></span><% } %>
82 </span>
83 </span>
84 <% if (c.following) { %>
85 <form method="post" action="/news/autoboost" class="tl-foll-ab" title="<%= t('tl.autoboost_hint') %>">
86 <input type="hidden" name="actor_uri" value="<%= c.actor_uri %>">
87 <label><input type="checkbox" name="auto_boost" value="1" <%= c.auto_boost ? 'checked' : '' %> data-autosubmit> &#10024; <%= t('tl.autoboost') %></label>
88 </form>
89 <form method="post" action="/news/unfollow"><input type="hidden" name="actor_uri" value="<%= c.actor_uri %>"><button type="submit" class="tl-unfollow"><%= t('tl.unfollow') %></button></form>
90 <% } %>
91 </li>
92 <% }); %>
93 </ul>
94 <% } else { %>
95 <p class="tl-empty"><%= t('tl.connect_empty') %></p>
96 <% } %>
97
98 <% if (unreachable.length) { %>
99 <details class="conn-unreach">
100 <summary>&#9888; <%= t('tl.unreachable') %> (<%= unreachable.length %>)</summary>
101 <p class="conn-unreach-lead"><%= t('tl.unreachable_lead') %></p>
102 <ul class="tl-foll-list">
103 <% unreachable.forEach(function (c) { var v = connView(c); %>
104 <li class="tl-foll is-stale">
105 <span class="tl-foll-av"><% if (c.icon) { %><img src="<%= avatar(c.icon, 96) %>" alt=""><% } else { %><%= v.initial %><% } %></span>
106 <span class="tl-foll-meta">
107 <a href="<%= v.profileUrl %>" target="_blank" rel="nofollow noopener"><%= v.display %></a>
108 <span class="conn-sub">
109 <span class="conn-dir conn-dir--<%= c.direction %>"><%= arrow(c.direction) %> <%= t('tl.dir_' + c.direction) %></span>
110 <% if (c.last_delivery_at) { %><span class="conn-deliv">&middot; <%= t('tl.last_delivery') %>: <%= formatDateTime(c.last_delivery_at) %></span><% } else { %><span class="conn-never">&middot; <%= t('tl.never_delivered') %></span><% } %>
111 <% if (c.last_error_at) { %><span class="conn-err">&middot; <%= t('tl.delivery_failed') %>: <%= formatDateTime(c.last_error_at) %></span><% } %>
112 </span>
113 </span>
114 <% if (c.follower_id) { %>
115 <form method="post" action="/followers/<%= c.follower_id %>/remove" onsubmit="return confirm('<%= t('tl.remove_confirm') %>')">
116 <button type="submit" class="tl-unfollow"><%= t('tl.remove_follower') %></button>
117 </form>
118 <% } %>
119 </li>
120 <% }); %>
121 </ul>
122 </details>
123 <% } %>
124</div>
125
126<style>
127 /* Guardians (FEP-633c 3.6): the buddy-list dot on the responsibility axis.
128 Green available, yellow declared away with an end, grey observed dormant
129 (one answer restores). Set apart from the follow list on purpose: these
130 are not people you chose to follow, they are the ones looking after you. */
131 .conn-guardians { margin: 0 0 1.25rem; padding: .8rem 1rem; border-radius: 12px;
132 border: 1px solid color-mix(in srgb, var(--ink, #000) 10%, transparent);
133 background: color-mix(in srgb, var(--ink, #000) 2.5%, transparent);
134 border-left: 3px solid #35a2ff; }
135 .conn-gtitle { margin: 0 0 .5rem; font-size: .95rem; }
136 .conn-glist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .35rem; }
137 .conn-guardian { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
138 .conn-gdot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
139 .conn-gdot--active { background: #4caf7d; }
140 .conn-gdot--away { background: #e0a83a; }
141 .conn-gdot--dormant { background: #8a93a3; }
142 .conn-ghandle { font-weight: 600; }
143 .conn-gstate { color: var(--ink-soft, #888); font-size: .82rem; }
144 .tl-wrap { max-width: 640px; margin: 1rem auto; padding: 0 1rem; }
145 .tl-title { margin: 0 0 .75rem; }
146 .tl-empty { color: var(--ink-soft, #888); }
147 .tl-follow { display: flex; gap: .5rem; margin: 0 0 1.25rem; flex-wrap: wrap; align-items: center; }
148 .tl-follow input[type="text"] { flex: 1; min-width: 200px; height: 2.4rem; padding: 0 .9rem; border-radius: 999px; font: inherit;
149 border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent); background: color-mix(in srgb, var(--ink, #000) 4%, transparent); color: var(--ink, #000); }
150 .tl-follow-ab { font-size: .85rem; color: var(--ink-soft, #888); display: inline-flex; align-items: center; gap: .3rem; cursor: pointer; }
151 .tl-foll-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .4rem; }
152 .tl-foll { display: flex; align-items: center; gap: .6rem; padding: .45rem .6rem; border-radius: 12px; background: color-mix(in srgb, var(--ink, #000) 4%, transparent); }
153 .tl-foll.is-stale { background: color-mix(in srgb, #c0392b 10%, transparent); }
154 .tl-foll-av { flex: 0 0 36px; width: 36px; height: 36px; border-radius: 50%; overflow: hidden; display: inline-flex; align-items: center; justify-content: center; font-weight: 700; background: color-mix(in srgb, var(--accent, #888) 20%, transparent); color: var(--accent, #555); }
155 .tl-foll-av img { width: 100%; height: 100%; object-fit: cover; background: #fff; }
156 .tl-foll-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.3; }
157 .tl-foll-meta a { color: var(--ink, inherit); text-decoration: none; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
158 .tl-foll-meta a:hover { text-decoration: underline; }
159 .conn-sub { font-size: .78rem; color: var(--ink-soft, #888); display: inline-flex; flex-wrap: wrap; gap: .35rem; align-items: baseline; }
160 .conn-dir { font-weight: 600; }
161 .conn-dir--mutual { color: var(--accent, #06c); }
162 .conn-dir--following { color: color-mix(in srgb, var(--ink, #000) 62%, transparent); }
163 .conn-dir--follower { color: color-mix(in srgb, var(--ink, #000) 62%, transparent); }
164 .conn-never, .conn-err { color: #c0392b; }
165 .tl-foll-ab { margin: 0; font-size: .8rem; color: var(--ink-soft, #888); cursor: pointer; }
166 .tl-foll-ab label { display: inline-flex; align-items: center; gap: .25rem; cursor: pointer; }
167 .tl-unfollow { background: none; border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent); border-radius: 8px; padding: .25rem .6rem; font-size: .8rem; color: var(--ink-soft, #888); cursor: pointer; white-space: nowrap; }
168 .tl-unfollow:hover { color: var(--ink, #000); }
169 .conn-unreach { margin: 1.25rem 0 0; border-top: 1px solid color-mix(in srgb, var(--ink, #000) 10%, transparent); padding-top: .75rem; }
170 .conn-unreach summary { cursor: pointer; font-weight: 600; color: #c0392b; }
171 .conn-unreach-lead { font-size: .8rem; color: var(--ink-soft, #888); margin: .5rem 0 .75rem; }
172</style>
Note: See TracBrowser for help on using the repository browser.