SPB Git

spb/drive Public

SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.

JavaScript 82.7% CSS 10.6% Nunjucks 3.6% Shell 1.8% SQL 1.3%

feat: web app shell, universal preview engine, sharing pages, search & activity UI

- Full SPA: grid/list views, multi-select + rectangle select, dnd move, inline rename, context menus, keyboard shortcuts, upload panel (chunked resumable), paste-to-upload
- Preview: images (zoom/pan/EXIF), video (transcode poll), audio (wavesurfer+ID3), pdf.js viewer with search, office→PDF, code (shiki), markdown GFM, CSV virtual table, JSON tree, ipynb, archives, fonts, eml, fallbacks
- Public share pages with OG tags, password gate, folder browser, zip-all
- Settings (password, sessions, API tokens), activity log, share manager, storage meter
- E2E smoke verified: upload→thumb→range→share→revoke→lockout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed yesterday (Aug 10, 2026) parent 3304d5c

Showing 22 changed files with +4,188 and −0

modified src/preview/handlers.mjs +1 −0
@@ -40,6 +40,7 @@ export async function describePreview(node, base) {
40 40 size: node.size,
41 41 mime: node.mime,
42 42 modified: node.modified,
43 + sha: node.blob_sha,
43 44 };
44 45 if (strategy === 'video') {
45 46 const probe = await probeMedia(node.blob_sha);
added src/web/assets/app.css +569 −0
@@ -0,0 +1,569 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Drive — Personal Cloud Drive
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/web/assets/app.css
8 + * Purpose : Full UI stylesheet — shell, views, overlays, share pages
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +* { box-sizing: border-box; }
14 +html, body { height: 100%; }
15 +body {
16 + margin: 0;
17 + background: var(--bg);
18 + color: var(--text);
19 + font: 14px/1.5 var(--font-sans);
20 + -webkit-font-smoothing: antialiased;
21 +}
22 +a { color: var(--accent); text-decoration: none; }
23 +a:hover { text-decoration: underline; }
24 +button { font: inherit; color: inherit; }
25 +::selection { background: var(--accent-soft); }
26 +:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
27 +
28 +/* ── Primitives ─────────────────────────────────────────────── */
29 +.btn {
30 + display: inline-flex; align-items: center; gap: 7px;
31 + padding: 7px 14px; border-radius: var(--radius-sm);
32 + border: 1px solid var(--border); background: var(--surface-2);
33 + cursor: pointer; transition: border-color 150ms var(--ease), background 150ms var(--ease);
34 + white-space: nowrap; user-select: none;
35 +}
36 +.btn:hover { border-color: var(--muted); }
37 +.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
38 +.btn.primary:hover { filter: brightness(1.08); }
39 +.btn.danger { color: var(--danger); }
40 +.btn.danger:hover { border-color: var(--danger); background: var(--danger-soft); }
41 +.btn.ghost { background: transparent; border-color: transparent; }
42 +.btn.ghost:hover { background: var(--surface-2); border-color: transparent; }
43 +.btn.icon { padding: 7px; }
44 +.btn:disabled { opacity: 0.45; cursor: default; pointer-events: none; }
45 +.btn svg { width: 16px; height: 16px; flex: none; }
46 +
47 +input[type='text'], input[type='password'], input[type='search'], input[type='number'],
48 +input[type='datetime-local'], select, textarea {
49 + background: var(--bg); color: var(--text);
50 + border: 1px solid var(--border); border-radius: var(--radius-sm);
51 + padding: 7px 10px; font: inherit; width: 100%;
52 + transition: border-color 150ms var(--ease);
53 +}
54 +input:focus, select:focus, textarea:focus { border-color: var(--accent); outline: none; }
55 +label.field { display: block; margin-bottom: 12px; }
56 +label.field > span { display: block; font-size: 12px; color: var(--muted); margin-bottom: 5px; font-weight: 500; }
57 +
58 +.chip {
59 + display: inline-flex; align-items: center; gap: 5px;
60 + font-size: 12px; padding: 2px 9px; border-radius: 99px;
61 + border: 1px solid var(--border); background: var(--surface-2); color: var(--muted);
62 + cursor: pointer; user-select: none;
63 +}
64 +.chip.active { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
65 +.chip .dot { width: 8px; height: 8px; border-radius: 50%; }
66 +
67 +kbd {
68 + font: 11px var(--font-mono); background: var(--surface-2);
69 + border: 1px solid var(--border); border-bottom-width: 2px;
70 + border-radius: 5px; padding: 1px 6px;
71 +}
72 +.mono { font-family: var(--font-mono); }
73 +.muted { color: var(--muted); }
74 +.spin { animation: spin 0.9s linear infinite; }
75 +@keyframes spin { to { transform: rotate(360deg); } }
76 +
77 +/* ── Login ──────────────────────────────────────────────────── */
78 +.login-wrap { min-height: 100%; display: grid; place-items: center; padding: 24px; }
79 +.login-card {
80 + width: 360px; max-width: 100%; background: var(--surface);
81 + border: 1px solid var(--border); border-radius: var(--radius);
82 + padding: 36px 32px;
83 +}
84 +.login-logo {
85 + width: 52px; height: 52px; border-radius: 14px; margin: 0 auto 18px;
86 + background: linear-gradient(135deg, var(--accent), var(--accent-2));
87 + display: grid; place-items: center; color: #fff;
88 + font: 700 19px var(--font-mono); letter-spacing: -0.5px;
89 +}
90 +.login-card h1 { font-size: 19px; text-align: center; margin: 0 0 4px; }
91 +.login-card .sub { text-align: center; color: var(--muted); font-size: 13px; margin: 0 0 24px; }
92 +.login-error {
93 + background: var(--danger-soft); border: 1px solid var(--danger);
94 + color: var(--danger); border-radius: var(--radius-sm);
95 + padding: 8px 12px; font-size: 13px; margin-bottom: 14px;
96 +}
97 +.login-remember { display: flex; align-items: center; gap: 8px; margin: 14px 0 18px; font-size: 13px; color: var(--muted); }
98 +.login-card .btn { width: 100%; justify-content: center; padding: 10px; }
99 +
100 +/* ── App shell ──────────────────────────────────────────────── */
101 +.shell { display: grid; grid-template-rows: 56px 1fr; height: 100vh; }
102 +.topbar {
103 + display: flex; align-items: center; gap: 14px; padding: 0 16px;
104 + background: var(--surface); border-bottom: 1px solid var(--border);
105 +}
106 +.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 15px; white-space: nowrap; }
107 +.brand .mark {
108 + width: 30px; height: 30px; border-radius: 9px;
109 + background: linear-gradient(135deg, var(--accent), var(--accent-2));
110 + display: grid; place-items: center; color: #fff; font: 700 12px var(--font-mono);
111 +}
112 +.searchbox { flex: 1; max-width: 560px; position: relative; }
113 +.searchbox svg { position: absolute; left: 10px; top: 50%; translate: 0 -50%; width: 15px; height: 15px; color: var(--muted); pointer-events: none; }
114 +.searchbox input { padding-left: 32px; background: var(--surface-2); border-radius: 8px; }
115 +.topbar .spacer { flex: 1; }
116 +
117 +.body { display: grid; grid-template-columns: 248px 1fr; min-height: 0; }
118 +.body.info-open { grid-template-columns: 248px 1fr 320px; }
119 +
120 +/* Sidebar */
121 +.sidebar {
122 + background: var(--surface); border-right: 1px solid var(--border);
123 + padding: 14px 10px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px;
124 +}
125 +.new-btn { margin: 0 4px 12px; justify-content: center; font-weight: 600; }
126 +.nav-item {
127 + display: flex; align-items: center; gap: 10px; padding: 7px 12px;
128 + border-radius: var(--radius-sm); color: var(--text); cursor: pointer;
129 + border: none; background: none; width: 100%; text-align: left; font-size: 13.5px;
130 +}
131 +.nav-item svg { width: 16px; height: 16px; color: var(--muted); flex: none; }
132 +.nav-item:hover { background: var(--surface-2); }
133 +.nav-item.active { background: var(--accent-soft); color: var(--accent); }
134 +.nav-item.active svg { color: var(--accent); }
135 +.nav-item .count { margin-left: auto; font-size: 11px; color: var(--muted); }
136 +.nav-section { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); padding: 14px 12px 4px; }
137 +
138 +.tree { margin: 2px 0; }
139 +.tree-row { display: flex; align-items: center; border-radius: var(--radius-sm); }
140 +.tree-row:hover { background: var(--surface-2); }
141 +.tree-row.active { background: var(--accent-soft); }
142 +.tree-row.drop-target { outline: 2px dashed var(--accent); outline-offset: -2px; }
143 +.tree-toggle {
144 + width: 22px; height: 26px; display: grid; place-items: center;
145 + background: none; border: none; cursor: pointer; color: var(--muted); flex: none;
146 +}
147 +.tree-toggle svg { width: 10px; height: 10px; transition: transform 150ms var(--ease); }
148 +.tree-toggle.open svg { transform: rotate(90deg); }
149 +.tree-label {
150 + flex: 1; display: flex; align-items: center; gap: 7px; padding: 4px 6px 4px 0;
151 + background: none; border: none; cursor: pointer; color: var(--text);
152 + font-size: 13px; text-align: left; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
153 +}
154 +.tree-label svg { width: 15px; height: 15px; flex: none; }
155 +
156 +.storage-meter { margin-top: auto; padding: 14px 12px 6px; border-top: 1px solid var(--border); }
157 +.storage-meter .bar { height: 5px; border-radius: 3px; background: var(--surface-2); overflow: hidden; display: flex; margin: 8px 0 6px; }
158 +.storage-meter .bar i { height: 100%; display: block; }
159 +.storage-meter .legend { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 11px; color: var(--muted); }
160 +.storage-meter .legend .dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; margin-right: 4px; }
161 +
162 +/* Main pane */
163 +.main { display: flex; flex-direction: column; min-width: 0; min-height: 0; position: relative; }
164 +.toolbar {
165 + display: flex; align-items: center; gap: 8px; padding: 10px 18px;
166 + border-bottom: 1px solid var(--border); min-height: 52px; flex-wrap: wrap;
167 +}
168 +.crumbs { display: flex; align-items: center; gap: 2px; font-size: 13.5px; flex: 1; min-width: 0; overflow: hidden; }
169 +.crumb {
170 + background: none; border: none; cursor: pointer; color: var(--muted);
171 + padding: 4px 8px; border-radius: 6px; white-space: nowrap; font-size: 13.5px;
172 +}
173 +.crumb:hover { background: var(--surface-2); color: var(--text); }
174 +.crumb.current { color: var(--text); font-weight: 600; }
175 +.crumb.drop-target { outline: 2px dashed var(--accent); outline-offset: -2px; }
176 +.crumb-sep { color: var(--muted); opacity: 0.5; }
177 +
178 +.sel-toolbar { display: flex; align-items: center; gap: 6px; }
179 +.sel-count { font-size: 13px; color: var(--accent); font-weight: 600; margin-right: 6px; }
180 +
181 +.content { flex: 1; overflow-y: auto; padding: 14px 18px 80px; position: relative; }
182 +
183 +/* Grid view */
184 +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--card, 168px), 1fr)); gap: 12px; }
185 +.card {
186 + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
187 + overflow: hidden; cursor: default; position: relative; user-select: none;
188 + transition: border-color 150ms var(--ease), background 150ms var(--ease);
189 +}
190 +.card:hover { border-color: var(--muted); }
191 +.card.selected { border-color: var(--accent); background: var(--accent-soft); }
192 +.card.drop-target { outline: 2px dashed var(--accent); outline-offset: -2px; }
193 +.card .thumb {
194 + aspect-ratio: 4/3; display: grid; place-items: center; background: var(--surface-2);
195 + overflow: hidden; position: relative;
196 +}
197 +.card .thumb img { width: 100%; height: 100%; object-fit: cover; }
198 +.card .thumb svg.type-icon { width: 34%; height: 34%; }
199 +.card .thumb .badge {
200 + position: absolute; right: 7px; bottom: 7px; background: rgba(0,0,0,0.72); color: #fff;
201 + font: 500 10.5px var(--font-mono); padding: 1px 6px; border-radius: 5px;
202 +}
203 +.card .meta { padding: 9px 11px; display: flex; align-items: center; gap: 8px; }
204 +.card .meta svg { width: 15px; height: 15px; flex: none; }
205 +.card .meta .name { flex: 1; font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
206 +.card .star-ind { position: absolute; left: 7px; top: 7px; width: 15px; height: 15px; color: var(--warning); filter: drop-shadow(0 1px 3px rgba(0,0,0,0.6)); }
207 +.card .tag-strip { position: absolute; left: 0; top: 0; bottom: 0; width: 3px; }
208 +
209 +/* List view */
210 +.list { display: flex; flex-direction: column; }
211 +.list-header, .row {
212 + display: grid; grid-template-columns: 30px minmax(200px, 1fr) 90px 110px 150px 140px;
213 + align-items: center; gap: 10px; padding: 5px 10px; border-radius: var(--radius-sm);
214 +}
215 +.list-header { color: var(--muted); font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; position: sticky; top: 0; background: var(--bg); z-index: 2; }
216 +.list-header button { background: none; border: none; color: inherit; cursor: pointer; font: inherit; text-transform: inherit; letter-spacing: inherit; display: flex; align-items: center; gap: 4px; padding: 4px 0; }
217 +.row { cursor: default; user-select: none; font-size: 13px; }
218 +.row:hover { background: var(--surface); }
219 +.row.selected { background: var(--accent-soft); }
220 +.row.drop-target { outline: 2px dashed var(--accent); outline-offset: -2px; }
221 +.row .name { display: flex; align-items: center; gap: 9px; overflow: hidden; }
222 +.row .name svg { width: 16px; height: 16px; flex: none; }
223 +.row .name span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
224 +.row .cell { color: var(--muted); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
225 +.row .rowtags { display: flex; gap: 4px; }
226 +.row .star-cell { color: var(--warning); display: grid; place-items: center; }
227 +.row .star-cell svg { width: 14px; height: 14px; }
228 +
229 +/* Inline rename */
230 +.rename-input { font: inherit; padding: 2px 6px !important; width: auto !important; min-width: 120px; }
231 +
232 +/* Selection rectangle */
233 +.select-rect { position: absolute; border: 1px solid var(--accent); background: var(--accent-soft); z-index: 5; pointer-events: none; }
234 +
235 +/* Empty states */
236 +.empty { display: grid; place-items: center; padding: 70px 20px; text-align: center; color: var(--muted); }
237 +.empty svg { width: 120px; height: 90px; margin-bottom: 18px; opacity: 0.9; }
238 +.empty h3 { color: var(--text); margin: 0 0 6px; font-size: 15px; }
239 +.empty p { margin: 0; font-size: 13px; }
240 +
241 +/* Skeletons */
242 +.skeleton { border-radius: var(--radius); background: linear-gradient(100deg, var(--surface) 40%, var(--surface-2) 50%, var(--surface) 60%); background-size: 200% 100%; animation: shimmer 1.4s infinite; height: 150px; }
243 +@keyframes shimmer { to { background-position: -200% 0; } }
244 +
245 +/* ── Info panel ─────────────────────────────────────────────── */
246 +.info-panel {
247 + background: var(--surface); border-left: 1px solid var(--border);
248 + overflow-y: auto; padding: 18px; display: none;
249 +}
250 +.body.info-open .info-panel { display: block; animation: slide-in 180ms var(--ease); }
251 +@keyframes slide-in { from { translate: 24px 0; opacity: 0; } }
252 +.info-thumb { width: 100%; aspect-ratio: 16/10; border-radius: var(--radius); background: var(--surface-2); display: grid; place-items: center; overflow: hidden; margin-bottom: 14px; }
253 +.info-thumb img { width: 100%; height: 100%; object-fit: cover; }
254 +.info-thumb svg { width: 56px; height: 56px; }
255 +.info-panel h3 { font-size: 14.5px; margin: 0 0 12px; word-break: break-word; }
256 +.kv { display: grid; grid-template-columns: 92px 1fr; gap: 6px 10px; font-size: 12.5px; margin-bottom: 16px; }
257 +.kv dt { color: var(--muted); } .kv dd { margin: 0; word-break: break-word; }
258 +.info-section { border-top: 1px solid var(--border); padding-top: 13px; margin-top: 13px; }
259 +.info-section h4 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); margin: 0 0 9px; }
260 +.tag-editor { display: flex; flex-wrap: wrap; gap: 5px; }
261 +
262 +/* ── Context menu ───────────────────────────────────────────── */
263 +.ctx-menu {
264 + position: fixed; z-index: 80; min-width: 210px;
265 + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
266 + box-shadow: var(--shadow-pop); padding: 5px; animation: pop 120ms var(--ease);
267 +}
268 +@keyframes pop { from { opacity: 0; transform: scale(0.97); } }
269 +.ctx-item {
270 + display: flex; align-items: center; gap: 10px; width: 100%;
271 + padding: 7px 11px; border: none; background: none; border-radius: var(--radius-sm);
272 + cursor: pointer; font-size: 13px; text-align: left; color: var(--text);
273 +}
274 +.ctx-item svg { width: 15px; height: 15px; color: var(--muted); flex: none; }
275 +.ctx-item:hover, .ctx-item.focused { background: var(--accent-soft); }
276 +.ctx-item.danger { color: var(--danger); }
277 +.ctx-item.danger svg { color: var(--danger); }
278 +.ctx-item kbd { margin-left: auto; }
279 +.ctx-sep { height: 1px; background: var(--border); margin: 5px 8px; }
280 +.ctx-colors { display: flex; gap: 6px; padding: 7px 11px; }
281 +.ctx-colors button { width: 18px; height: 18px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; }
282 +.ctx-colors button:hover { border-color: var(--text); }
283 +
284 +/* ── Toasts ─────────────────────────────────────────────────── */
285 +.toasts { position: fixed; bottom: 20px; left: 50%; translate: -50% 0; z-index: 95; display: flex; flex-direction: column; gap: 8px; align-items: center; }
286 +.toast {
287 + display: flex; align-items: center; gap: 12px;
288 + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
289 + box-shadow: var(--shadow-pop); padding: 10px 16px; font-size: 13px;
290 + animation: toast-in 200ms var(--ease);
291 +}
292 +@keyframes toast-in { from { translate: 0 12px; opacity: 0; } }
293 +.toast button { background: none; border: none; color: var(--accent); font-weight: 600; cursor: pointer; padding: 0; }
294 +.toast.error { border-color: var(--danger); }
295 +
296 +/* ── Modals ─────────────────────────────────────────────────── */
297 +.modal-scrim { position: fixed; inset: 0; background: var(--scrim); z-index: 70; display: grid; place-items: center; animation: fade 150ms var(--ease); }
298 +@keyframes fade { from { opacity: 0; } }
299 +.modal {
300 + width: 440px; max-width: calc(100vw - 32px); max-height: 86vh; overflow-y: auto;
301 + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
302 + box-shadow: var(--shadow-pop); padding: 22px; animation: pop 150ms var(--ease);
303 +}
304 +.modal h2 { font-size: 16px; margin: 0 0 16px; }
305 +.modal .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
306 +.modal.wide { width: 620px; }
307 +
308 +/* Folder tree picker */
309 +.picker-tree { max-height: 300px; overflow-y: auto; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px; }
310 +
311 +/* Share modal specifics */
312 +.share-url { display: flex; gap: 8px; margin: 12px 0; }
313 +.share-url input { font-family: var(--font-mono); font-size: 12px; }
314 +.share-qr { display: grid; place-items: center; padding: 10px; }
315 +.share-qr img { border-radius: var(--radius-sm); background: #fff; padding: 8px; }
316 +
317 +/* ── Upload panel ───────────────────────────────────────────── */
318 +.upload-panel {
319 + position: fixed; right: 18px; bottom: 18px; width: 360px; z-index: 60;
320 + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
321 + box-shadow: var(--shadow-pop); overflow: hidden; display: none;
322 +}
323 +.upload-panel.open { display: block; animation: toast-in 200ms var(--ease); }
324 +.upload-head { display: flex; align-items: center; gap: 10px; padding: 11px 14px; background: var(--surface-2); font-weight: 600; font-size: 13px; }
325 +.upload-head .agg { color: var(--muted); font-weight: 400; font-size: 12px; }
326 +.upload-head button { margin-left: auto; }
327 +.upload-list { max-height: 300px; overflow-y: auto; }
328 +.upload-panel.min .upload-list { display: none; }
329 +.upload-item { display: flex; align-items: center; gap: 10px; padding: 9px 14px; border-top: 1px solid var(--border); font-size: 12.5px; }
330 +.upload-item .fname { flex: 1; min-width: 0; }
331 +.upload-item .fname .n { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
332 +.upload-item .fname .s { color: var(--muted); font-size: 11px; }
333 +.upload-item .prog { height: 4px; border-radius: 2px; background: var(--surface-2); overflow: hidden; margin-top: 4px; }
334 +.upload-item .prog i { display: block; height: 100%; background: var(--accent); border-radius: 2px; transition: width 200ms linear; }
335 +.upload-item.done .prog i { background: var(--accent-2); }
336 +.upload-item.err .prog i { background: var(--danger); }
337 +.upload-item .act { display: flex; gap: 2px; }
338 +
339 +/* Drop overlay */
340 +.drop-overlay {
341 + position: fixed; inset: 0; z-index: 90; background: var(--scrim);
342 + display: none; place-items: center; pointer-events: none;
343 +}
344 +.drop-overlay.active { display: grid; }
345 +.drop-overlay .inner {
346 + border: 2px dashed var(--accent); border-radius: 16px; padding: 60px 90px;
347 + color: var(--accent); font-size: 17px; font-weight: 600; background: var(--accent-soft);
348 + display: flex; flex-direction: column; align-items: center; gap: 14px;
349 +}
350 +.drop-overlay svg { width: 44px; height: 44px; }
351 +
352 +/* ── Preview overlay ────────────────────────────────────────── */
353 +.preview-overlay { position: fixed; inset: 0; z-index: 85; background: var(--scrim); display: flex; flex-direction: column; animation: fade 150ms var(--ease); }
354 +.preview-head { display: flex; align-items: center; gap: 12px; padding: 12px 18px; color: #fff; }
355 +.preview-head .title { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
356 +.preview-head .size { color: rgba(255,255,255,0.6); font-size: 12px; white-space: nowrap; }
357 +.preview-head .p-actions { margin-left: auto; display: flex; gap: 4px; }
358 +.preview-head .btn { background: rgba(255,255,255,0.08); border-color: transparent; color: #fff; }
359 +.preview-head .btn:hover { background: rgba(255,255,255,0.16); }
360 +.preview-body { flex: 1; min-height: 0; display: grid; place-items: center; position: relative; padding: 0 64px 24px; }
361 +.preview-stage { max-width: 100%; max-height: 100%; display: grid; place-items: center; }
362 +.preview-nav {
363 + position: absolute; top: 50%; translate: 0 -50%; z-index: 3;
364 + width: 44px; height: 44px; border-radius: 50%; border: none; cursor: pointer;
365 + background: rgba(255,255,255,0.1); color: #fff; display: grid; place-items: center;
366 +}
367 +.preview-nav:hover { background: rgba(255,255,255,0.22); }
368 +.preview-nav.prev { left: 12px; } .preview-nav.next { right: 12px; }
369 +.preview-nav svg { width: 20px; height: 20px; }
370 +.preview-count { position: absolute; bottom: 10px; left: 50%; translate: -50% 0; color: rgba(255,255,255,0.55); font-size: 12px; }
371 +
372 +.pv-img-wrap { overflow: hidden; width: 100%; height: 100%; display: grid; place-items: center; cursor: grab; }
373 +.pv-img-wrap img { max-width: 100%; max-height: 100%; transition: transform 120ms var(--ease); transform-origin: center; }
374 +.pv-toolbar {
375 + position: absolute; bottom: 18px; left: 50%; translate: -50% 0; z-index: 4;
376 + display: flex; gap: 4px; background: rgba(10,13,20,0.85); border: 1px solid rgba(255,255,255,0.1);
377 + border-radius: 99px; padding: 5px 8px;
378 +}
379 +.pv-toolbar .btn { background: none; border: none; color: #fff; padding: 6px 9px; }
380 +.pv-toolbar .btn:hover { background: rgba(255,255,255,0.12); }
381 +.pv-toolbar .zoom-label { color: rgba(255,255,255,0.65); font-size: 12px; align-self: center; min-width: 42px; text-align: center; }
382 +
383 +video.pv-video { max-width: 100%; max-height: 100%; border-radius: var(--radius); background: #000; outline: none; }
384 +
385 +.pv-panel {
386 + width: min(920px, 100%); height: 100%; background: var(--surface);
387 + border-radius: var(--radius); border: 1px solid var(--border);
388 + overflow: hidden; display: flex; flex-direction: column;
389 +}
390 +.pv-panel .pv-scroll { flex: 1; overflow: auto; padding: 22px 28px; }
391 +.pv-panel .pv-toolstrip { display: flex; align-items: center; gap: 6px; padding: 8px 14px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--muted); flex-wrap: wrap; }
392 +
393 +/* Code preview */
394 +.pv-code pre { margin: 0; font: 12.5px/1.6 var(--font-mono); }
395 +.pv-code .shiki { background: transparent !important; padding: 0; }
396 +.pv-code.linenums code { counter-reset: ln; }
397 +.pv-code.linenums .line::before {
398 + counter-increment: ln; content: counter(ln);
399 + display: inline-block; width: 3.2em; margin-right: 1.2em; text-align: right;
400 + color: var(--muted); opacity: 0.55; user-select: none;
401 +}
402 +.pv-code.wrap pre { white-space: pre-wrap; word-break: break-all; }
403 +
404 +/* Markdown preview */
405 +.md-body { font-size: 14.5px; line-height: 1.65; max-width: 780px; margin: 0 auto; }
406 +.md-body h1, .md-body h2 { border-bottom: 1px solid var(--border); padding-bottom: 6px; }
407 +.md-body h1 a, .md-body h2 a, .md-body h3 a, .md-body h4 a { color: inherit; }
408 +.md-body pre.code-fence, .md-body pre.mermaid-block { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 13px 16px; overflow-x: auto; font: 12.5px/1.6 var(--font-mono); }
409 +.md-body code { font-family: var(--font-mono); font-size: 0.9em; background: var(--surface-2); border-radius: 4px; padding: 1px 5px; }
410 +.md-body pre code { background: none; padding: 0; }
411 +.md-body table { border-collapse: collapse; width: 100%; margin: 14px 0; }
412 +.md-body th, .md-body td { border: 1px solid var(--border); padding: 7px 11px; text-align: left; }
413 +.md-body th { background: var(--surface-2); }
414 +.md-body img { max-width: 100%; }
415 +.md-body blockquote { border-left: 3px solid var(--accent); margin: 14px 0; padding: 2px 16px; color: var(--muted); }
416 +.md-body .task-list-item { list-style: none; }
417 +.md-body .task-list-item input { margin-right: 7px; }
418 +
419 +/* CSV table */
420 +.csv-wrap { overflow: auto; height: 100%; }
421 +.csv-table { border-collapse: collapse; font: 12px var(--font-mono); min-width: 100%; }
422 +.csv-table th {
423 + position: sticky; top: 0; background: var(--surface-2); z-index: 2;
424 + border: 1px solid var(--border); padding: 6px 10px; text-align: left; cursor: pointer; white-space: nowrap;
425 +}
426 +.csv-table th:hover { color: var(--accent); }
427 +.csv-table td { border: 1px solid var(--border); padding: 4px 10px; white-space: nowrap; max-width: 340px; overflow: hidden; text-overflow: ellipsis; }
428 +.csv-table tr:nth-child(even) td { background: var(--surface); }
429 +
430 +/* JSON tree */
431 +.json-tree { font: 12.5px/1.7 var(--font-mono); }
432 +.json-tree details { padding-left: 18px; }
433 +.json-tree summary { cursor: pointer; list-style: none; margin-left: -18px; }
434 +.json-tree summary::before { content: '▸'; display: inline-block; width: 14px; color: var(--muted); }
435 +.json-tree details[open] > summary::before { content: '▾'; }
436 +.json-key { color: var(--accent); }
437 +.json-str { color: var(--accent-2); }
438 +.json-num { color: var(--warning); }
439 +.json-bool { color: #b48cff; }
440 +.json-null { color: var(--muted); }
441 +
442 +/* Archive browser */
443 +.arc-list { font-size: 13px; }
444 +.arc-row { display: flex; align-items: center; gap: 10px; padding: 6px 10px; border-radius: var(--radius-sm); cursor: pointer; }
445 +.arc-row:hover { background: var(--surface-2); }
446 +.arc-row svg { width: 15px; height: 15px; color: var(--muted); flex: none; }
447 +.arc-row .sz { margin-left: auto; color: var(--muted); font-size: 12px; font-family: var(--font-mono); }
448 +
449 +/* Audio player */
450 +.pv-audio { width: min(680px, 92vw); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 26px; }
451 +.pv-audio .art { width: 88px; height: 88px; border-radius: var(--radius-sm); background: var(--surface-2); display: grid; place-items: center; overflow: hidden; }
452 +.pv-audio .art img { width: 100%; height: 100%; object-fit: cover; }
453 +.pv-audio .art svg { width: 34px; height: 34px; color: var(--muted); }
454 +.pv-audio .top { display: flex; gap: 18px; align-items: center; margin-bottom: 18px; }
455 +.pv-audio .tt { min-width: 0; }
456 +.pv-audio .tt .t { font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
457 +.pv-audio .tt .a { color: var(--muted); font-size: 12.5px; }
458 +#waveform { margin: 6px 0 12px; }
459 +.audio-controls { display: flex; align-items: center; gap: 8px; }
460 +.audio-controls .time { font: 12px var(--font-mono); color: var(--muted); margin-left: auto; }
461 +
462 +/* PDF viewer */
463 +.pv-pdf { width: 100%; height: 100%; display: flex; background: var(--surface); border-radius: var(--radius); border: 1px solid var(--border); overflow: hidden; }
464 +.pdf-rail { width: 130px; overflow-y: auto; border-right: 1px solid var(--border); padding: 10px; display: flex; flex-direction: column; gap: 10px; background: var(--surface-2); flex: none; }
465 +.pdf-rail canvas { width: 100%; border-radius: 4px; cursor: pointer; border: 2px solid transparent; display: block; }
466 +.pdf-rail .cur canvas { border-color: var(--accent); }
467 +.pdf-rail .pn { text-align: center; font-size: 10.5px; color: var(--muted); margin-top: 3px; }
468 +.pdf-main { flex: 1; overflow: auto; display: flex; flex-direction: column; align-items: center; gap: 18px; padding: 20px; }
469 +.pdf-page { position: relative; box-shadow: 0 3px 16px rgba(0,0,0,0.4); }
470 +.pdf-page canvas { display: block; }
471 +.pdf-page .textLayer {
472 + position: absolute; inset: 0; overflow: hidden; line-height: 1;
473 + opacity: 1; forced-color-adjust: none; transform-origin: 0 0; z-index: 1;
474 +}
475 +.pdf-page .textLayer span, .pdf-page .textLayer br { color: transparent; position: absolute; white-space: pre; cursor: text; transform-origin: 0 0; }
476 +.pdf-page .textLayer ::selection { background: rgba(79, 140, 255, 0.4); }
477 +.pdf-page .textLayer .hl { background: rgba(255, 209, 102, 0.5); color: transparent; border-radius: 2px; }
478 +
479 +/* Font specimen */
480 +.font-specimen h2 { font-size: 34px; margin: 4px 0 20px; font-weight: 400; }
481 +.font-specimen .alpha { font-size: 21px; word-break: break-all; color: var(--muted); margin-bottom: 20px; }
482 +.font-specimen .pangram { border-top: 1px solid var(--border); padding-top: 16px; }
483 +
484 +/* Fallback card */
485 +.fallback-card { text-align: center; padding: 46px 40px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); max-width: 440px; }
486 +.fallback-card svg { width: 64px; height: 64px; margin-bottom: 14px; }
487 +.fallback-card h3 { margin: 0 0 4px; word-break: break-all; }
488 +.fallback-card .fm { color: var(--muted); font-size: 12.5px; margin-bottom: 6px; }
489 +.fallback-card .sha { font: 10.5px var(--font-mono); color: var(--muted); word-break: break-all; opacity: 0.7; margin-bottom: 18px; }
490 +
491 +/* Processing state */
492 +.pv-processing { text-align: center; color: #fff; }
493 +.pv-processing .bar { width: 220px; height: 4px; border-radius: 2px; background: rgba(255,255,255,0.15); overflow: hidden; margin: 16px auto 0; }
494 +.pv-processing .bar i { display: block; height: 100%; width: 40%; background: var(--accent); border-radius: 2px; animation: indeterminate 1.2s infinite var(--ease); }
495 +@keyframes indeterminate { from { margin-left: -40%; } to { margin-left: 100%; } }
496 +
497 +/* ── Share pages (public) ───────────────────────────────────── */
498 +.share-page { min-height: 100vh; display: flex; flex-direction: column; }
499 +.share-topbar { display: flex; align-items: center; gap: 12px; padding: 14px 22px; border-bottom: 1px solid var(--border); background: var(--surface); }
500 +.share-main { flex: 1; display: flex; flex-direction: column; padding: 26px 22px; max-width: 1080px; margin: 0 auto; width: 100%; }
501 +.share-main.wide { max-width: 1280px; }
502 +.share-file-head { display: flex; align-items: center; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
503 +.share-file-head .fico { width: 44px; height: 44px; flex: none; }
504 +.share-file-head h1 { font-size: 17px; margin: 0; word-break: break-word; }
505 +.share-file-head .fmeta { color: var(--muted); font-size: 12.5px; }
506 +.share-file-head .btn { margin-left: auto; }
507 +.share-stage { flex: 1; min-height: 380px; display: grid; place-items: center; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; position: relative; padding: 14px; }
508 +.share-footer { text-align: center; color: var(--muted); font-size: 12px; padding: 18px; border-top: 1px solid var(--border); }
509 +.share-footer a { color: var(--muted); }
510 +
511 +/* Share folder browser reuses grid/list */
512 +.share-crumbs { margin-bottom: 14px; }
513 +
514 +/* Expired / error pages */
515 +.center-page { min-height: 100vh; display: grid; place-items: center; padding: 24px; text-align: center; }
516 +.center-page .big { font-size: 56px; font-weight: 700; background: linear-gradient(135deg, var(--accent), var(--accent-2)); -webkit-background-clip: text; background-clip: text; color: transparent; margin-bottom: 8px; }
517 +.center-page h1 { font-size: 19px; margin: 0 0 8px; }
518 +.center-page p { color: var(--muted); margin: 0 0 22px; }
519 +
520 +/* Shortcuts cheat sheet */
521 +.shortcuts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 26px; font-size: 13px; }
522 +.shortcuts-grid > div { display: flex; justify-content: space-between; gap: 14px; align-items: center; padding: 3px 0; }
523 +
524 +/* Settings page */
525 +.settings { max-width: 620px; }
526 +.settings .panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 18px; }
527 +.settings .panel h3 { margin: 0 0 14px; font-size: 14.5px; }
528 +.session-row { display: flex; align-items: center; gap: 12px; padding: 9px 0; border-top: 1px solid var(--border); font-size: 13px; }
529 +.session-row:first-of-type { border-top: none; }
530 +.session-row .who { flex: 1; min-width: 0; }
531 +.session-row .ua { color: var(--muted); font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
532 +
533 +/* Activity page */
534 +.activity-list { max-width: 760px; }
535 +.act-row { display: flex; gap: 13px; padding: 10px 6px; border-bottom: 1px solid var(--border); font-size: 13px; align-items: flex-start; }
536 +.act-row svg { width: 16px; height: 16px; color: var(--muted); flex: none; margin-top: 2px; }
537 +.act-row .when { margin-left: auto; color: var(--muted); font-size: 11.5px; white-space: nowrap; }
538 +
539 +/* Shares manager */
540 +.shares-table { width: 100%; border-collapse: collapse; font-size: 13px; }
541 +.shares-table th { text-align: left; color: var(--muted); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.05em; padding: 8px 10px; border-bottom: 1px solid var(--border); }
542 +.shares-table td { padding: 9px 10px; border-bottom: 1px solid var(--border); vertical-align: middle; }
543 +.shares-table .url-cell { display: flex; gap: 6px; align-items: center; font: 11.5px var(--font-mono); }
544 +
545 +/* ── Responsive ─────────────────────────────────────────────── */
546 +.sidebar-toggle { display: none; }
547 +@media (max-width: 900px) {
548 + .body, .body.info-open { grid-template-columns: 1fr; }
549 + .sidebar {
550 + position: fixed; z-index: 65; top: 56px; bottom: 0; left: 0; width: 264px;
551 + translate: -100% 0; transition: translate 200ms var(--ease); box-shadow: var(--shadow-pop);
552 + }
553 + .sidebar.open { translate: 0 0; }
554 + .sidebar-toggle { display: inline-flex; }
555 + .info-panel { position: fixed; z-index: 65; top: 56px; bottom: 0; right: 0; width: min(320px, 88vw); box-shadow: var(--shadow-pop); }
556 + .body.info-open .info-panel { display: block; }
557 + .searchbox { max-width: none; }
558 + .brand span { display: none; }
559 + .preview-body { padding: 0 8px 12px; }
560 + .preview-nav.prev { left: 4px; } .preview-nav.next { right: 4px; }
561 + .list-header, .row { grid-template-columns: 30px 1fr 80px; }
562 + .list-header > :nth-child(n+4), .row > :nth-child(n+4) { display: none; }
563 + .upload-panel { width: calc(100vw - 24px); right: 12px; }
564 + .pdf-rail { display: none; }
565 +}
566 +
567 +@media print {
568 + .topbar, .sidebar, .toolbar, .preview-head, .pv-toolbar, .preview-nav { display: none !important; }
569 +}
added src/web/assets/favicon.svg +21 −0
@@ -0,0 +1,21 @@
1 +<!--
2 + ─────────────────────────────────────────────
3 + SPB Drive — Personal Cloud Drive
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/assets/favicon.svg
8 + Purpose : App favicon — SPB monogram on gradient tile
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +-->
12 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
13 + <defs>
14 + <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
15 + <stop offset="0" stop-color="#4f8cff"/>
16 + <stop offset="1" stop-color="#22d3aa"/>
17 + </linearGradient>
18 + </defs>
19 + <rect width="64" height="64" rx="15" fill="url(#g)"/>
20 + <text x="32" y="41" text-anchor="middle" font-family="JetBrains Mono, monospace" font-weight="700" font-size="21" fill="#fff">SPB</text>
21 +</svg>
added src/web/assets/icon-192.png +0 −0

Binary file not shown.

added src/web/assets/icon-512.png +0 −0

Binary file not shown.

added src/web/assets/js/api.js +42 −0
@@ -0,0 +1,42 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Drive — Personal Cloud Drive
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/web/assets/js/api.js
8 + * Purpose : JSON API client — CSRF header, error normalization
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +/** Perform an API call; throws Error with .code on failure. */
14 +export async function api(path, { method = 'GET', body, raw = false } = {}) {
15 + const headers = {};
16 + if (method !== 'GET' && method !== 'HEAD') headers['x-spbdrive-csrf'] = '1';
17 + if (body !== undefined) headers['content-type'] = 'application/json';
18 + const res = await fetch(path, {
19 + method,
20 + headers,
21 + body: body !== undefined ? JSON.stringify(body) : undefined,
22 + credentials: 'same-origin',
23 + });
24 + if (res.status === 401) {
25 + location.href = `/login?next=${encodeURIComponent(location.pathname)}`;
26 + throw new Error('unauthorized');
27 + }
28 + if (raw) return res;
29 + const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null;
30 + if (!res.ok) {
31 + const err = new Error(data?.error?.message ?? `HTTP ${res.status}`);
32 + err.code = data?.error?.code ?? 'http_error';
33 + err.status = res.status;
34 + throw err;
35 + }
36 + return data;
37 +}
38 +
39 +export const getJSON = (path) => api(path);
40 +export const post = (path, body) => api(path, { method: 'POST', body });
41 +export const patch = (path, body) => api(path, { method: 'PATCH', body });
42 +export const del = (path) => api(path, { method: 'DELETE' });
added src/web/assets/js/app.js +1422 −0
@@ -0,0 +1,1422 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Drive — Personal Cloud Drive
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/web/assets/js/app.js
8 + * Purpose : Main SPA — routing, browsing, selection, dnd, uploads, shares,
9 + * search, trash, tags, settings, activity, keyboard shortcuts
10 + * License : MIT © Simon-Pierre Boucher
11 + * ─────────────────────────────────────────────
12 + */
13 +
14 +import { api, getJSON, post, patch, del } from './api.js';
15 +import {
16 + h, esc, fmtSize, fmtDate, toast, modal, confirmModal, contextMenu, copyText,
17 +} from './ui.js';
18 +import { UI, nodeIcon, folderIcon, fileIcon, EMPTY_ART } from './icons.js';
19 +import { UploadManager, collectDropped, bindPasteUpload } from './upload.js';
20 +import { Viewer } from './viewer.js';
21 +
22 +const FOLDER_COLORS = ['blue', 'teal', 'green', 'yellow', 'orange', 'red', 'purple', 'pink'];
23 +
24 +const state = {
25 + rootId: 1,
26 + route: { view: 'folder', id: 1 },
27 + nodes: [], // nodes in current view
28 + path: [],
29 + selection: new Set(),
30 + anchor: null, // shift-select anchor id
31 + sort: 'name',
32 + dir: 'asc',
33 + viewMode: localStorage.getItem('spbdrive-view') ?? 'grid',
34 + viewModes: JSON.parse(localStorage.getItem('spbdrive-view-per-folder') ?? '{}'),
35 + cardSize: Number(localStorage.getItem('spbdrive-card') ?? 168),
36 + tree: [],
37 + tags: [],
38 + infoNode: null,
39 + clipboard: null, // {ids, cut}
40 +};
41 +
42 +const $ = (id) => document.getElementById(id);
43 +const content = $('content');
44 +const toolbar = $('toolbar');
45 +
46 +// ── Boot ─────────────────────────────────────────────────────────────
47 +const uploads = new UploadManager({
48 + onFinished: () => { refreshCurrent(); refreshSidebar(); },
49 +});
50 +
51 +async function boot() {
52 + try {
53 + const me = await getJSON('/api/v1/me');
54 + state.rootId = me.rootId;
55 + } catch { return; }
56 + wireChrome();
57 + bindPasteUpload(uploads, () => currentFolderId());
58 + await refreshSidebar();
59 + window.addEventListener('hashchange', onRoute);
60 + onRoute();
61 +}
62 +
63 +function currentFolderId() {
64 + return state.route.view === 'folder' ? state.route.id : state.rootId;
65 +}
66 +
67 +// ── Routing ──────────────────────────────────────────────────────────
68 +function onRoute() {
69 + const hash = location.hash.slice(2) || `folder/${state.rootId}`;
70 + const [view, arg] = hash.split('/');
71 + const q = new URLSearchParams(hash.split('?')[1] ?? '');
72 + state.route = { view: view.split('?')[0], id: Number(arg) || arg, q };
73 + state.selection.clear();
74 + state.infoNode = null;
75 + updateInfoPanel();
76 + const views = {
77 + folder: () => loadFolder(Number(state.route.id) || state.rootId),
78 + recent: loadRecent,
79 + starred: loadStarred,
80 + shared: loadShares,
81 + trash: loadTrash,
82 + tag: () => loadTag(Number(state.route.id)),
83 + search: () => loadSearch(decodeURIComponent(String(state.route.id ?? ''))),
84 + activity: loadActivity,
85 + settings: loadSettings,
86 + };
87 + (views[state.route.view] ?? views.folder)();
88 + highlightSidebar();
89 +}
90 +
91 +const go = (hash) => { location.hash = `#/${hash}`; };
92 +
93 +// ── Chrome (topbar, sidebar skeleton) ────────────────────────────────
94 +function wireChrome() {
95 + $('sidebarToggle').innerHTML = UI.menu;
96 + $('viewToggle').innerHTML = state.viewMode === 'grid' ? UI.listv : UI.grid;
97 + $('themeToggle').innerHTML = document.documentElement.dataset.theme === 'light' ? UI.moon : UI.sun;
98 + $('settingsBtn').innerHTML = UI.gear;
99 +
100 + $('sidebarToggle').onclick = () => $('sidebar').classList.toggle('open');
101 + $('uploadBtn').onclick = () => pickFiles();
102 + $('viewToggle').onclick = toggleViewMode;
103 + $('themeToggle').onclick = toggleTheme;
104 + $('settingsBtn').onclick = () => go('settings');
105 + $('newBtn').onclick = (e) => {
106 + const r = e.currentTarget.getBoundingClientRect();
107 + contextMenu(r.left, r.bottom + 4, [
108 + { label: 'New folder', icon: UI.folderNew, kbd: 'N', onClick: newFolderDialog },
109 + { sep: true },
110 + { label: 'Upload files', icon: UI.upload, onClick: () => pickFiles() },
111 + { label: 'Upload folder', icon: UI.move, onClick: () => $('folderPick').click() },
112 + ]);
113 + };
114 +
115 + $('filePick').onchange = (e) => {
116 + uploads.add([...e.target.files].map((f) => ({ file: f, relPath: f.name })), currentFolderId());
117 + e.target.value = '';
118 + };
119 + $('folderPick').onchange = (e) => {
120 + uploads.add([...e.target.files].map((f) => ({ file: f, relPath: f.webkitRelativePath || f.name })), currentFolderId());
121 + e.target.value = '';
122 + };
123 +
124 + // Global search box
125 + const search = $('searchInput');
126 + let debounce;
127 + search.addEventListener('input', () => {
128 + clearTimeout(debounce);
129 + debounce = setTimeout(() => {
130 + if (search.value.trim()) go(`search/${encodeURIComponent(search.value.trim())}`);
131 + }, 350);
132 + });
133 + search.addEventListener('keydown', (e) => {
134 + if (e.key === 'Enter' && search.value.trim()) go(`search/${encodeURIComponent(search.value.trim())}`);
135 + if (e.key === 'Escape') search.blur();
136 + });
137 +
138 + // Full-window drag & drop
139 + let dragDepth = 0;
140 + window.addEventListener('dragenter', (e) => {
141 + if (![...e.dataTransfer?.types ?? []].includes('Files')) return;
142 + dragDepth += 1;
143 + $('dropOverlay').classList.add('active');
144 + });
145 + window.addEventListener('dragleave', () => {
146 + dragDepth = Math.max(0, dragDepth - 1);
147 + if (!dragDepth) $('dropOverlay').classList.remove('active');
148 + });
149 + window.addEventListener('dragover', (e) => e.preventDefault());
150 + window.addEventListener('drop', async (e) => {
151 + e.preventDefault();
152 + dragDepth = 0;
153 + $('dropOverlay').classList.remove('active');
154 + if (!e.dataTransfer?.files?.length && !e.dataTransfer?.items?.length) return;
155 + const items = await collectDropped(e.dataTransfer);
156 + if (items.length) uploads.add(items, currentFolderId());
157 + });
158 +
159 + document.addEventListener('keydown', onGlobalKey);
160 +}
161 +
162 +function pickFiles() { $('filePick').click(); }
163 +
164 +function toggleViewMode() {
165 + state.viewMode = state.viewMode === 'grid' ? 'list' : 'grid';
166 + localStorage.setItem('spbdrive-view', state.viewMode);
167 + if (state.route.view === 'folder') {
168 + state.viewModes[state.route.id] = state.viewMode;
169 + localStorage.setItem('spbdrive-view-per-folder', JSON.stringify(state.viewModes));
170 + }
171 + $('viewToggle').innerHTML = state.viewMode === 'grid' ? UI.listv : UI.grid;
172 + renderNodes();
173 +}
174 +
175 +function toggleTheme() {
176 + const next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
177 + document.documentElement.dataset.theme = next;
178 + localStorage.setItem('spbdrive-theme', next);
179 + $('themeToggle').innerHTML = next === 'light' ? UI.moon : UI.sun;
180 +}
181 +
182 +// ── Sidebar ──────────────────────────────────────────────────────────
183 +async function refreshSidebar() {
184 + const [treeRes, tagsRes, stats] = await Promise.all([
185 + getJSON('/api/v1/tree'), getJSON('/api/v1/tags'), getJSON('/api/v1/stats'),
186 + ]);
187 + state.tree = treeRes.folders;
188 + state.tags = tagsRes.tags;
189 +
190 + const sections = $('navSections');
191 + sections.innerHTML = '';
192 + const navs = [
193 + ['My Drive', UI.move, `folder/${state.rootId}`],
194 + ['Recent', UI.clock, 'recent'],
195 + ['Starred', UI.starO, 'starred'],
196 + ['Shared', UI.link, 'shared'],
197 + ['Activity', UI.activity, 'activity'],
198 + ['Trash', UI.trash, 'trash'],
199 + ];
200 + for (const [label, icon, hash] of navs) {
201 + sections.append(h('button.nav-item', { dataset: { nav: hash }, onclick: () => go(hash) },
202 + h('span', { html: icon, style: { display: 'contents' } }), label));
203 + }
204 +
205 + renderTree();
206 + renderTagList();
207 + renderStorageMeter(stats);
208 + highlightSidebar();
209 +}
210 +
211 +const expanded = new Set(JSON.parse(localStorage.getItem('spbdrive-expanded') ?? '[1]'));
212 +
213 +function renderTree() {
214 + const host = $('folderTree');
215 + host.innerHTML = '';
216 + const byParent = new Map();
217 + for (const f of state.tree) {
218 + if (!byParent.has(f.parent_id)) byParent.set(f.parent_id, []);
219 + byParent.get(f.parent_id).push(f);
220 + }
221 + const build = (parentId, depth) => {
222 + const frag = document.createDocumentFragment();
223 + for (const folder of byParent.get(parentId) ?? []) {
224 + const kids = byParent.get(folder.id) ?? [];
225 + const row = h('div.tree-row', {
226 + dataset: { folderId: folder.id, nav: `folder/${folder.id}` },
227 + style: { paddingLeft: `${depth * 14}px` },
228 + });
229 + const toggle = h(`button.tree-toggle${expanded.has(folder.id) ? '.open' : ''}`, {
230 + html: kids.length ? UI.chevron : '',
231 + tabindex: kids.length ? 0 : -1,
232 + onclick: (e) => {
233 + e.stopPropagation();
234 + expanded.has(folder.id) ? expanded.delete(folder.id) : expanded.add(folder.id);
235 + localStorage.setItem('spbdrive-expanded', JSON.stringify([...expanded]));
236 + renderTree();
237 + },
238 + });
239 + const label = h('button.tree-label', {
240 + onclick: () => go(`folder/${folder.id}`),
241 + oncontextmenu: (e) => {
242 + e.preventDefault();
243 + nodeContextMenu(e, { id: folder.id, name: folder.name || 'My Drive', type: 'folder', color: folder.color, emoji: folder.emoji, starred: false, tags: [] });
244 + },
245 + },
246 + h('span', { html: folderIcon(folder), style: { display: 'contents' } }),
247 + folder.id === state.rootId ? 'My Drive' : folder.name);
248 + row.append(toggle, label);
249 + makeFolderDropTarget(row, folder.id);
250 + frag.append(row);
251 + if (expanded.has(folder.id) && kids.length) frag.append(build(folder.id, depth + 1));
252 + }
253 + return frag;
254 + };
255 + host.append(build(null, 0));
256 +}
257 +
258 +function renderTagList() {
259 + const host = $('tagList');
260 + host.innerHTML = '';
261 + for (const tag of state.tags) {
262 + host.append(h('button.nav-item', { dataset: { nav: `tag/${tag.id}` }, onclick: () => go(`tag/${tag.id}`) },
263 + h('span', { html: `<svg viewBox="0 0 24 24" fill="${tag.color}" stroke="none" width="16" height="16"><circle cx="12" cy="12" r="6"/></svg>`, style: { display: 'contents' } }),
264 + tag.name,
265 + h('span.count', {}, String(tag.count ?? ''))));
266 + }
267 + host.append(h('button.nav-item', {
268 + onclick: () => editTagsDialog(),
269 + style: { color: 'var(--muted)' },
270 + }, h('span', { html: UI.plus, style: { display: 'contents' } }), 'Manage tags'));
271 +}
272 +
273 +const BUCKET_COLORS = { images: '#22d3aa', video: '#b48cff', audio: '#ff7ab8', docs: '#4f8cff', archives: '#ffb454', other: '#8b93a3' };
274 +
275 +function renderStorageMeter(stats) {
276 + const host = $('storageMeter');
277 + const total = stats.usedBytes || 1;
278 + host.innerHTML = '';
279 + host.append(
280 + h('div', { style: { fontSize: '12px', fontWeight: 600 } }, `${fmtSize(stats.usedBytes)} used`),
281 + h('div.bar', {}, stats.byType.map((b) =>
282 + h('i', { style: { width: `${(b.bytes / total) * 100}%`, background: BUCKET_COLORS[b.bucket] ?? '#8b93a3' }, title: `${b.bucket}: ${fmtSize(b.bytes)}` }))),
283 + h('div.legend', {}, stats.byType.filter((b) => b.bytes > 0).map((b) =>
284 + h('span', {}, h('span.dot', { style: { background: BUCKET_COLORS[b.bucket] } }), `${b.bucket} ${fmtSize(b.bytes)}`))),
285 + h('div', { style: { fontSize: '11px', color: 'var(--muted)', marginTop: '5px' } }, `${stats.nodeCount} items · ${stats.blobCount} unique blobs`),
286 + );
287 +}
288 +
289 +function highlightSidebar() {
290 + const key = state.route.view === 'folder' ? `folder/${state.route.id}` : `${state.route.view}${state.route.id !== undefined && state.route.view === 'tag' ? `/${state.route.id}` : ''}`;
291 + document.querySelectorAll('[data-nav]').forEach((el) => {
292 + el.classList.toggle('active', el.dataset.nav === key);
293 + });
294 +}
295 +
296 +// ── Views: folder / recent / starred / tag / search / trash ──────────
297 +async function loadFolder(id) {
298 + content.innerHTML = '<div class="grid">' + '<div class="skeleton"></div>'.repeat(8) + '</div>';
299 + let data;
300 + try {
301 + data = await getJSON(`/api/v1/nodes/${id}/children?sort=${state.sort}&dir=${state.dir}`);
302 + } catch {
303 + go(`folder/${state.rootId}`);
304 + return;
305 + }
306 + state.nodes = data.children;
307 + state.path = data.path;
308 + state.viewMode = state.viewModes[id] ?? state.viewMode;
309 + $('viewToggle').innerHTML = state.viewMode === 'grid' ? UI.listv : UI.grid;
310 + renderToolbar();
311 + renderNodes();
312 +}
313 +
314 +async function loadRecent() {
315 + const { items } = await getJSON('/api/v1/recent');
316 + showFlatList('Recent', items, { empty: 'Nothing recent yet' });
317 +}
318 +
319 +async function loadStarred() {
320 + const { items } = await getJSON('/api/v1/starred');
321 + showFlatList('Starred', items, { empty: 'Star files and folders to find them here fast' });
322 +}
323 +
324 +async function loadTag(tagId) {
325 + const { items } = await getJSON(`/api/v1/tags/${tagId}/nodes`);
326 + const tag = state.tags.find((t) => t.id === tagId);
327 + showFlatList(`Tag: ${tag?.name ?? ''}`, items, { empty: 'No items carry this tag' });
328 +}
329 +
330 +function showFlatList(title, items, { empty }) {
331 + state.nodes = items;
332 + state.path = [];
333 + toolbar.innerHTML = '';
334 + toolbar.append(h('div.crumbs', {}, h('span.crumb.current', {}, title)), selectionToolbar());
335 + content.innerHTML = '';
336 + if (!items.length) {
337 + content.append(emptyState('search', empty, ''));
338 + return;
339 + }
340 + renderNodes();
341 +}
342 +
343 +async function loadSearch(q) {
344 + $('searchInput').value = q;
345 + const filters = state.route.q ?? new URLSearchParams();
346 + const params = new URLSearchParams({ q });
347 + for (const [k, v] of filters) params.set(k, v);
348 + const { results } = await getJSON(`/api/v1/search?${params}`);
349 + state.nodes = results;
350 + state.path = [];
351 + toolbar.innerHTML = '';
352 + const chips = h('div', { style: { display: 'flex', gap: '6px', flexWrap: 'wrap' } });
353 + const filterDefs = [
354 + ['type', ['image', 'video', 'audio', 'doc', 'archive', 'folder']],
355 + ];
356 + for (const [key, values] of filterDefs) {
357 + for (const value of values) {
358 + const active = filters.get(key) === value;
359 + chips.append(h(`button.chip${active ? '.active' : ''}`, {
360 + onclick: () => {
361 + const p = new URLSearchParams(filters);
362 + active ? p.delete(key) : p.set(key, value);
363 + location.hash = `#/search/${encodeURIComponent(q)}?${p}`;
364 + },
365 + }, value));
366 + }
367 + }
368 + for (const flag of ['starred', 'shared']) {
369 + const active = filters.get(flag) === 'true';
370 + chips.append(h(`button.chip${active ? '.active' : ''}`, {
371 + onclick: () => {
372 + const p = new URLSearchParams(filters);
373 + active ? p.delete(flag) : p.set(flag, 'true');
374 + location.hash = `#/search/${encodeURIComponent(q)}?${p}`;
375 + },
376 + }, flag));
377 + }
378 + toolbar.append(
379 + h('div.crumbs', {}, h('span.crumb.current', {}, `Search “${q}” — ${results.length} result${results.length === 1 ? '' : 's'}`)),
380 + selectionToolbar(),
381 + );
382 + content.innerHTML = '';
383 + content.append(chips, h('div', { style: { height: '12px' } }));
384 + if (!results.length) {
385 + content.append(emptyState('search', 'No results', 'Try other words, or check filters'));
386 + return;
387 + }
388 + renderNodes(true);
389 +}
390 +
391 +async function loadTrash() {
392 + const { items } = await getJSON('/api/v1/trash');
393 + state.nodes = items;
394 + state.path = [];
395 + toolbar.innerHTML = '';
396 + toolbar.append(
397 + h('div.crumbs', {}, h('span.crumb.current', {}, 'Trash'), h('span.muted', { style: { fontSize: '12px', marginLeft: '10px' } }, 'Items are deleted forever after 30 days')),
398 + items.length ? h('button.btn.danger', {
399 + onclick: async () => {
400 + if (await confirmModal({ title: 'Empty trash', message: `Permanently delete ${items.length} item(s)? This cannot be undone.`, confirmLabel: 'Delete forever', danger: true, typed: 'DELETE' })) {
401 + await post('/api/v1/trash/empty', {});
402 + toast('Trash emptied');
403 + loadTrash();
404 + refreshSidebar();
405 + }
406 + },
407 + }, 'Empty trash') : null,
408 + );
409 + content.innerHTML = '';
410 + if (!items.length) {
411 + content.append(emptyState('trash', 'Trash is empty', 'Deleted items land here for 30 days'));
412 + return;
413 + }
414 + renderNodes();
415 +}
416 +
417 +// ── Toolbar (breadcrumbs + sort + selection ops) ─────────────────────
418 +function renderToolbar() {
419 + toolbar.innerHTML = '';
420 + const crumbs = h('div.crumbs');
421 + state.path.forEach((part, i) => {
422 + if (i > 0) crumbs.append(h('span.crumb-sep', {}, '›'));
423 + const isLast = i === state.path.length - 1;
424 + const crumb = h(`button.crumb${isLast ? '.current' : ''}`, {
425 + onclick: () => go(`folder/${part.id}`),
426 + }, part.name || 'My Drive');
427 + makeFolderDropTarget(crumb, part.id);
428 + crumbs.append(crumb);
429 + });
430 + const sortSel = h('select', {
431 + style: { width: 'auto' },
432 + onchange: (e) => {
433 + const [sort, dir] = e.target.value.split(':');
434 + state.sort = sort; state.dir = dir;
435 + loadFolder(currentFolderId());
436 + },
437 + }, ...[['name:asc', 'Name ↑'], ['name:desc', 'Name ↓'], ['modified:desc', 'Newest'], ['modified:asc', 'Oldest'], ['size:desc', 'Largest'], ['size:asc', 'Smallest'], ['type:asc', 'Type']]
438 + .map(([v, l]) => h('option', { value: v, selected: `${state.sort}:${state.dir}` === v }, l)));
439 +
440 + const slider = state.viewMode === 'grid'
441 + ? h('input', {
442 + type: 'range', min: 120, max: 260, step: 35, value: state.cardSize,
443 + title: 'Thumbnail size', style: { width: '90px' },
444 + oninput: (e) => {
445 + state.cardSize = Number(e.target.value);
446 + localStorage.setItem('spbdrive-card', e.target.value);
447 + content.querySelector('.grid')?.style.setProperty('--card', `${state.cardSize}px`);
448 + },
449 + })
450 + : null;
451 +
452 + toolbar.append(crumbs, selectionToolbar(), sortSel, slider ?? '');
453 +}
454 +
455 +function selectionToolbar() {
456 + const host = h('div.sel-toolbar');
457 + updateSelectionToolbar(host);
458 + return host;
459 +}
460 +
461 +function updateSelectionToolbar(host) {
462 + host = host ?? toolbar.querySelector('.sel-toolbar');
463 + if (!host) return;
464 + host.innerHTML = '';
465 + const n = state.selection.size;
466 + if (!n) return;
467 + const inTrash = state.route.view === 'trash';
468 + host.append(h('span.sel-count', {}, `${n} selected`));
469 + const ids = [...state.selection];
470 + if (inTrash) {
471 + host.append(
472 + h('button.btn', { onclick: () => bulkRestore(ids) }, 'Restore'),
473 + h('button.btn.danger', { onclick: () => bulkDeleteForever(ids) }, 'Delete forever'),
474 + );
475 + } else {
476 + host.append(
477 + h('button.btn.icon', { title: 'Download', html: UI.download, onclick: () => downloadIds(ids) }),
478 + h('button.btn.icon', { title: 'Move', html: UI.move, onclick: () => moveDialog(ids) }),
479 + h('button.btn.icon', { title: 'Trash (Del)', html: UI.trash, onclick: () => bulkTrash(ids) }),
480 + );
481 + }
482 +}
483 +
484 +// ── Node rendering (grid + list) ─────────────────────────────────────
485 +function nodeById(id) { return state.nodes.find((n) => n.id === id); }
486 +
487 +function renderNodes(withSnippets = false) {
488 + const existingChips = state.route.view === 'search' ? [...content.children].slice(0, 2) : [];
489 + content.innerHTML = '';
490 + existingChips.forEach((c) => content.append(c));
491 +
492 + if (!state.nodes.length && state.route.view === 'folder') {
493 + content.append(emptyState('folder', 'This folder is empty', 'Drop files anywhere, or press Upload'));
494 + return;
495 + }
496 +
497 + const host = state.viewMode === 'grid' ? renderGrid() : renderList(withSnippets);
498 + content.append(host);
499 + wireRectangleSelect(host);
500 + updateSelectionToolbar();
501 +}
502 +
503 +function thumbEl(node) {
504 + if (node.hasThumb) {
505 + const img = h('img', { loading: 'lazy', src: `/thumb/${node.id}?size=256`, alt: '' });
506 + img.onerror = () => img.replaceWith(h('span', { html: nodeIcon(node), style: { display: 'contents' } }));
507 + return img;
508 + }
509 + return h('span', { html: nodeIcon(node), style: { display: 'contents' } });
510 +}
511 +
512 +function renderGrid() {
513 + const grid = h('div.grid', { style: { '--card': `${state.cardSize}px` } });
514 + for (const node of state.nodes) {
515 + const card = h('div.card', { dataset: { id: node.id }, tabindex: 0 },
516 + h('div.thumb', {}, thumbEl(node)),
517 + h('div.meta', {},
518 + h('span', { html: nodeIcon(node), style: { display: 'contents' } }),
519 + h('span.name', { title: node.name }, node.name),
520 + ),
521 + );
522 + if (node.starred) card.append(h('span.star-ind', { html: UI.star }));
523 + if (node.tags?.length) card.append(h('span.tag-strip', { style: { background: node.tags[0].color } }));
524 + wireNode(card, node);
525 + grid.append(card);
526 + }
527 + return grid;
528 +}
529 +
530 +function renderList(withSnippets) {
531 + const list = h('div.list');
532 + const headBtn = (label, key) => h('button', {
533 + onclick: () => {
534 + state.dir = state.sort === key && state.dir === 'asc' ? 'desc' : 'asc';
535 + state.sort = key;
536 + state.route.view === 'folder' ? loadFolder(currentFolderId()) : sortLocal();
537 + },
538 + }, label, state.sort === key ? (state.dir === 'asc' ? ' ↑' : ' ↓') : '');
539 + list.append(h('div.list-header', {},
540 + h('span'), headBtn('Name', 'name'), headBtn('Size', 'size'), headBtn('Type', 'type'), headBtn('Modified', 'modified'), h('span', {}, 'Tags')));
541 + for (const node of state.nodes) {
542 + const row = h('div.row', { dataset: { id: node.id }, tabindex: 0 },
543 + h('span.star-cell', { html: node.starred ? UI.star : '' }),
544 + h('div.name', {},
545 + h('span', { html: nodeIcon(node), style: { display: 'contents' } }),
546 + h('span', { title: node.name }, node.name),
547 + ),
548 + h('span.cell', {}, node.type === 'folder' ? '—' : fmtSize(node.size)),
549 + h('span.cell', {}, node.type === 'folder' ? 'Folder' : (node.mime?.split('/')[1] ?? node.strategy ?? 'file')),
550 + h('span.cell', {}, fmtDate(node.modified)),
551 + h('span.rowtags', {}, (node.tags ?? []).slice(0, 3).map((t) =>
552 + h('span.chip', { style: { borderColor: t.color, color: t.color } }, t.name))),
553 + );
554 + if (withSnippets && node.snippet) {
555 + row.append(h('div', { style: { gridColumn: '2 / -1', fontSize: '12px', color: 'var(--muted)' }, html: node.snippet }));
556 + }
557 + wireNode(row, node);
558 + list.append(row);
559 + }
560 + return list;
561 +}
562 +
563 +function sortLocal() {
564 + const dir = state.dir === 'asc' ? 1 : -1;
565 + const key = state.sort;
566 + state.nodes.sort((a, b) => {
567 + if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
568 + if (key === 'size' || key === 'modified') return (a[key] - b[key]) * dir;
569 + return String(a[key === 'type' ? 'mime' : key] ?? '').localeCompare(String(b[key === 'type' ? 'mime' : key] ?? '')) * dir;
570 + });
571 + renderNodes();
572 +}
573 +
574 +function emptyState(art, title, subtitle) {
575 + return h('div.empty', {}, h('div', {},
576 + h('div', { html: EMPTY_ART[art] ?? EMPTY_ART.folder }),
577 + h('h3', {}, title),
578 + h('p', {}, subtitle)));
579 +}
580 +
581 +// ── Node interactions: select, open, dnd, context menu ───────────────
582 +function wireNode(el, node) {
583 + el.addEventListener('click', (e) => {
584 + e.stopPropagation();
585 + if (e.shiftKey && state.anchor !== null) {
586 + const ids = state.nodes.map((n) => n.id);
587 + const a = ids.indexOf(state.anchor);
588 + const b = ids.indexOf(node.id);
589 + state.selection = new Set(ids.slice(Math.min(a, b), Math.max(a, b) + 1));
590 + } else if (e.metaKey || e.ctrlKey) {
591 + state.selection.has(node.id) ? state.selection.delete(node.id) : state.selection.add(node.id);
592 + state.anchor = node.id;
593 + } else {
594 + state.selection = new Set([node.id]);
595 + state.anchor = node.id;
596 + }
597 + paintSelection();
598 + showInfo(node);
599 + });
600 +
601 + el.addEventListener('dblclick', () => openNode(node));
602 + el.addEventListener('keydown', (e) => {
603 + if (e.key === 'Enter') openNode(node);
604 + });
605 +
606 + el.addEventListener('contextmenu', (e) => {
607 + e.preventDefault();
608 + if (!state.selection.has(node.id)) {
609 + state.selection = new Set([node.id]);
610 + state.anchor = node.id;
611 + paintSelection();
612 + }
613 + nodeContextMenu(e, node);
614 + });
615 +
616 + // Drag to move
617 + el.draggable = state.route.view !== 'trash';
618 + el.addEventListener('dragstart', (e) => {
619 + if (!state.selection.has(node.id)) {
620 + state.selection = new Set([node.id]);
621 + paintSelection();
622 + }
623 + e.dataTransfer.setData('application/x-spbdrive-ids', JSON.stringify([...state.selection]));
624 + e.dataTransfer.effectAllowed = 'move';
625 + });
626 + if (node.type === 'folder') makeFolderDropTarget(el, node.id);
627 +}
628 +
629 +function makeFolderDropTarget(el, folderId) {
630 + el.addEventListener('dragover', (e) => {
631 + if (![...e.dataTransfer.types].includes('application/x-spbdrive-ids')) return;
632 + e.preventDefault();
633 + e.dataTransfer.dropEffect = 'move';
634 + el.classList.add('drop-target');
635 + });
636 + el.addEventListener('dragleave', () => el.classList.remove('drop-target'));
637 + el.addEventListener('drop', async (e) => {
638 + el.classList.remove('drop-target');
639 + const raw = e.dataTransfer.getData('application/x-spbdrive-ids');
640 + if (!raw) return;
641 + e.preventDefault();
642 + e.stopPropagation();
643 + const ids = JSON.parse(raw).filter((id) => id !== folderId);
644 + if (ids.length) await moveIds(ids, folderId);
645 + });
646 +}
647 +
648 +function paintSelection() {
649 + content.querySelectorAll('[data-id]').forEach((el) => {
650 + el.classList.toggle('selected', state.selection.has(Number(el.dataset.id)));
651 + });
652 + updateSelectionToolbar();
653 +}
654 +
655 +function openNode(node) {
656 + if (node.type === 'folder') {
657 + if (state.route.view === 'trash') return;
658 + go(`folder/${node.id}`);
659 + return;
660 + }
661 + openViewer(node);
662 +}
663 +
664 +function openViewer(node) {
665 + const files = state.nodes.filter((n) => n.type === 'file');
666 + const index = files.findIndex((n) => n.id === node.id);
667 + const viewer = new Viewer(files, Math.max(index, 0), {
668 + descUrl: (n) => `/api/v1/preview/${n.id}`,
669 + streamUrl: (n) => `/stream/${n.id}`,
670 + dlUrl: (n) => `/dl/${n.id}`,
671 + actions: [
672 + { title: 'Download', icon: UI.download, onClick: (n) => { location.href = `/dl/${n.id}`; } },
673 + { title: 'Share', icon: UI.share, onClick: (n) => shareDialog(n) },
674 + {
675 + title: 'Star', icon: UI.starO,
676 + onClick: async (n) => { await toggleStar(n); },
677 + },
678 + { title: 'Info', icon: UI.info, onClick: (n, v) => { v.close(); showInfo(n, true); } },
679 + {
680 + title: 'Delete', icon: UI.trash,
681 + onClick: async (n, v) => { v.close(); await bulkTrash([n.id]); },
682 + },
683 + ],
684 + });
685 + return viewer;
686 +}
687 +
688 +// ── Context menu ─────────────────────────────────────────────────────
689 +function nodeContextMenu(e, node) {
690 + const multi = state.selection.size > 1;
691 + const ids = multi ? [...state.selection] : [node.id];
692 + const inTrash = state.route.view === 'trash';
693 +
694 + if (inTrash) {
695 + contextMenu(e.clientX, e.clientY, [
696 + { label: multi ? `Restore ${ids.length} items` : 'Restore', icon: UI.restore, onClick: () => bulkRestore(ids) },
697 + { sep: true },
698 + { label: 'Delete forever', icon: UI.trash, danger: true, onClick: () => bulkDeleteForever(ids) },
699 + ]);
700 + return;
701 + }
702 +
703 + const colorRow = node.type === 'folder' && !multi
704 + ? h('div.ctx-colors', {}, FOLDER_COLORS.map((c) =>
705 + h('button', {
706 + style: { background: `var(--folder-${c})` }, title: c,
707 + onclick: async () => {
708 + const { closeContextMenu } = await import('./ui.js');
709 + closeContextMenu();
710 + await patch(`/api/v1/nodes/${node.id}`, { color: c });
711 + refreshCurrent(); refreshSidebar();
712 + },
713 + })))
714 + : null;
715 +
716 + contextMenu(e.clientX, e.clientY, [
717 + !multi && { label: node.type === 'folder' ? 'Open' : 'Preview', icon: UI.eye, kbd: '↵', onClick: () => openNode(node) },
718 + !multi && node.type === 'file' && { label: 'Download', icon: UI.download, onClick: () => { location.href = `/dl/${node.id}`; } },
719 + multi && { label: `Download ${ids.length} as ZIP`, icon: UI.download, onClick: () => downloadIds(ids) },
720 + { label: 'Share', icon: UI.share, onClick: () => shareDialog(node) },
721 + { sep: true },
722 + !multi && { label: 'Rename', icon: UI.rename, kbd: 'F2', onClick: () => inlineRename(node) },
723 + { label: 'Move to…', icon: UI.move, onClick: () => moveDialog(ids) },
724 + !multi && { label: 'Duplicate', icon: UI.duplicate, onClick: () => duplicateNode(node) },
725 + { label: node.starred && !multi ? 'Unstar' : 'Star', icon: UI.starO, kbd: 'S', onClick: () => Promise.all(ids.map((id) => toggleStar(nodeById(id) ?? node))).then(refreshCurrent) },
726 + { label: 'Tags…', icon: UI.tag, onClick: () => tagDialog(node) },
727 + !multi && { label: 'Details', icon: UI.info, onClick: () => showInfo(node, true) },
728 + colorRow && { sep: true },
729 + colorRow && { custom: colorRow },
730 + node.type === 'folder' && !multi && { custom: emojiRow(node) },
731 + { sep: true },
732 + { label: multi ? `Move ${ids.length} to trash` : 'Move to trash', icon: UI.trash, danger: true, kbd: 'Del', onClick: () => bulkTrash(ids) },
733 + ].filter(Boolean));
734 +}
735 +
736 +function emojiRow(node) {
737 + const emojis = ['📁', '📸', '🎬', '🎵', '💼', '🧠', '🚀', '❤️', ''];
738 + return h('div.ctx-colors', {}, emojis.map((em) =>
739 + h('button', {
740 + style: { background: 'var(--surface-2)', fontSize: '12px' }, title: em || 'none',
741 + onclick: async () => {
742 + const { closeContextMenu } = await import('./ui.js');
743 + closeContextMenu();
744 + await patch(`/api/v1/nodes/${node.id}`, { emoji: em || null });
745 + refreshCurrent(); refreshSidebar();
746 + },
747 + }, em || '∅')));
748 +}
749 +
750 +// ── Operations ───────────────────────────────────────────────────────
751 +function refreshCurrent() { onRoute(); }
752 +
753 +async function newFolderDialog() {
754 + const input = h('input', { type: 'text', placeholder: 'Folder name' });
755 + modal({
756 + title: 'New folder',
757 + body: h('div', {}, input),
758 + actions: [
759 + { label: 'Cancel', onClick: () => {} },
760 + {
761 + label: 'Create', primary: true,
762 + onClick: async () => {
763 + const name = input.value.trim();
764 + if (!name) return false;
765 + await post('/api/v1/nodes', { parentId: currentFolderId(), name, type: 'folder' });
766 + refreshCurrent(); refreshSidebar();
767 + return true;
768 + },
769 + },
770 + ],
771 + });
772 +}
773 +
774 +async function toggleStar(node) {
775 + const next = !node.starred;
776 + node.starred = next; // optimistic
777 + paintSelection();
778 + try {
779 + await patch(`/api/v1/nodes/${node.id}`, { starred: next });
780 + } catch {
781 + node.starred = !next;
782 + toast('Could not update star', { error: true });
783 + }
784 + refreshCurrent();
785 +}
786 +
787 +function inlineRename(node) {
788 + const el = content.querySelector(`[data-id="${node.id}"]`);
789 + const nameEl = el?.querySelector('.name span:last-child, .meta .name');
790 + if (!nameEl) return;
791 + const input = h('input.rename-input', { type: 'text', value: node.name });
792 + nameEl.replaceWith(input);
793 + input.focus();
794 + const dot = node.name.lastIndexOf('.');
795 + input.setSelectionRange(0, node.type === 'file' && dot > 0 ? dot : node.name.length);
796 + const done = async (commit) => {
797 + input.onblur = null;
798 + const name = input.value.trim();
799 + if (commit && name && name !== node.name) {
800 + try {
801 + await patch(`/api/v1/nodes/${node.id}`, { name });
802 + node.name = name;
803 + } catch (err) {
804 + toast(err.message, { error: true });
805 + }
806 + }
807 + refreshCurrent();
808 + if (node.type === 'folder') refreshSidebar();
809 + };
810 + input.onblur = () => done(true);
811 + input.onkeydown = (e) => {
812 + e.stopPropagation();
813 + if (e.key === 'Enter') done(true);
814 + if (e.key === 'Escape') done(false);
815 + };
816 + input.onclick = (e) => e.stopPropagation();
817 +}
818 +
819 +async function duplicateNode(node) {
820 + await post(`/api/v1/nodes/${node.id}/duplicate`, {});
821 + toast('Duplicated');
822 + refreshCurrent();
823 +}
824 +
825 +async function bulkTrash(ids) {
826 + for (const id of ids) await del(`/api/v1/nodes/${id}`);
827 + state.selection.clear();
828 + toast(`Moved ${ids.length > 1 ? `${ids.length} items` : 'item'} to trash`, {
829 + actionLabel: 'Undo',
830 + onAction: async () => {
831 + for (const id of ids) await post(`/api/v1/nodes/${id}/restore`, {});
832 + refreshCurrent(); refreshSidebar();
833 + },
834 + });
835 + refreshCurrent(); refreshSidebar();
836 +}
837 +
838 +async function bulkRestore(ids) {
839 + for (const id of ids) await post(`/api/v1/nodes/${id}/restore`, {});
840 + state.selection.clear();
841 + toast('Restored');
842 + refreshCurrent(); refreshSidebar();
843 +}
844 +
845 +async function bulkDeleteForever(ids) {
846 + const ok = await confirmModal({
847 + title: 'Delete forever',
848 + message: `Permanently delete ${ids.length} item(s)? This cannot be undone.`,
849 + confirmLabel: 'Delete forever',
850 + danger: true,
851 + });
852 + if (!ok) return;
853 + for (const id of ids) await del(`/api/v1/nodes/${id}?force=true`);
854 + state.selection.clear();
855 + toast('Deleted forever');
856 + refreshCurrent(); refreshSidebar();
857 +}
858 +
859 +function downloadIds(ids) {
860 + if (ids.length === 1) {
861 + const node = nodeById(ids[0]);
862 + if (node?.type === 'file') { location.href = `/dl/${node.id}`; return; }
863 + }
864 + location.href = `/api/v1/zip?ids=${ids.join(',')}`;
865 +}
866 +
867 +async function moveIds(ids, targetId) {
868 + let skipped = 0;
869 + for (const id of ids) {
870 + try {
871 + await patch(`/api/v1/nodes/${id}`, { parentId: targetId });
872 + } catch (err) {
873 + skipped += 1;
874 + if (err.status === 409) {
875 + const choice = await conflictDialog(nodeById(id)?.name ?? 'item');
876 + if (choice && choice !== 'skip') {
877 + await patch(`/api/v1/nodes/${id}`, { parentId: targetId, conflict: choice });
878 + skipped -= 1;
879 + }
880 + } else toast(err.message, { error: true });
881 + }
882 + }
883 + state.selection.clear();
884 + toast(`Moved ${ids.length - skipped} item(s)`);
885 + refreshCurrent(); refreshSidebar();
886 +}
887 +
888 +function conflictDialog(name) {
889 + return new Promise((resolve) => {
890 + modal({
891 + title: 'Name conflict',
892 + body: h('p', { style: { color: 'var(--muted)' } }, `"${name}" already exists in the destination.`),
893 + onClose: () => resolve(null),
894 + actions: [
895 + { label: 'Skip', onClick: () => resolve('skip') },
896 + { label: 'Replace', danger: true, onClick: () => resolve('replace') },
897 + { label: 'Keep both', primary: true, onClick: () => resolve('keep-both') },
898 + ],
899 + });
900 + });
901 +}
902 +
903 +function moveDialog(ids) {
904 + const tree = h('div.picker-tree');
905 + let chosen = state.rootId;
906 + const byParent = new Map();
907 + for (const f of state.tree) {
908 + if (!byParent.has(f.parent_id)) byParent.set(f.parent_id, []);
909 + byParent.get(f.parent_id).push(f);
910 + }
911 + const build = (parentId, depth) => {
912 + for (const folder of byParent.get(parentId) ?? []) {
913 + if (ids.includes(folder.id)) continue; // can't move into itself
914 + const row = h('button.nav-item', {
915 + style: { paddingLeft: `${12 + depth * 16}px` },
916 + onclick: (e) => {
917 + tree.querySelectorAll('.active').forEach((n) => n.classList.remove('active'));
918 + e.currentTarget.classList.add('active');
919 + chosen = folder.id;
920 + },
921 + },
922 + h('span', { html: folderIcon(folder), style: { display: 'contents' } }),
923 + folder.id === state.rootId ? 'My Drive' : folder.name);
924 + tree.append(row);
925 + build(folder.id, depth + 1);
926 + }
927 + };
928 + build(null, 0);
929 + modal({
930 + title: `Move ${ids.length} item(s)`,
931 + body: tree,
932 + actions: [
933 + { label: 'Cancel', onClick: () => {} },
934 + { label: 'Copy here', onClick: async () => { for (const id of ids) await post(`/api/v1/nodes/${id}/copy`, { parentId: chosen }); toast('Copied'); refreshCurrent(); refreshSidebar(); } },
935 + { label: 'Move here', primary: true, onClick: () => moveIds(ids, chosen) },
936 + ],
937 + });
938 +}
939 +
940 +// ── Tags ─────────────────────────────────────────────────────────────
941 +async function tagDialog(node) {
942 + const { tags } = await getJSON('/api/v1/tags');
943 + const current = new Set((node.tags ?? []).map((t) => t.id));
944 + const list = h('div.tag-editor', {}, tags.map((tag) =>
945 + h(`button.chip${current.has(tag.id) ? '.active' : ''}`, {
946 + onclick: (e) => {
947 + current.has(tag.id) ? current.delete(tag.id) : current.add(tag.id);
948 + e.currentTarget.classList.toggle('active');
949 + },
950 + }, h('span.dot', { style: { background: tag.color } }), tag.name)));
951 + const newInput = h('input', { type: 'text', placeholder: 'New tag name…', style: { marginTop: '12px' } });
952 + newInput.addEventListener('keydown', async (e) => {
953 + if (e.key !== 'Enter' || !newInput.value.trim()) return;
954 + const { tag } = await post('/api/v1/tags', { name: newInput.value.trim(), color: randomTagColor() });
955 + current.add(tag.id);
956 + list.append(h('button.chip.active', {}, h('span.dot', { style: { background: tag.color } }), tag.name));
957 + newInput.value = '';
958 + });
959 + modal({
960 + title: `Tags — ${node.name}`,
961 + body: h('div', {}, list, newInput),
962 + actions: [
963 + { label: 'Cancel', onClick: () => {} },
964 + {
965 + label: 'Save', primary: true,
966 + onClick: async () => {
967 + await patch(`/api/v1/nodes/${node.id}`, { tagIds: [...current] });
968 + refreshCurrent(); refreshSidebar();
969 + },
970 + },
971 + ],
972 + });
973 +}
974 +
975 +function randomTagColor() {
976 + const palette = ['#4f8cff', '#22d3aa', '#7bd88f', '#ffd166', '#ff9f5a', '#ff5d5d', '#b48cff', '#ff7ab8'];
977 + return palette[Math.floor(Math.random() * palette.length)];
978 +}
979 +
980 +async function editTagsDialog() {
981 + const { tags } = await getJSON('/api/v1/tags');
982 + const list = h('div');
983 + for (const tag of tags) {
984 + const nameInput = h('input', { type: 'text', value: tag.name, style: { width: '160px' } });
985 + const colorInput = h('input', { type: 'color', value: tag.color, style: { width: '42px', padding: '2px' } });
986 + list.append(h('div', { style: { display: 'flex', gap: '8px', marginBottom: '8px', alignItems: 'center' } },
987 + colorInput, nameInput,
988 + h('button.btn.icon.ghost', {
989 + title: 'Save', html: UI.check,
990 + onclick: async () => { await patch(`/api/v1/tags/${tag.id}`, { name: nameInput.value, color: colorInput.value }); toast('Tag saved'); refreshSidebar(); },
991 + }),
992 + h('button.btn.icon.ghost.danger', {
993 + title: 'Delete', html: UI.trash,
994 + onclick: async () => { await del(`/api/v1/tags/${tag.id}`); toast('Tag deleted'); refreshSidebar(); list.querySelector(`[data-tag="${tag.id}"]`)?.remove(); },
995 + })));
996 + }
997 + const newInput = h('input', { type: 'text', placeholder: 'New tag — press Enter' });
998 + newInput.addEventListener('keydown', async (e) => {
999 + if (e.key === 'Enter' && newInput.value.trim()) {
1000 + await post('/api/v1/tags', { name: newInput.value.trim(), color: randomTagColor() });
1001 + refreshSidebar();
1002 + toast('Tag created');
1003 + newInput.value = '';
1004 + }
1005 + });
1006 + modal({
1007 + title: 'Manage tags',
1008 + body: h('div', {}, list, newInput),
1009 + actions: [{ label: 'Done', primary: true, onClick: () => {} }],
1010 + });
1011 +}
1012 +
1013 +// ── Info panel ───────────────────────────────────────────────────────
1014 +function showInfo(node, force = false) {
1015 + state.infoNode = node;
1016 + if (force) $('body').classList.add('info-open');
1017 + updateInfoPanel();
1018 +}
1019 +
1020 +async function updateInfoPanel() {
1021 + const panel = $('infoPanel');
1022 + const node = state.infoNode;
1023 + if (!node) {
1024 + panel.innerHTML = '';
1025 + $('body').classList.remove('info-open');
1026 + return;
1027 + }
1028 + if (!$('body').classList.contains('info-open')) return;
1029 + panel.innerHTML = '';
1030 + panel.append(
1031 + h('div', { style: { display: 'flex', justifyContent: 'flex-end' } },
1032 + h('button.btn.icon.ghost', { html: UI.close, onclick: () => { state.infoNode = null; updateInfoPanel(); } })),
1033 + h('div.info-thumb', {}, node.type === 'file' && node.hasThumb
1034 + ? h('img', { src: `/thumb/${node.id}?size=512` })
1035 + : h('span', { html: nodeIcon(node), style: { display: 'contents' } })),
1036 + h('h3', {}, node.name),
1037 + );
1038 + const kv = h('dl.kv');
1039 + const add = (k, v) => kv.append(h('dt', {}, k), h('dd', {}, v));
1040 + add('Type', node.type === 'folder' ? 'Folder' : (node.mime ?? 'file'));
1041 + if (node.type === 'file') add('Size', fmtSize(node.size));
1042 + add('Created', fmtDate(node.created));
1043 + add('Modified', fmtDate(node.modified));
1044 + panel.append(kv);
1045 +
1046 + // Tags section
1047 + const tagWrap = h('div.tag-editor', {}, (node.tags ?? []).map((t) =>
1048 + h('span.chip.active', { style: { borderColor: t.color, color: t.color } }, t.name)));
1049 + tagWrap.append(h('button.chip', { onclick: () => tagDialog(node) }, '+ edit'));
1050 + panel.append(h('div.info-section', {}, h('h4', {}, 'Tags'), tagWrap));
1051 +
1052 + // Shares section
1053 + try {
1054 + const { shares } = await getJSON(`/api/v1/nodes/${node.id}/shares`);
1055 + const wrap = h('div');
1056 + for (const share of shares) {
1057 + wrap.append(h('div', { style: { display: 'flex', gap: '6px', alignItems: 'center', marginBottom: '6px', fontSize: '12px' } },
1058 + h('span.mono', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' } }, `/s/${share.token}`),
1059 + h('button.btn.icon.ghost', { html: UI.copy, title: 'Copy URL', onclick: () => copyText(share.url, 'Share URL copied') }),
1060 + h('button.btn.icon.ghost.danger', { html: UI.close, title: 'Revoke', onclick: async () => { await del(`/api/v1/shares/${share.id}`); toast('Share revoked'); updateInfoPanel(); } })));
1061 + }
1062 + wrap.append(h('button.btn', { style: { marginTop: '4px' }, onclick: () => shareDialog(node) },
1063 + h('span', { html: UI.share, style: { display: 'contents' } }), 'New share link'));
1064 + panel.append(h('div.info-section', {}, h('h4', {}, `Shares (${shares.length})`), wrap));
1065 + } catch {}
1066 +}
1067 +
1068 +// ── Share dialog & manager ───────────────────────────────────────────
1069 +function shareDialog(node) {
1070 + const expiry = h('select', {},
1071 + ...[['', 'Never'], ['3600000', '1 hour'], ['86400000', '1 day'], ['604800000', '7 days'], ['2592000000', '30 days']]
1072 + .map(([v, l]) => h('option', { value: v, selected: v === '604800000' }, l)));
1073 + const password = h('input', { type: 'text', placeholder: 'Optional password', autocomplete: 'off' });
1074 + const maxDl = h('input', { type: 'number', min: 1, placeholder: 'Unlimited' });
1075 + const allowDl = h('input', { type: 'checkbox', checked: true });
1076 + const label = h('input', { type: 'text', placeholder: 'Note to self (optional)' });
1077 + const result = h('div');
1078 +
1079 + modal({
1080 + title: `Share — ${node.name}`,
1081 + body: h('div', {},
1082 + h('label.field', {}, h('span', {}, 'Expires'), expiry),
1083 + h('label.field', {}, h('span', {}, 'Password'), password),
1084 + h('label.field', {}, h('span', {}, 'Max downloads'), maxDl),
1085 + h('label.field', { style: { display: 'flex', alignItems: 'center', gap: '8px' } }, allowDl, h('span', { style: { margin: 0 } }, 'Allow download (off = preview only)')),
1086 + h('label.field', {}, h('span', {}, 'Label'), label),
1087 + result),
1088 + actions: [
1089 + { label: 'Close', onClick: () => {} },
1090 + {
1091 + label: 'Create link', primary: true,
1092 + onClick: async (close) => {
1093 + const { share } = await post('/api/v1/shares', {
1094 + nodeId: node.id,
1095 + expiresAt: expiry.value ? Date.now() + Number(expiry.value) : null,
1096 + password: password.value || null,
1097 + maxDownloads: maxDl.value ? Number(maxDl.value) : null,
1098 + allowDownload: allowDl.checked,
1099 + label: label.value || null,
1100 + });
1101 + result.innerHTML = '';
1102 + result.append(
1103 + h('div.share-url', {},
1104 + h('input', { type: 'text', value: share.url, readonly: true, onclick: (e) => e.target.select() }),
1105 + h('button.btn.primary', { onclick: () => copyText(share.url, 'Share URL copied') }, 'Copy')),
1106 + h('div.share-qr', {}, h('img', { src: `/api/v1/shares/${share.id}/qr`, width: 160, height: 160, alt: 'QR code' })),
1107 + );
1108 + updateInfoPanel();
1109 + return false; // keep modal open to show the URL
1110 + },
1111 + },
1112 + ],
1113 + });
1114 +}
1115 +
1116 +async function loadShares() {
1117 + const { shares } = await getJSON('/api/v1/shares');
1118 + state.nodes = [];
1119 + toolbar.innerHTML = '';
1120 + toolbar.append(h('div.crumbs', {}, h('span.crumb.current', {}, `Shared links (${shares.filter((s) => !s.revokedAt).length} active)`)));
1121 + content.innerHTML = '';
1122 + if (!shares.length) {
1123 + content.append(emptyState('search', 'No share links yet', 'Right-click any file or folder → Share'));
1124 + return;
1125 + }
1126 + const table = h('table.shares-table', {},
1127 + h('thead', {}, h('tr', {},
1128 + ...['Item', 'Link', 'Visits', 'Downloads', 'Expires', 'Status', ''].map((c) => h('th', {}, c)))));
1129 + const tbody = h('tbody');
1130 + for (const share of shares) {
1131 + const expired = share.revokedAt || (share.expiresAt && share.expiresAt < Date.now());
1132 + const expiry = share.revokedAt ? 'revoked'
1133 + : !share.expiresAt ? 'never'
1134 + : share.expiresAt < Date.now() ? 'expired'
1135 + : countdown(share.expiresAt);
1136 + tbody.append(h('tr', { style: expired ? { opacity: 0.55 } : {} },
1137 + h('td', {}, h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
1138 + h('span', { html: share.nodeType === 'folder' ? folderIcon({}) : fileIcon('file'), style: { display: 'contents' } }),
1139 + h('span', {}, share.nodeName),
1140 + share.label ? h('span.chip', {}, share.label) : '')),
1141 + h('td', {}, h('div.url-cell', {},
1142 + h('span', {}, `/s/${share.token}`),
1143 + h('button.btn.icon.ghost', { html: UI.copy, title: 'Copy', onclick: () => copyText(share.url, 'URL copied') }),
1144 + h('button.btn.icon.ghost', { html: UI.qr, title: 'QR code', onclick: () => showQr(share) }))),
1145 + h('td', {}, String(share.visits)),
1146 + h('td', {}, `${share.downloads}${share.maxDownloads ? ` / ${share.maxDownloads}` : ''}`),
1147 + h('td', {}, expiry),
1148 + h('td', {}, h('span.chip', { style: expired ? { color: 'var(--danger)', borderColor: 'var(--danger)' } : { color: 'var(--accent-2)', borderColor: 'var(--accent-2)' } },
1149 + expired ? 'inactive' : share.hasPassword ? '🔒 active' : 'active')),
1150 + h('td', {}, h('div', { style: { display: 'flex', gap: '4px' } },
1151 + h('button.btn.icon.ghost', { html: UI.activity, title: 'Visit log', onclick: () => showShareEvents(share) }),
1152 + !share.revokedAt ? h('button.btn.icon.ghost.danger', {
1153 + html: UI.close, title: 'Revoke',
1154 + onclick: async () => { await del(`/api/v1/shares/${share.id}`); toast('Revoked'); loadShares(); },
1155 + }) : '')),
1156 + ));
1157 + }
1158 + table.append(tbody);
1159 + content.append(table);
1160 +}
1161 +
1162 +function countdown(ts) {
1163 + const diff = ts - Date.now();
1164 + if (diff < 3_600_000) return `${Math.max(1, Math.round(diff / 60_000))} min`;
1165 + if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)} h`;
1166 + return `${Math.round(diff / 86_400_000)} d`;
1167 +}
1168 +
1169 +function showQr(share) {
1170 + modal({
1171 + title: 'QR code',
1172 + body: h('div.share-qr', {}, h('img', { src: `/api/v1/shares/${share.id}/qr`, width: 220, height: 220, alt: 'QR code' }),
1173 + h('p.mono', { style: { fontSize: '11px', color: 'var(--muted)' } }, share.url)),
1174 + actions: [{ label: 'Close', primary: true, onClick: () => {} }],
1175 + });
1176 +}
1177 +
1178 +async function showShareEvents(share) {
1179 + const { events } = await getJSON(`/api/v1/shares/${share.id}/events`);
1180 + const list = h('div', { style: { maxHeight: '50vh', overflow: 'auto' } });
1181 + if (!events.length) list.append(h('p.muted', {}, 'No visits yet.'));
1182 + for (const ev of events) {
1183 + list.append(h('div.act-row', {},
1184 + h('span', { html: ev.kind === 'download' ? UI.download : UI.eye, style: { display: 'contents' } }),
1185 + h('div', {}, h('div', {}, `${ev.kind} · ${ev.ip || 'unknown ip'}`), h('div.ua', { style: { color: 'var(--muted)', fontSize: '11px' } }, ev.ua ?? '')),
1186 + h('span.when', {}, fmtDate(ev.ts))));
1187 + }
1188 + modal({ title: `Visits — /s/${share.token}`, body: list, wide: true, actions: [{ label: 'Close', primary: true, onClick: () => {} }] });
1189 +}
1190 +
1191 +// ── Activity ─────────────────────────────────────────────────────────
1192 +const ACT_ICONS = {
1193 + 'file.upload': UI.upload, 'file.download_zip': UI.download, 'folder.create': UI.folderNew,
1194 + 'node.rename': UI.rename, 'node.move': UI.move, 'node.copy': UI.copy, 'node.trash': UI.trash,
1195 + 'node.restore': UI.restore, 'node.delete_forever': UI.trash, 'node.star': UI.starO,
1196 + 'share.create': UI.share, 'share.visit': UI.eye, 'share.download': UI.download,
1197 + 'share.revoke': UI.close, 'auth.login': UI.check, 'auth.login_failed': UI.close,
1198 +};
1199 +
1200 +async function loadActivity() {
1201 + const { events } = await getJSON('/api/v1/activity?limit=300');
1202 + toolbar.innerHTML = '';
1203 + toolbar.append(h('div.crumbs', {}, h('span.crumb.current', {}, 'Activity')));
1204 + content.innerHTML = '';
1205 + const list = h('div.activity-list');
1206 + for (const ev of events) {
1207 + list.append(h('div.act-row', {},
1208 + h('span', { html: ACT_ICONS[ev.kind] ?? UI.activity, style: { display: 'contents' } }),
1209 + h('div', {},
1210 + h('div', {}, `${ev.kind.replace(/[._]/g, ' ')}${ev.node_name ? ` — ${ev.node_name}` : ''}`),
1211 + ev.detail || ev.ip ? h('div', { style: { color: 'var(--muted)', fontSize: '11.5px' } }, [ev.detail, ev.ip].filter(Boolean).join(' · ')) : ''),
1212 + h('span.when', {}, fmtDate(ev.ts))));
1213 + }
1214 + content.append(list.children.length ? list : emptyState('search', 'No activity yet', ''));
1215 +}
1216 +
1217 +// ── Settings ─────────────────────────────────────────────────────────
1218 +async function loadSettings() {
1219 + toolbar.innerHTML = '';
1220 + toolbar.append(h('div.crumbs', {}, h('span.crumb.current', {}, 'Settings')));
1221 + content.innerHTML = '';
1222 + const wrap = h('div.settings');
1223 +
1224 + // Change password
1225 + const cur = h('input', { type: 'password', autocomplete: 'current-password' });
1226 + const next = h('input', { type: 'password', autocomplete: 'new-password' });
1227 + const next2 = h('input', { type: 'password', autocomplete: 'new-password' });
1228 + wrap.append(h('div.panel', {},
1229 + h('h3', {}, 'Change password'),
1230 + h('label.field', {}, h('span', {}, 'Current password'), cur),
1231 + h('label.field', {}, h('span', {}, 'New password'), next),
1232 + h('label.field', {}, h('span', {}, 'Repeat new password'), next2),
1233 + h('button.btn.primary', {
1234 + onclick: async () => {
1235 + if (next.value !== next2.value) { toast('Passwords do not match', { error: true }); return; }
1236 + try {
1237 + await post('/api/v1/auth/password', { current: cur.value, next: next.value });
1238 + toast('Password changed');
1239 + cur.value = next.value = next2.value = '';
1240 + } catch (err) { toast(err.message, { error: true }); }
1241 + },
1242 + }, 'Update password')));
1243 +
1244 + // Sessions
1245 + const sessionsPanel = h('div.panel', {}, h('h3', {}, 'Active sessions'));
1246 + const renderSessions = async () => {
1247 + [...sessionsPanel.querySelectorAll('.session-row, .btn.danger')].forEach((n) => n.remove());
1248 + const { sessions } = await getJSON('/api/v1/auth/sessions');
1249 + for (const s of sessions) {
1250 + sessionsPanel.append(h('div.session-row', {},
1251 + h('div.who', {},
1252 + h('div', {}, `${s.ip || 'unknown ip'} ${s.remember ? '· remembered' : ''}`),
1253 + h('div.ua', {}, s.ua ?? '')),
1254 + h('span.muted', { style: { fontSize: '11.5px' } }, `seen ${fmtDate(s.last_seen)}`),
1255 + h('button.btn.icon.ghost.danger', {
1256 + html: UI.close, title: 'Revoke',
1257 + onclick: async () => { await del(`/api/v1/auth/sessions/${s.id}`); renderSessions(); },
1258 + })));
1259 + }
1260 + sessionsPanel.append(h('button.btn.danger', {
1261 + style: { marginTop: '12px' },
1262 + onclick: async () => {
1263 + if (await confirmModal({ title: 'Logout everywhere', message: 'Revoke every session including this one?', confirmLabel: 'Logout everywhere', danger: true })) {
1264 + await del('/api/v1/auth/sessions');
1265 + location.href = '/login';
1266 + }
1267 + },
1268 + }, 'Logout everywhere'));
1269 + };
1270 + renderSessions();
1271 + wrap.append(sessionsPanel);
1272 +
1273 + // API tokens
1274 + const tokensPanel = h('div.panel', {}, h('h3', {}, 'API tokens (CLI)'));
1275 + const renderTokens = async () => {
1276 + [...tokensPanel.querySelectorAll('.session-row, .tok-new')].forEach((n) => n.remove());
1277 + const { tokens } = await getJSON('/api/v1/auth/api-tokens');
1278 + for (const t of tokens) {
1279 + tokensPanel.append(h('div.session-row', {},
1280 + h('div.who', {}, h('div', {}, t.name), h('div.ua', {}, `created ${fmtDate(t.created)} · last used ${fmtDate(t.last_used)}`)),
1281 + h('button.btn.icon.ghost.danger', {
1282 + html: UI.close, title: 'Revoke',
1283 + onclick: async () => { await del(`/api/v1/auth/api-tokens/${t.id}`); renderTokens(); },
1284 + })));
1285 + }
1286 + const nameInput = h('input', { type: 'text', placeholder: 'Token name (e.g. laptop-cli)', style: { width: '220px' } });
1287 + tokensPanel.append(h('div.tok-new', { style: { display: 'flex', gap: '8px', marginTop: '12px' } },
1288 + nameInput,
1289 + h('button.btn.primary', {
1290 + onclick: async () => {
1291 + const { token } = await post('/api/v1/auth/api-tokens', { name: nameInput.value || 'token' });
1292 + modal({
1293 + title: 'API token created',
1294 + body: h('div', {},
1295 + h('p', { style: { color: 'var(--muted)', fontSize: '13px' } }, 'Copy it now — it will not be shown again. Use it with `spbdrive init`.'),
1296 + h('div.share-url', {},
1297 + h('input', { type: 'text', value: token, readonly: true, onclick: (e) => e.target.select() }),
1298 + h('button.btn.primary', { onclick: () => copyText(token, 'Token copied') }, 'Copy'))),
1299 + actions: [{ label: 'Done', primary: true, onClick: () => {} }],
1300 + });
1301 + renderTokens();
1302 + },
1303 + }, 'Create token')));
1304 + };
1305 + renderTokens();
1306 + wrap.append(tokensPanel);
1307 +
1308 + // Appearance + logout
1309 + wrap.append(h('div.panel', {},
1310 + h('h3', {}, 'Appearance'),
1311 + h('button.btn', { onclick: toggleTheme }, 'Toggle dark / light theme'),
1312 + h('span.muted', { style: { marginLeft: '10px', fontSize: '12px' } }, 'Preference is saved in this browser.')));
1313 + wrap.append(h('div.panel', {},
1314 + h('h3', {}, 'Session'),
1315 + h('a.btn', { href: '/logout' }, h('span', { html: UI.logout, style: { display: 'contents' } }), 'Log out')));
1316 +
1317 + content.append(wrap);
1318 +}
1319 +
1320 +// ── Rectangle select ─────────────────────────────────────────────────
1321 +function wireRectangleSelect(host) {
1322 + let start = null; let rect = null;
1323 + content.onpointerdown = (e) => {
1324 + if (e.button !== 0 || e.target.closest('[data-id], button, input, a, select')) return;
1325 + start = { x: e.clientX, y: e.clientY };
1326 + if (!e.metaKey && !e.ctrlKey && !e.shiftKey) {
1327 + state.selection.clear();
1328 + paintSelection();
1329 + }
1330 + };
1331 + content.onpointermove = (e) => {
1332 + if (!start) return;
1333 + if (!rect) {
1334 + if (Math.hypot(e.clientX - start.x, e.clientY - start.y) < 6) return;
1335 + rect = h('div.select-rect');
1336 + document.body.append(rect);
1337 + }
1338 + const x = Math.min(start.x, e.clientX); const y = Math.min(start.y, e.clientY);
1339 + const w = Math.abs(e.clientX - start.x); const hgt = Math.abs(e.clientY - start.y);
1340 + Object.assign(rect.style, { left: `${x}px`, top: `${y}px`, width: `${w}px`, height: `${hgt}px`, position: 'fixed' });
1341 + const box = { left: x, top: y, right: x + w, bottom: y + hgt };
1342 + host.querySelectorAll('[data-id]').forEach((el) => {
1343 + const r = el.getBoundingClientRect();
1344 + const hit = !(r.right < box.left || r.left > box.right || r.bottom < box.top || r.top > box.bottom);
1345 + const id = Number(el.dataset.id);
1346 + hit ? state.selection.add(id) : state.selection.delete(id);
1347 + });
1348 + paintSelection();
1349 + };
1350 + const end = () => { start = null; rect?.remove(); rect = null; };
1351 + content.onpointerup = end;
1352 + content.onpointerleave = end;
1353 +}
1354 +
1355 +// ── Keyboard shortcuts ───────────────────────────────────────────────
1356 +function onGlobalKey(e) {
1357 + const inInput = e.target.matches('input, textarea, select, [contenteditable]');
1358 + if (e.key === '/' && !inInput) { e.preventDefault(); $('searchInput').focus(); return; }
1359 + if (inInput) return;
1360 + if (document.querySelector('.preview-overlay, .modal-scrim, .ctx-menu')) return;
1361 +
1362 + const focusedId = state.anchor;
1363 + const node = focusedId ? nodeById(focusedId) : null;
1364 +
1365 + if (e.key === '?') { showShortcuts(); return; }
1366 + if (e.key === 'Enter' && node) { openNode(node); return; }
1367 + if (e.key === ' ' && node && node.type === 'file') { e.preventDefault(); openViewer(node); return; }
1368 + if (e.key === 'F2' && node) { e.preventDefault(); inlineRename(node); return; }
1369 + if ((e.key === 'Delete' || e.key === 'Backspace') && state.selection.size) {
1370 + e.preventDefault();
1371 + state.route.view === 'trash' ? bulkDeleteForever([...state.selection]) : bulkTrash([...state.selection]);
1372 + return;
1373 + }
1374 + if (e.key.toLowerCase() === 's' && node && !e.metaKey && !e.ctrlKey) { toggleStar(node); return; }
1375 + if (e.key.toLowerCase() === 'a' && (e.metaKey || e.ctrlKey)) {
1376 + e.preventDefault();
1377 + state.selection = new Set(state.nodes.map((n) => n.id));
1378 + paintSelection();
1379 + return;
1380 + }
1381 + if (e.key.toLowerCase() === 'v' && !e.metaKey && !e.ctrlKey) { toggleViewMode(); return; }
1382 + if (e.key.toLowerCase() === 'n' && !e.metaKey && !e.ctrlKey && state.route.view === 'folder') { newFolderDialog(); return; }
1383 +
1384 + // Arrow navigation
1385 + if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key) && state.nodes.length) {
1386 + e.preventDefault();
1387 + const ids = state.nodes.map((n) => n.id);
1388 + let idx = focusedId ? ids.indexOf(focusedId) : -1;
1389 + const cols = state.viewMode === 'grid'
1390 + ? Math.max(1, Math.floor(content.querySelector('.grid')?.clientWidth / (state.cardSize + 12)) || 1)
1391 + : 1;
1392 + const delta = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -cols, ArrowDown: cols }[e.key];
1393 + idx = Math.min(Math.max(idx + delta, 0), ids.length - 1);
1394 + const id = ids[idx];
1395 + state.anchor = id;
1396 + if (e.shiftKey) state.selection.add(id);
1397 + else state.selection = new Set([id]);
1398 + paintSelection();
1399 + content.querySelector(`[data-id="${id}"]`)?.scrollIntoView({ block: 'nearest' });
1400 + const n = nodeById(id);
1401 + if (n) showInfo(n);
1402 + }
1403 +}
1404 +
1405 +function showShortcuts() {
1406 + const rows = [
1407 + ['Navigate', '← → ↑ ↓'], ['Open / enter folder', '↵'], ['Quick look', 'Space'],
1408 + ['Rename', 'F2'], ['Move to trash', 'Del'], ['Select all', '⌘A'],
1409 + ['Extend selection', 'Shift+click'], ['Toggle item', '⌘+click'],
1410 + ['Star', 'S'], ['Toggle view', 'V'], ['New folder', 'N'],
1411 + ['Search', '/'], ['This cheat sheet', '?'], ['Close / cancel', 'Esc'],
1412 + ];
1413 + modal({
1414 + title: 'Keyboard shortcuts',
1415 + wide: true,
1416 + body: h('div.shortcuts-grid', {}, rows.map(([label, key]) =>
1417 + h('div', {}, h('span', {}, label), h('kbd', {}, key)))),
1418 + actions: [{ label: 'Close', primary: true, onClick: () => {} }],
1419 + });
1420 +}
1421 +
1422 +boot();
added src/web/assets/js/icons.js +99 −0
@@ -0,0 +1,99 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Drive — Personal Cloud Drive
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/web/assets/js/icons.js
8 + * Purpose : Crisp custom SVG icon set — file families + UI glyphs
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +const S = 'fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"';
14 +const filePath = '<path d="M6 2.5h7.5L19 8v13a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 5 21V4A1.5 1.5 0 0 1 6.5 2.5Z"/><path d="M13.5 2.5V8H19"/>';
15 +
16 +/** File-family icons, color-coded per family. */
17 +const FAMILY = {
18 + folder: { color: 'var(--folder-blue)', svg: `<path d="M3 6.5A1.5 1.5 0 0 1 4.5 5h4.6l2 2.4h8.4A1.5 1.5 0 0 1 21 8.9V18a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 18Z" fill="currentColor" stroke="none"/>` },
19 + image: { color: '#22d3aa', svg: `${filePath}<circle cx="9.4" cy="11.5" r="1.4"/><path d="m6.5 18.5 3.4-3.7 2.3 2.2 2.6-3.2 2.7 4.7"/>` },
20 + video: { color: '#b48cff', svg: `${filePath}<path d="m10 11.5 4.5 2.7-4.5 2.7Z" fill="currentColor"/>` },
21 + audio: { color: '#ff7ab8', svg: `${filePath}<path d="M9.5 17.5v-5l5-1.4v5"/><circle cx="8.2" cy="17.6" r="1.3"/><circle cx="13.2" cy="16.2" r="1.3"/>` },
22 + pdf: { color: '#ff5d5d', svg: `${filePath}<path d="M7.5 17.5v-5h1.6a1.5 1.5 0 0 1 0 3H7.5m5-3v5m0-5h1.3a1.7 2 0 0 1 0 4h-.3m3.5 1v-5h2.5m-2.5 2.6h2"/>` },
23 + doc: { color: '#4f8cff', svg: `${filePath}<path d="M8 12.5h8M8 15.5h8M8 18.5h5"/>` },
24 + sheet: { color: '#7bd88f', svg: `${filePath}<path d="M8 12h8v7H8Zm0 2.3h8m-8 2.3h8M11 12v7"/>` },
25 + slides: { color: '#ff9f5a', svg: `${filePath}<rect x="7.5" y="12" width="9" height="5.5" rx="0.8"/><path d="M12 17.5v2"/>` },
26 + code: { color: '#ffd166', svg: `${filePath}<path d="m9.5 12.5-2.4 2.7 2.4 2.7m5-5.4 2.4 2.7-2.4 2.7"/>` },
27 + text: { color: '#8b93a3', svg: `${filePath}<path d="M8 12.5h8M8 15.5h8M8 18.5h4"/>` },
28 + archive: { color: '#ffb454', svg: `${filePath}<path d="M11 5h2m-2 2.5h2M11 10h2m-1.5 3h1a1 1 0 0 1 1 1v2.2a1.5 1.5 0 1 1-2 0V14a1 1 0 0 1 1-1Z"/>` },
29 + font: { color: '#e6e9ef', svg: `${filePath}<path d="m8.5 18.5 3-8 3 8m-5-2.3h4"/>` },
30 + mail: { color: '#4f8cff', svg: `${filePath}<rect x="7" y="12" width="10" height="7" rx="1"/><path d="m7.5 12.8 4.5 3.4 4.5-3.4"/>` },
31 + book: { color: '#22d3aa', svg: `${filePath}<path d="M8 19a2 2 0 0 1 2-2h6V11H10a2 2 0 0 0-2 2Z"/>` },
32 + cube: { color: '#b48cff', svg: `${filePath}<path d="m12 11 4 2v4.5l-4 2-4-2V13Zm-4 2 4 2 4-2m-4 2v4.5"/>` },
33 + file: { color: '#8b93a3', svg: filePath },
34 +};
35 +
36 +/** Render a file-family icon (class type-icon for sizing). */
37 +export function fileIcon(family, cls = '') {
38 + const f = FAMILY[family] ?? FAMILY.file;
39 + return `<svg class="type-icon ${cls}" viewBox="0 0 24 24" ${S} style="color:${f.color}" aria-hidden="true">${f.svg}</svg>`;
40 +}
41 +
42 +/** Folder icon honoring per-folder color + emoji override. */
43 +export function folderIcon(node, cls = '') {
44 + if (node?.emoji) return `<span class="${cls}" style="font-size:1.15em;line-height:1" aria-hidden="true">${node.emoji}</span>`;
45 + const color = node?.color ? `var(--folder-${node.color}, ${node.color})` : 'var(--folder-blue)';
46 + return `<svg class="type-icon ${cls}" viewBox="0 0 24 24" style="color:${color}" aria-hidden="true">${FAMILY.folder.svg}</svg>`;
47 +}
48 +
49 +export function nodeIcon(node, cls = '') {
50 + return node.type === 'folder' ? folderIcon(node, cls) : fileIcon(node.icon, cls);
51 +}
52 +
53 +/** UI glyphs. */
54 +export const UI = {
55 + chevron: `<svg viewBox="0 0 24 24" ${S}><path d="m9 6 6 6-6 6"/></svg>`,
56 + chevronL: `<svg viewBox="0 0 24 24" ${S}><path d="m15 6-6 6 6 6"/></svg>`,
57 + star: `<svg viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="m12 3 2.7 5.6 6.1.8-4.5 4.3 1.1 6-5.4-2.9-5.4 2.9 1.1-6L3.2 9.4l6.1-.8Z"/></svg>`,
58 + starO: `<svg viewBox="0 0 24 24" ${S}><path d="m12 3 2.7 5.6 6.1.8-4.5 4.3 1.1 6-5.4-2.9-5.4 2.9 1.1-6L3.2 9.4l6.1-.8Z"/></svg>`,
59 + download: `<svg viewBox="0 0 24 24" ${S}><path d="M12 4v12m0 0 5-5m-5 5-5-5M4 19h16"/></svg>`,
60 + share: `<svg viewBox="0 0 24 24" ${S}><circle cx="6" cy="12" r="2.5"/><circle cx="17" cy="6" r="2.5"/><circle cx="17" cy="18" r="2.5"/><path d="m8.3 10.8 6.4-3.6m-6.4 6 6.4 3.6"/></svg>`,
61 + trash: `<svg viewBox="0 0 24 24" ${S}><path d="M4 7h16m-2 0v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7m3 0V4.5A1.5 1.5 0 0 1 10.5 3h3A1.5 1.5 0 0 1 15 4.5V7m-5 4v6m4-6v6"/></svg>`,
62 + restore: `<svg viewBox="0 0 24 24" ${S}><path d="M4 10a8 8 0 1 1 2.3 6.3M4 10V5m0 5h5"/></svg>`,
63 + rename: `<svg viewBox="0 0 24 24" ${S}><path d="m14 5 5 5L8 21H3v-5Zm-3 3 5 5"/></svg>`,
64 + copy: `<svg viewBox="0 0 24 24" ${S}><rect x="9" y="9" width="12" height="12" rx="1.5"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>`,
65 + move: `<svg viewBox="0 0 24 24" ${S}><path d="M3 6.5A1.5 1.5 0 0 1 4.5 5h4.6l2 2.4h8.4A1.5 1.5 0 0 1 21 8.9V18a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 18Z"/><path d="M12 10.5v6m0 0 2.5-2.5M12 16.5 9.5 14"/></svg>`,
66 + info: `<svg viewBox="0 0 24 24" ${S}><circle cx="12" cy="12" r="9"/><path d="M12 11v5m0-8v.5"/></svg>`,
67 + close: `<svg viewBox="0 0 24 24" ${S}><path d="m6 6 12 12M18 6 6 18"/></svg>`,
68 + plus: `<svg viewBox="0 0 24 24" ${S}><path d="M12 5v14M5 12h14"/></svg>`,
69 + grid: `<svg viewBox="0 0 24 24" ${S}><rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/></svg>`,
70 + listv: `<svg viewBox="0 0 24 24" ${S}><path d="M8 6h13M8 12h13M8 18h13M3.5 6h.5m-.5 6h.5m-.5 6h.5"/></svg>`,
71 + sun: `<svg viewBox="0 0 24 24" ${S}><circle cx="12" cy="12" r="4.5"/><path d="M12 2.5v2m0 15v2m9.5-9.5h-2m-15 0h-2m16-6.7-1.4 1.4M6.9 17.1l-1.4 1.4m0-13.4 1.4 1.4m10.2 10.2 1.4 1.4"/></svg>`,
72 + moon: `<svg viewBox="0 0 24 24" ${S}><path d="M20 14.5A8 8 0 0 1 9.5 4 8 8 0 1 0 20 14.5Z"/></svg>`,
73 + gear: `<svg viewBox="0 0 24 24" ${S}><circle cx="12" cy="12" r="3"/><path d="M12 2.8 13.5 5h2.6l.9 2.4 2.3 1.3-.4 2.6 1.6 2-1.6 2 .4 2.6-2.3 1.3-.9 2.4h-2.6L12 21.2 10.5 19H7.9L7 16.6l-2.3-1.3.4-2.6-1.6-2 1.6-2L4.7 6l2.3-1.3L7.9 5h2.6Z"/></svg>`,
74 + menu: `<svg viewBox="0 0 24 24" ${S}><path d="M4 7h16M4 12h16M4 17h16"/></svg>`,
75 + upload: `<svg viewBox="0 0 24 24" ${S}><path d="M12 16V4m0 0 5 5m-5-5-5 5M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>`,
76 + search: `<svg viewBox="0 0 24 24" ${S}><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>`,
77 + clock: `<svg viewBox="0 0 24 24" ${S}><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2"/></svg>`,
78 + tag: `<svg viewBox="0 0 24 24" ${S}><path d="m3.5 12.5 8-8H20v8.5l-8 8a1.4 1.4 0 0 1-2 0l-6.5-6.5a1.4 1.4 0 0 1 0-2Z"/><circle cx="15.5" cy="8.5" r="1.2"/></svg>`,
79 + activity: `<svg viewBox="0 0 24 24" ${S}><path d="M3 12h4l2.5-7 5 14 2.5-7h4"/></svg>`,
80 + link: `<svg viewBox="0 0 24 24" ${S}><path d="M10 14a4.5 4.5 0 0 0 6.4.4l3-3a4.5 4.5 0 1 0-6.4-6.3l-1.3 1.2"/><path d="M14 10a4.5 4.5 0 0 0-6.4-.4l-3 3a4.5 4.5 0 1 0 6.4 6.3l1.3-1.2"/></svg>`,
81 + check: `<svg viewBox="0 0 24 24" ${S}><path d="m5 13 4.5 4.5L19 7"/></svg>`,
82 + dots: `<svg viewBox="0 0 24 24" fill="currentColor" stroke="none"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>`,
83 + duplicate: `<svg viewBox="0 0 24 24" ${S}><rect x="8" y="8" width="12" height="12" rx="1.5"/><path d="M16 4H5.5A1.5 1.5 0 0 0 4 5.5V16"/></svg>`,
84 + qr: `<svg viewBox="0 0 24 24" ${S}><rect x="4" y="4" width="6" height="6"/><rect x="14" y="4" width="6" height="6"/><rect x="4" y="14" width="6" height="6"/><path d="M14 14h2.5v2.5H14Zm3.5 3.5H20V20h-2.5Zm0-3.5H20m-6 6h2.5"/></svg>`,
85 + eye: `<svg viewBox="0 0 24 24" ${S}><path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="2.8"/></svg>`,
86 + zoomIn: `<svg viewBox="0 0 24 24" ${S}><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5M8 11h6m-3-3v6"/></svg>`,
87 + zoomOut: `<svg viewBox="0 0 24 24" ${S}><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5M8 11h6"/></svg>`,
88 + rotate: `<svg viewBox="0 0 24 24" ${S}><path d="M20 11a8 8 0 1 0-2 5.3M20 5v6h-6"/></svg>`,
89 + expand: `<svg viewBox="0 0 24 24" ${S}><path d="M4 9V4h5m11 5V4h-5M4 15v5h5m11-5v5h-5"/></svg>`,
90 + folderNew: `<svg viewBox="0 0 24 24" ${S}><path d="M3 6.5A1.5 1.5 0 0 1 4.5 5h4.6l2 2.4h8.4A1.5 1.5 0 0 1 21 8.9V18a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 18Z"/><path d="M12 10.5v6m-3-3h6"/></svg>`,
91 + logout: `<svg viewBox="0 0 24 24" ${S}><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4m7 13 5-4-5-4m5 4H9"/></svg>`,
92 +};
93 +
94 +/** Empty-state illustrations (custom minimal SVG art). */
95 +export const EMPTY_ART = {
96 + folder: `<svg viewBox="0 0 160 120" fill="none"><rect x="30" y="34" width="100" height="66" rx="8" stroke="var(--border)" stroke-width="2.5" fill="var(--surface)"/><path d="M30 44a8 8 0 0 1 8-8h22l8 9h54a8 8 0 0 1 8 8" stroke="var(--accent)" stroke-width="2.5"/><circle cx="80" cy="72" r="13" stroke="var(--muted)" stroke-width="2.5" stroke-dasharray="4 5"/><path d="M80 66v12m-6-6h12" stroke="var(--muted)" stroke-width="2.5" stroke-linecap="round"/></svg>`,
97 + trash: `<svg viewBox="0 0 160 120" fill="none"><path d="M55 40h50l-4 58a8 8 0 0 1-8 7H67a8 8 0 0 1-8-7Z" stroke="var(--border)" stroke-width="2.5" fill="var(--surface)"/><path d="M48 40h64M70 40v-6a6 6 0 0 1 6-6h8a6 6 0 0 1 6 6v6" stroke="var(--accent-2)" stroke-width="2.5" stroke-linecap="round"/><path d="m70 56 20 32m0-32-20 32" stroke="var(--muted)" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/></svg>`,
98 + search: `<svg viewBox="0 0 160 120" fill="none"><circle cx="72" cy="56" r="26" stroke="var(--accent)" stroke-width="2.5" fill="var(--surface)"/><path d="m92 76 18 18" stroke="var(--accent)" stroke-width="2.5" stroke-linecap="round"/><path d="M62 56h20m-16-8h12m-14 16h16" stroke="var(--muted)" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/></svg>`,
99 +};
added src/web/assets/js/share-page.js +122 −0
@@ -0,0 +1,122 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Drive — Personal Cloud Drive
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/web/assets/js/share-page.js
8 + * Purpose : Public share pages — file preview + read-only folder browser
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { h, fmtSize, fmtDate } from './ui.js';
14 +import { fileIcon, folderIcon, UI } from './icons.js';
15 +import { renderPreview, Viewer } from './viewer.js';
16 +
17 +const page = document.querySelector('.share-page');
18 +const token = page.dataset.token;
19 +const allowDownload = page.dataset.allowDownload === '1';
20 +
21 +document.getElementById('shareIcon').innerHTML = page.dataset.folder
22 + ? folderIcon({})
23 + : fileIcon('file');
24 +
25 +if (page.dataset.folder) {
26 + bootFolder();
27 +} else {
28 + bootFile();
29 +}
30 +
31 +// ── Single file share ────────────────────────────────────────────────
32 +async function bootFile() {
33 + const stage = document.getElementById('shareStage');
34 + stage.append(h('div.pv-processing', { style: { color: 'var(--text)' } }, 'Loading preview…'));
35 + try {
36 + const res = await fetch(`/s/${token}/preview`, { credentials: 'same-origin' });
37 + if (!res.ok) throw new Error('preview unavailable');
38 + const desc = await res.json();
39 + document.getElementById('shareIcon').innerHTML = fileIcon(iconFor(desc.strategy));
40 + stage.innerHTML = '';
41 + await renderPreview(stage, desc, {
42 + stream: `/s/${token}/stream`,
43 + dl: allowDownload ? `/s/${token}/dl` : null,
44 + });
45 + } catch {
46 + stage.innerHTML = '';
47 + stage.append(h('div.fallback-card', {},
48 + h('div', { html: fileIcon('file') }),
49 + h('h3', {}, page.dataset.name),
50 + h('div.fm', {}, fmtSize(page.dataset.size)),
51 + allowDownload ? h('a.btn.primary', { href: `/s/${token}/dl`, download: '' }, 'Download') : null));
52 + }
53 +}
54 +
55 +function iconFor(strategy) {
56 + const map = {
57 + image: 'image', svg: 'image', heic: 'image', video: 'video', audio: 'audio',
58 + pdf: 'pdf', office: 'doc', markdown: 'text', csv: 'sheet', notebook: 'code',
59 + structured: 'code', archive: 'archive', font: 'font', email: 'mail',
60 + epub: 'book', model3d: 'cube', code: 'code',
61 + };
62 + return map[strategy] ?? 'file';
63 +}
64 +
65 +// ── Folder share browser ─────────────────────────────────────────────
66 +async function bootFolder() {
67 + const rootId = Number(page.dataset.nodeId);
68 + const browser = document.getElementById('shareBrowser');
69 + const crumbsEl = document.getElementById('shareCrumbs');
70 +
71 + const load = async (folderId) => {
72 + browser.innerHTML = '<div class="grid">' + '<div class="skeleton"></div>'.repeat(6) + '</div>';
73 + const res = await fetch(`/s/${token}/api/children?id=${folderId}`, { credentials: 'same-origin' });
74 + if (!res.ok) { browser.innerHTML = '<p class="muted">Could not load folder.</p>'; return; }
75 + const { children, path } = await res.json();
76 +
77 + crumbsEl.innerHTML = '';
78 + path.forEach((part, i) => {
79 + if (i > 0) crumbsEl.append(h('span.crumb-sep', {}, '›'));
80 + crumbsEl.append(h(`button.crumb${i === path.length - 1 ? '.current' : ''}`, {
81 + onclick: () => load(part.id),
82 + }, part.name || page.dataset.name));
83 + });
84 +
85 + browser.innerHTML = '';
86 + if (!children.length) {
87 + browser.append(h('p.muted', { style: { textAlign: 'center', padding: '40px' } }, 'This folder is empty.'));
88 + return;
89 + }
90 + const grid = h('div.grid', { style: { '--card': '168px' } });
91 + const files = children.filter((c) => c.type === 'file');
92 + for (const child of children) {
93 + const thumb = child.hasThumb
94 + ? h('img', { loading: 'lazy', src: `/s/${token}/thumb/${child.id}?size=256`, alt: '' })
95 + : h('span', { html: child.type === 'folder' ? folderIcon(child) : fileIcon(child.icon), style: { display: 'contents' } });
96 + const card = h('div.card', { tabindex: 0 },
97 + h('div.thumb', {}, thumb),
98 + h('div.meta', {},
99 + h('span', { html: child.type === 'folder' ? folderIcon(child) : fileIcon(child.icon), style: { display: 'contents' } }),
100 + h('span.name', { title: child.name }, child.name)));
101 + card.addEventListener('dblclick', () => open(child, files));
102 + card.addEventListener('click', () => open(child, files));
103 + grid.append(card);
104 + }
105 + browser.append(grid);
106 + };
107 +
108 + const open = (child, files) => {
109 + if (child.type === 'folder') { load(child.id); return; }
110 + const index = files.findIndex((f) => f.id === child.id);
111 + new Viewer(files, Math.max(index, 0), {
112 + descUrl: (n) => `/s/${token}/preview/${n.id}`,
113 + streamUrl: (n) => `/s/${token}/stream/${n.id}`,
114 + dlUrl: allowDownload ? (n) => `/s/${token}/dl/${n.id}` : null,
115 + actions: allowDownload
116 + ? [{ title: 'Download', icon: UI.download, onClick: (n) => { location.href = `/s/${token}/dl/${n.id}`; } }]
117 + : [],
118 + });
119 + };
120 +
121 + load(rootId);
122 +}
added src/web/assets/js/ui.js +206 −0
@@ -0,0 +1,206 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Drive — Personal Cloud Drive
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/web/assets/js/ui.js
8 + * Purpose : UI primitives — DOM helper, toasts, modals, context menu, formats
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +/** Tiny hyperscript: h('button.btn.primary', {onclick}, 'Save'). */
14 +export function h(spec, attrs = {}, ...children) {
15 + const [tag, ...classes] = spec.split('.');
16 + const el = document.createElement(tag || 'div');
17 + if (classes.length) el.className = classes.join(' ');
18 + for (const [key, val] of Object.entries(attrs ?? {})) {
19 + if (val === undefined || val === null || val === false) continue;
20 + if (key.startsWith('on') && typeof val === 'function') el.addEventListener(key.slice(2), val);
21 + else if (key === 'html') el.innerHTML = val;
22 + else if (key === 'dataset') Object.assign(el.dataset, val);
23 + else if (key === 'style' && typeof val === 'object') Object.assign(el.style, val);
24 + else el.setAttribute(key, val === true ? '' : val);
25 + }
26 + for (const child of children.flat(Infinity)) {
27 + if (child === null || child === undefined || child === false) continue;
28 + el.append(child.nodeType ? child : document.createTextNode(child));
29 + }
30 + return el;
31 +}
32 +
33 +export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) =>
34 + ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
35 +
36 +export function fmtSize(bytes) {
37 + const n = Number(bytes ?? 0);
38 + if (n < 1024) return `${n} B`;
39 + const units = ['KB', 'MB', 'GB', 'TB'];
40 + let v = n / 1024; let i = 0;
41 + while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
42 + return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}`;
43 +}
44 +
45 +export function fmtDate(ts) {
46 + if (!ts) return '—';
47 + const d = new Date(ts);
48 + const now = Date.now();
49 + const diff = now - ts;
50 + if (diff < 60_000) return 'just now';
51 + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} min ago`;
52 + if (diff < 86_400_000 && new Date(now).getDate() === d.getDate()) {
53 + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
54 + }
55 + return d.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' });
56 +}
57 +
58 +export function fmtDuration(sec) {
59 + if (!Number.isFinite(sec)) return '';
60 + const s = Math.round(sec);
61 + const m = Math.floor(s / 60); const r = s % 60;
62 + const hh = Math.floor(m / 60);
63 + return hh ? `${hh}:${String(m % 60).padStart(2, '0')}:${String(r).padStart(2, '0')}`
64 + : `${m}:${String(r).padStart(2, '0')}`;
65 +}
66 +
67 +// ── Toasts ───────────────────────────────────────────────────────────
68 +export function toast(message, { actionLabel, onAction, error = false, ttl = 4500 } = {}) {
69 + const host = document.getElementById('toasts');
70 + if (!host) return;
71 + const el = h(`div.toast${error ? '.error' : ''}`, { role: 'status' }, message);
72 + if (actionLabel) {
73 + el.append(h('button', {
74 + onclick: () => { el.remove(); onAction?.(); },
75 + }, actionLabel));
76 + }
77 + el.append(h('button', {
78 + 'aria-label': 'Dismiss', style: { color: 'var(--muted)' },
79 + onclick: () => el.remove(),
80 + }, '✕'));
81 + host.append(el);
82 + setTimeout(() => el.remove(), ttl);
83 +}
84 +
85 +// ── Modals ───────────────────────────────────────────────────────────
86 +export function modal({ title, body, actions = [], wide = false, onClose }) {
87 + const scrim = h('div.modal-scrim', {
88 + onclick: (e) => { if (e.target === scrim) close(); },
89 + });
90 + const box = h(`div.modal${wide ? '.wide' : ''}`, { role: 'dialog', 'aria-label': title });
91 + const close = () => { scrim.remove(); document.removeEventListener('keydown', onKey); onClose?.(); };
92 + const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };
93 + document.addEventListener('keydown', onKey);
94 + box.append(h('h2', {}, title));
95 + box.append(body);
96 + if (actions.length) {
97 + box.append(h('div.actions', {}, actions.map(({ label, primary, danger, onClick }) =>
98 + h(`button.btn${primary ? '.primary' : ''}${danger ? '.danger' : ''}`, {
99 + onclick: async () => { if ((await onClick?.(close)) !== false) close(); },
100 + }, label))));
101 + }
102 + scrim.append(box);
103 + document.body.append(scrim);
104 + const first = box.querySelector('input, select, textarea, button');
105 + first?.focus();
106 + return { close, box };
107 +}
108 +
109 +export function confirmModal({ title, message, confirmLabel = 'Confirm', danger = false, typed = null }) {
110 + return new Promise((resolve) => {
111 + let input = null;
112 + const body = h('div', {}, h('p', { style: { color: 'var(--muted)', margin: '0 0 6px' } }, message));
113 + if (typed) {
114 + body.append(h('p', { style: { fontSize: '12.5px' } }, `Type "${typed}" to confirm:`));
115 + input = h('input', { type: 'text' });
116 + body.append(input);
117 + }
118 + const m = modal({
119 + title,
120 + body,
121 + onClose: () => resolve(false),
122 + actions: [
123 + { label: 'Cancel', onClick: () => resolve(false) },
124 + {
125 + label: confirmLabel, primary: !danger, danger,
126 + onClick: () => {
127 + if (typed && input.value !== typed) { input.style.borderColor = 'var(--danger)'; return false; }
128 + resolve(true);
129 + return true;
130 + },
131 + },
132 + ],
133 + });
134 + return m;
135 + });
136 +}
137 +
138 +// ── Context menu ─────────────────────────────────────────────────────
139 +let openMenu = null;
140 +export function closeContextMenu() { openMenu?.remove(); openMenu = null; }
141 +
142 +/**
143 + * items: {label, icon, danger, kbd, onClick} | {sep: true} | {custom: Element}
144 + */
145 +export function contextMenu(x, y, items) {
146 + closeContextMenu();
147 + const menu = h('div.ctx-menu', { role: 'menu' });
148 + for (const item of items) {
149 + if (!item) continue;
150 + if (item.sep) { menu.append(h('div.ctx-sep')); continue; }
151 + if (item.custom) { menu.append(item.custom); continue; }
152 + const btn = h(`button.ctx-item${item.danger ? '.danger' : ''}`, {
153 + role: 'menuitem',
154 + onclick: () => { closeContextMenu(); item.onClick?.(); },
155 + });
156 + if (item.icon) btn.append(h('span', { html: item.icon, style: { display: 'contents' } }));
157 + btn.append(h('span', {}, item.label));
158 + if (item.kbd) btn.append(h('kbd', {}, item.kbd));
159 + menu.append(btn);
160 + }
161 + document.body.append(menu);
162 + const { innerWidth: vw, innerHeight: vh } = window;
163 + const rect = menu.getBoundingClientRect();
164 + menu.style.left = `${Math.min(x, vw - rect.width - 8)}px`;
165 + menu.style.top = `${Math.min(y, vh - rect.height - 8)}px`;
166 + openMenu = menu;
167 +
168 + // Keyboard accessibility: arrows + enter + escape.
169 + const focusables = [...menu.querySelectorAll('.ctx-item')];
170 + let idx = -1;
171 + const onKey = (e) => {
172 + if (e.key === 'Escape') { cleanup(); }
173 + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
174 + e.preventDefault();
175 + idx = (idx + (e.key === 'ArrowDown' ? 1 : -1) + focusables.length) % focusables.length;
176 + focusables[idx]?.focus();
177 + }
178 + };
179 + const onDoc = (e) => { if (!menu.contains(e.target)) cleanup(); };
180 + const cleanup = () => {
181 + closeContextMenu();
182 + document.removeEventListener('keydown', onKey, true);
183 + document.removeEventListener('pointerdown', onDoc, true);
184 + document.removeEventListener('contextmenu', onDoc, true);
185 + };
186 + document.addEventListener('keydown', onKey, true);
187 + document.addEventListener('pointerdown', onDoc, true);
188 + document.addEventListener('contextmenu', onDoc, true);
189 + focusables[0]?.focus();
190 + return menu;
191 +}
192 +
193 +/** Copy text to the clipboard with a toast. */
194 +export async function copyText(text, label = 'Copied to clipboard') {
195 + try {
196 + await navigator.clipboard.writeText(text);
197 + toast(label);
198 + } catch {
199 + const ta = h('textarea', { style: { position: 'fixed', opacity: 0 } }, text);
200 + document.body.append(ta);
201 + ta.select();
202 + document.execCommand('copy');
203 + ta.remove();
204 + toast(label);
205 + }
206 +}
added src/web/assets/js/upload.js +276 −0
@@ -0,0 +1,276 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Drive — Personal Cloud Drive
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/web/assets/js/upload.js
8 + * Purpose : Chunked resumable upload manager + Google-Drive-style panel
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { post, api } from './api.js';
14 +import { h, fmtSize, toast } from './ui.js';
15 +import { UI } from './icons.js';
16 +
17 +const CONCURRENT_FILES = 3;
18 +
19 +/**
20 + * Walk dropped DataTransfer items (files AND directory trees).
21 + * @returns {Promise<Array<{file: File, relPath: string}>>}
22 + */
23 +export async function collectDropped(dataTransfer) {
24 + const out = [];
25 + const entries = [...dataTransfer.items]
26 + .filter((i) => i.kind === 'file')
27 + .map((i) => i.webkitGetAsEntry?.() ?? null);
28 +
29 + if (entries.every((e) => e === null)) {
30 + for (const file of dataTransfer.files) out.push({ file, relPath: file.name });
31 + return out;
32 + }
33 +
34 + const walk = async (entry, prefix) => {
35 + if (!entry) return;
36 + if (entry.isFile) {
37 + const file = await new Promise((res, rej) => entry.file(res, rej));
38 + out.push({ file, relPath: prefix + entry.name });
39 + } else if (entry.isDirectory) {
40 + const reader = entry.createReader();
41 + // readEntries returns batches of ≤100; loop until empty.
42 + for (;;) {
43 + const batch = await new Promise((res, rej) => reader.readEntries(res, rej));
44 + if (!batch.length) break;
45 + for (const child of batch) await walk(child, `${prefix}${entry.name}/`);
46 + }
47 + }
48 + };
49 + for (const entry of entries) await walk(entry, '');
50 + return out;
51 +}
52 +
53 +export class UploadManager {
54 + /** @param {{onFinished: (parentId: number) => void}} hooks */
55 + constructor(hooks = {}) {
56 + this.hooks = hooks;
57 + this.items = [];
58 + this.active = 0;
59 + this.panel = document.getElementById('uploadPanel');
60 + }
61 +
62 + /** Enqueue files targeted at a folder. items: {file, relPath} */
63 + add(files, parentId) {
64 + for (const { file, relPath } of files) {
65 + this.items.push({
66 + file,
67 + relPath: relPath || file.name,
68 + parentId,
69 + state: 'queued', // queued | uploading | done | error | cancelled
70 + sent: 0,
71 + uploadId: null,
72 + speed: 0,
73 + el: null,
74 + cancelled: false,
75 + });
76 + }
77 + this.renderPanel();
78 + this.pump();
79 + }
80 +
81 + pump() {
82 + while (this.active < CONCURRENT_FILES) {
83 + const next = this.items.find((i) => i.state === 'queued');
84 + if (!next) break;
85 + this.active += 1;
86 + this.uploadOne(next).finally(() => {
87 + this.active -= 1;
88 + this.pump();
89 + if (!this.items.some((i) => i.state === 'queued' || i.state === 'uploading')) {
90 + const target = this.items.at(-1)?.parentId;
91 + const okCount = this.items.filter((i) => i.state === 'done').length;
92 + if (okCount) this.hooks.onFinished?.(target);
93 + }
94 + });
95 + }
96 + }
97 +
98 + async uploadOne(item) {
99 + item.state = 'uploading';
100 + this.updateItem(item);
101 + try {
102 + const init = await post('/api/v1/upload/init', {
103 + parentId: item.parentId,
104 + name: item.relPath.split('/').pop(),
105 + path: item.relPath.includes('/') ? item.relPath : undefined,
106 + size: item.file.size,
107 + });
108 + item.uploadId = init.uploadId;
109 + const { chunkSize, nChunks } = init;
110 + let have = new Set(init.have);
111 +
112 + const startedAt = Date.now();
113 + for (let n = 0; n < nChunks; n += 1) {
114 + if (item.cancelled) throw new Error('cancelled');
115 + if (have.has(n)) { item.sent += chunkSize; continue; }
116 + const blob = item.file.slice(n * chunkSize, Math.min((n + 1) * chunkSize, item.file.size));
117 + let attempt = 0;
118 + for (;;) {
119 + try {
120 + const res = await fetch(`/api/v1/upload/${item.uploadId}/chunk/${n}`, {
121 + method: 'PUT',
122 + headers: { 'content-type': 'application/octet-stream', 'x-spbdrive-csrf': '1' },
123 + body: blob,
124 + credentials: 'same-origin',
125 + });
126 + if (!res.ok) throw new Error(`chunk ${n}: HTTP ${res.status}`);
127 + break;
128 + } catch (err) {
129 + attempt += 1;
130 + if (item.cancelled || attempt > 3) throw err;
131 + await new Promise((r) => setTimeout(r, 1000 * attempt));
132 + // Ask the server what it already has (resume support).
133 + const status = await api(`/api/v1/upload/${item.uploadId}`);
134 + have = new Set(status.have);
135 + if (have.has(n)) break;
136 + }
137 + }
138 + item.sent = Math.min((n + 1) * chunkSize, item.file.size);
139 + item.speed = item.sent / Math.max((Date.now() - startedAt) / 1000, 0.1);
140 + this.updateItem(item);
141 + }
142 +
143 + await post(`/api/v1/upload/${item.uploadId}/complete`, {
144 + conflict: 'keep-both',
145 + mime: item.file.type || undefined,
146 + });
147 + item.state = 'done';
148 + item.sent = item.file.size;
149 + } catch (err) {
150 + if (item.cancelled) {
151 + item.state = 'cancelled';
152 + if (item.uploadId) api(`/api/v1/upload/${item.uploadId}`, { method: 'DELETE' }).catch(() => {});
153 + } else {
154 + item.state = 'error';
155 + item.error = err.message;
156 + }
157 + }
158 + this.updateItem(item);
159 + this.updateHead();
160 + }
161 +
162 + cancel(item) {
163 + item.cancelled = true;
164 + if (item.state === 'queued') { item.state = 'cancelled'; this.updateItem(item); }
165 + }
166 +
167 + retry(item) {
168 + if (item.state !== 'error') return;
169 + item.state = 'queued';
170 + item.cancelled = false;
171 + item.sent = 0;
172 + this.updateItem(item);
173 + this.pump();
174 + }
175 +
176 + // ── Panel UI ────────────────────────────────────────────────────────
177 + renderPanel() {
178 + if (!this.panel) return;
179 + if (!this.panel.querySelector('.upload-head')) {
180 + this.panel.innerHTML = '';
181 + this.head = h('div.upload-head', {},
182 + h('span', {}, 'Uploads'),
183 + this.agg = h('span.agg'),
184 + h('button.btn.icon.ghost', {
185 + title: 'Minimize',
186 + onclick: () => this.panel.classList.toggle('min'),
187 + }, h('span', { html: UI.chevron, style: { display: 'contents' } })),
188 + h('button.btn.icon.ghost', {
189 + title: 'Close',
190 + onclick: () => { this.panel.classList.remove('open'); this.items = this.items.filter((i) => i.state === 'uploading' || i.state === 'queued'); },
191 + }, h('span', { html: UI.close, style: { display: 'contents' } })),
192 + );
193 + this.list = h('div.upload-list');
194 + this.panel.append(this.head, this.list);
195 + }
196 + this.panel.classList.add('open');
197 + for (const item of this.items) {
198 + if (!item.el) {
199 + item.el = h('div.upload-item', {},
200 + h('div.fname', {},
201 + h('span.n', {}, item.relPath),
202 + h('span.s'),
203 + h('div.prog', {}, h('i')),
204 + ),
205 + h('div.act', {},
206 + h('button.btn.icon.ghost', {
207 + title: 'Retry', style: { display: 'none' },
208 + onclick: () => this.retry(item),
209 + }, h('span', { html: UI.restore, style: { display: 'contents' } })),
210 + h('button.btn.icon.ghost', {
211 + title: 'Cancel',
212 + onclick: () => this.cancel(item),
213 + }, h('span', { html: UI.close, style: { display: 'contents' } })),
214 + ),
215 + );
216 + this.list.prepend(item.el);
217 + }
218 + this.updateItem(item);
219 + }
220 + this.updateHead();
221 + }
222 +
223 + updateItem(item) {
224 + if (!item.el) return;
225 + const pct = item.file.size ? Math.min(100, (item.sent / item.file.size) * 100) : 100;
226 + item.el.className = `upload-item${item.state === 'done' ? ' done' : ''}${item.state === 'error' ? ' err' : ''}`;
227 + item.el.querySelector('.prog i').style.width = `${item.state === 'done' ? 100 : pct}%`;
228 + const status = {
229 + queued: 'Waiting…',
230 + uploading: `${fmtSize(item.sent)} / ${fmtSize(item.file.size)} · ${fmtSize(item.speed)}/s`,
231 + done: `Done · ${fmtSize(item.file.size)}`,
232 + error: `Failed — ${item.error ?? 'error'}`,
233 + cancelled: 'Cancelled',
234 + }[item.state];
235 + item.el.querySelector('.s').textContent = status;
236 + item.el.querySelector('[title="Retry"]').style.display = item.state === 'error' ? '' : 'none';
237 + item.el.querySelector('[title="Cancel"]').style.display =
238 + item.state === 'uploading' || item.state === 'queued' ? '' : 'none';
239 + }
240 +
241 + updateHead() {
242 + if (!this.agg) return;
243 + const total = this.items.length;
244 + const done = this.items.filter((i) => i.state === 'done').length;
245 + const failed = this.items.filter((i) => i.state === 'error').length;
246 + const activeBytes = this.items.reduce((s, i) => s + i.sent, 0);
247 + const allBytes = this.items.reduce((s, i) => s + i.file.size, 0);
248 + this.agg.textContent = failed
249 + ? `${done}/${total} · ${failed} failed`
250 + : done === total
251 + ? `${total} done`
252 + : `${done}/${total} · ${allBytes ? Math.round((activeBytes / allBytes) * 100) : 0}%`;
253 + }
254 +}
255 +
256 +/** Wire paste-to-upload: screenshots land as pasted-YYYYMMDD-HHmmss.png. */
257 +export function bindPasteUpload(manager, getCurrentFolder) {
258 + document.addEventListener('paste', (e) => {
259 + if (['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) return;
260 + const files = [...(e.clipboardData?.items ?? [])]
261 + .filter((i) => i.kind === 'file')
262 + .map((i) => i.getAsFile())
263 + .filter(Boolean);
264 + if (!files.length) return;
265 + e.preventDefault();
266 + const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 15).replace(/^(\d{8})/, '$1-');
267 + const items = files.map((file, i) => ({
268 + file,
269 + relPath: file.name && file.name !== 'image.png'
270 + ? file.name
271 + : `pasted-${stamp}${files.length > 1 ? `-${i + 1}` : ''}.png`,
272 + }));
273 + manager.add(items, getCurrentFolder());
274 + toast(`Uploading ${items.length} pasted file${items.length > 1 ? 's' : ''}…`);
275 + });
276 +}
added src/web/assets/js/viewer.js +971 −0
@@ -0,0 +1,971 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Drive — Personal Cloud Drive
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/web/assets/js/viewer.js
8 + * Purpose : Universal preview renderers + full-screen viewer overlay
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { h, fmtSize, fmtDuration, toast, copyText } from './ui.js';
14 +import { fileIcon, UI } from './icons.js';
15 +
16 +/**
17 + * Render a preview into `stage` for a descriptor from GET …/preview.
18 + * @param {HTMLElement} stage
19 + * @param {object} desc preview descriptor (strategy, base, …)
20 + * @param {{stream: string, dl: string|null}} urls media URLs for this node
21 + */
22 +export async function renderPreview(stage, desc, urls) {
23 + stage.innerHTML = '';
24 + const render = RENDERERS[desc.strategy] ?? RENDERERS.fallback;
25 + try {
26 + await render(stage, desc, urls);
27 + } catch (err) {
28 + console.error('preview failed', err);
29 + RENDERERS.fallback(stage, desc, urls);
30 + }
31 +}
32 +
33 +const processing = (label) =>
34 + h('div.pv-processing', {},
35 + h('div', {}, label),
36 + h('div.bar', {}, h('i')));
37 +
38 +const panel = (...kids) => {
39 + const scroll = h('div.pv-scroll');
40 + const el = h('div.pv-panel', {}, ...kids, scroll);
41 + return { el, scroll };
42 +};
43 +
44 +// ── Images ───────────────────────────────────────────────────────────
45 +async function renderImage(stage, desc, urls, srcOverride) {
46 + const img = h('img', { src: srcOverride ?? urls.stream, alt: desc.name, draggable: false });
47 + const wrap = h('div.pv-img-wrap', {}, img);
48 + let scale = 1; let rot = 0; let tx = 0; let ty = 0; let fit = true;
49 + const apply = () => {
50 + img.style.transform = `translate(${tx}px, ${ty}px) rotate(${rot}deg) scale(${scale})`;
51 + zoomLabel.textContent = fit ? 'Fit' : `${Math.round(scale * 100)}%`;
52 + };
53 + const setZoom = (z, keepFit = false) => { scale = Math.min(Math.max(z, 0.1), 12); fit = keepFit; apply(); };
54 +
55 + wrap.addEventListener('wheel', (e) => {
56 + e.preventDefault();
57 + setZoom(scale * (e.deltaY < 0 ? 1.15 : 0.87));
58 + }, { passive: false });
59 +
60 + let drag = null;
61 + wrap.addEventListener('pointerdown', (e) => {
62 + drag = { x: e.clientX - tx, y: e.clientY - ty };
63 + wrap.setPointerCapture(e.pointerId);
64 + wrap.style.cursor = 'grabbing';
65 + });
66 + wrap.addEventListener('pointermove', (e) => {
67 + if (!drag) return;
68 + tx = e.clientX - drag.x; ty = e.clientY - drag.y; apply();
69 + });
70 + wrap.addEventListener('pointerup', () => { drag = null; wrap.style.cursor = 'grab'; });
71 + img.addEventListener('dblclick', () => { tx = 0; ty = 0; setZoom(scale === 1 ? 2 : 1, scale !== 1); });
72 +
73 + const zoomLabel = h('span.zoom-label', {}, 'Fit');
74 + const bar = h('div.pv-toolbar', {},
75 + h('button.btn', { title: 'Zoom out', html: UI.zoomOut, onclick: () => setZoom(scale * 0.8) }),
76 + zoomLabel,
77 + h('button.btn', { title: 'Zoom in', html: UI.zoomIn, onclick: () => setZoom(scale * 1.25) }),
78 + h('button.btn', { title: '1:1', onclick: () => { tx = 0; ty = 0; setZoom(1); } }, '1:1'),
79 + h('button.btn', { title: 'Rotate', html: UI.rotate, onclick: () => { rot = (rot + 90) % 360; apply(); } }),
80 + h('button.btn', { title: 'Image info', html: UI.info, onclick: () => showExif(desc) }),
81 + );
82 + stage.append(wrap, bar);
83 +}
84 +
85 +async function showExif(desc) {
86 + const { modal } = await import('./ui.js');
87 + let data = {};
88 + try { data = await (await fetch(`${desc.base}/exif`, { credentials: 'same-origin' })).json(); } catch {}
89 + const dl = h('dl.kv');
90 + const add = (k, v) => { if (v !== null && v !== undefined && v !== '') { dl.append(h('dt', {}, k), h('dd', {}, String(v))); } };
91 + add('Dimensions', data.width ? `${data.width} × ${data.height}` : null);
92 + add('Format', data.format);
93 + add('Camera', data.camera);
94 + add('Lens', data.lens);
95 + add('ISO', data.iso);
96 + add('Exposure', data.exposure ? `1/${Math.round(1 / data.exposure)}s` : null);
97 + add('Aperture', data.fnumber ? `ƒ/${data.fnumber}` : null);
98 + add('Focal length', data.focal ? `${data.focal} mm` : null);
99 + add('Taken', data.taken ? new Date(data.taken).toLocaleString() : null);
100 + const body = h('div', {}, dl.children.length ? dl : h('p.muted', {}, 'No metadata available.'));
101 + if (data.gps) {
102 + body.append(h('a.btn', {
103 + href: `https://www.openstreetmap.org/?mlat=${data.gps.lat}&mlon=${data.gps.lon}#map=15/${data.gps.lat}/${data.gps.lon}`,
104 + target: '_blank', rel: 'noopener',
105 + }, 'Open map location'));
106 + }
107 + modal({ title: 'Image details', body, actions: [{ label: 'Close', primary: true, onClick: () => {} }] });
108 +}
109 +
110 +// ── Video ────────────────────────────────────────────────────────────
111 +async function renderVideo(stage, desc, urls) {
112 + const attach = (src) => {
113 + const video = h('video.pv-video', {
114 + src, controls: true, autoplay: true, playsinline: true,
115 + });
116 + // Keyboard: space handled natively when focused; add speed + pip controls.
117 + const bar = h('div.pv-toolbar', {},
118 + ...[0.5, 1, 1.5, 2].map((rate) =>
119 + h('button.btn', { onclick: () => { video.playbackRate = rate; } }, `${rate}×`)),
120 + 'pictureInPictureEnabled' in document
121 + ? h('button.btn', { title: 'Picture in picture', onclick: () => video.requestPictureInPicture().catch(() => {}) }, 'PiP')
122 + : null,
123 + h('button.btn', { title: 'Fullscreen', html: UI.expand, onclick: () => video.requestFullscreen?.() }),
124 + );
125 + stage.append(video, bar);
126 + video.focus();
127 + };
128 +
129 + if (desc.webSafe) { attach(urls.stream); return; }
130 +
131 + stage.append(processing('Optimizing for playback…'));
132 + const poll = async () => {
133 + try {
134 + const res = await fetch(`${desc.base}/video`, { credentials: 'same-origin' });
135 + const { state } = await res.json();
136 + if (state === 'ready') { stage.innerHTML = ''; attach(`${desc.base}/video/file`); return; }
137 + if (state === 'unavailable') {
138 + stage.innerHTML = '';
139 + RENDERERS.fallback(stage, { ...desc, note: 'This codec can\'t be optimized on the server.' }, urls);
140 + return;
141 + }
142 + } catch { /* keep polling */ }
143 + if (stage.isConnected) setTimeout(poll, 2500);
144 + };
145 + poll();
146 +}
147 +
148 +// ── Audio (waveform + ID3) ───────────────────────────────────────────
149 +async function renderAudio(stage, desc, urls) {
150 + const art = h('div.art', { html: fileIcon('audio') });
151 + const title = h('div.t', {}, desc.name);
152 + const artist = h('div.a', {}, desc.probe?.duration ? fmtDuration(desc.probe.duration) : '');
153 + const wave = h('div', { id: 'waveform' });
154 + const playBtn = h('button.btn.primary', {}, '▶ Play');
155 + const time = h('span.time', {}, '0:00');
156 + const box = h('div.pv-audio', {},
157 + h('div.top', {}, art, h('div.tt', {}, title, artist)),
158 + wave,
159 + h('div.audio-controls', {},
160 + playBtn,
161 + ...[1, 1.5, 2].map((r) => h('button.btn', { onclick: () => ws?.setPlaybackRate(r) }, `${r}×`)),
162 + h('button.btn', { id: 'loopBtn', onclick: (e) => { loop = !loop; e.currentTarget.classList.toggle('primary', loop); } }, 'Loop'),
163 + time),
164 + );
165 + stage.append(box);
166 +
167 + // Best-effort ID3v2 read for title/artist/cover (first 256 KB).
168 + readId3(urls.stream).then((tags) => {
169 + if (tags?.title) title.textContent = tags.title;
170 + if (tags?.artist) artist.textContent = tags.artist + (artist.textContent ? ` · ${artist.textContent}` : '');
171 + if (tags?.cover) { art.innerHTML = ''; art.append(h('img', { src: tags.cover })); }
172 + }).catch(() => {});
173 +
174 + let ws = null; let loop = false;
175 + try {
176 + const { default: WaveSurfer } = await import('/vendor/wavesurfer/wavesurfer.esm.js');
177 + let peaks = null;
178 + try {
179 + const res = await fetch(`${desc.base}/peaks`, { credentials: 'same-origin' });
180 + peaks = (await res.json()).peaks;
181 + if (!peaks?.length) peaks = null;
182 + } catch {}
183 + ws = WaveSurfer.create({
184 + container: wave,
185 + url: urls.stream,
186 + peaks: peaks ? [peaks] : undefined,
187 + duration: peaks ? desc.probe?.duration : undefined,
188 + height: 72,
189 + waveColor: 'rgba(139, 147, 163, 0.55)',
190 + progressColor: '#4f8cff',
191 + cursorColor: '#22d3aa',
192 + barWidth: 2, barGap: 1, barRadius: 2,
193 + });
194 + ws.on('timeupdate', (t) => { time.textContent = `${fmtDuration(t)} / ${fmtDuration(ws.getDuration())}`; });
195 + ws.on('finish', () => { if (loop) { ws.seekTo(0); ws.play(); } else playBtn.textContent = '▶ Play'; });
196 + playBtn.onclick = () => {
197 + ws.playPause();
198 + playBtn.textContent = ws.isPlaying() ? '⏸ Pause' : '▶ Play';
199 + };
200 + } catch {
201 + // Wavesurfer unavailable → native audio element.
202 + wave.replaceWith(h('audio', { src: urls.stream, controls: true, style: { width: '100%' } }));
203 + playBtn.style.display = 'none';
204 + }
205 +}
206 +
207 +/** Minimal ID3v2 parser: TIT2/TPE1 + APIC cover. */
208 +async function readId3(url) {
209 + const res = await fetch(url, { headers: { range: 'bytes=0-262143' }, credentials: 'same-origin' });
210 + const buf = new Uint8Array(await res.arrayBuffer());
211 + if (buf[0] !== 0x49 || buf[1] !== 0x44 || buf[2] !== 0x33) return null; // "ID3"
212 + const synch = (o) => (buf[o] << 21) | (buf[o + 1] << 14) | (buf[o + 2] << 7) | buf[o + 3];
213 + const tagSize = Math.min(synch(6) + 10, buf.length);
214 + const td = new TextDecoder('utf-8'); const tl = new TextDecoder('latin1');
215 + const out = {};
216 + let off = 10;
217 + while (off + 10 < tagSize) {
218 + const id = tl.decode(buf.slice(off, off + 4));
219 + if (!/^[A-Z0-9]{4}$/.test(id)) break;
220 + const size = buf[10] >= 4 ? synch(off + 4)
221 + : (buf[off + 4] << 24) | (buf[off + 5] << 16) | (buf[off + 6] << 8) | buf[off + 7];
222 + const body = buf.slice(off + 10, off + 10 + size);
223 + if ((id === 'TIT2' || id === 'TPE1') && body.length > 1) {
224 + const enc = body[0];
225 + const text = enc === 1 || enc === 2
226 + ? new TextDecoder('utf-16').decode(body.slice(1))
227 + : (enc === 3 ? td : tl).decode(body.slice(1));
228 + out[id === 'TIT2' ? 'title' : 'artist'] = text.replace(/\0+$/, '').replace(/^/, '');
229 + }
230 + if (id === 'APIC' && body.length > 10) {
231 + let p = 1;
232 + while (p < body.length && body[p] !== 0) p += 1; // mime
233 + const mimeStr = tl.decode(body.slice(1, p));
234 + p += 2; // skip null + picture type
235 + while (p < body.length && body[p] !== 0) p += 1; // description
236 + p += 1;
237 + if (p < body.length) {
238 + out.cover = URL.createObjectURL(new Blob([body.slice(p)], { type: mimeStr || 'image/jpeg' }));
239 + }
240 + }
241 + off += 10 + size;
242 + }
243 + return out;
244 +}
245 +
246 +// ── PDF (pdf.js) ─────────────────────────────────────────────────────
247 +async function renderPdf(stage, desc, urls, srcOverride, note) {
248 + const pdfjs = await import('/vendor/pdfjs/pdf.min.mjs');
249 + pdfjs.GlobalWorkerOptions.workerSrc = '/vendor/pdfjs/pdf.worker.min.mjs';
250 +
251 + const rail = h('div.pdf-rail');
252 + const main = h('div.pdf-main');
253 + const pageInput = h('input', { type: 'number', min: 1, value: 1, style: { width: '58px', textAlign: 'center' } });
254 + const searchInput = h('input', { type: 'search', placeholder: 'Search in document…', style: { width: '190px' } });
255 + const searchInfo = h('span.muted');
256 + let zoom = 1.2;
257 + const strip = h('div.pv-toolstrip', {},
258 + note ? h('span.chip', {}, note) : null,
259 + pageInput, h('span.muted', { id: 'pageCount' }),
260 + h('button.btn.icon.ghost', { html: UI.zoomOut, title: 'Zoom out', onclick: () => rescale(zoom * 0.85) }),
261 + h('button.btn.icon.ghost', { html: UI.zoomIn, title: 'Zoom in', onclick: () => rescale(zoom * 1.2) }),
262 + searchInput, searchInfo,
263 + h('button.btn.icon.ghost', { title: 'Print', onclick: () => window.print() }, '🖨'),
264 + );
265 + const viewer = h('div.pv-pdf', {}, rail, h('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 } }, strip, main));
266 + stage.append(viewer);
267 +
268 + const doc = await pdfjs.getDocument({ url: srcOverride ?? urls.stream, withCredentials: true }).promise;
269 + strip.querySelector('#pageCount').textContent = `/ ${doc.numPages}`;
270 + pageInput.max = doc.numPages;
271 +
272 + const pages = [];
273 + for (let i = 1; i <= doc.numPages; i += 1) {
274 + const holder = h('div.pdf-page', { dataset: { page: i } });
275 + main.append(holder);
276 + pages.push({ holder, rendered: false, i });
277 + }
278 +
279 + const renderPage = async (entry, scale = zoom) => {
280 + const page = await doc.getPage(entry.i);
281 + const viewport = page.getViewport({ scale: scale * (window.devicePixelRatio > 1 ? 1.5 : 1) });
282 + const canvas = h('canvas');
283 + canvas.width = viewport.width; canvas.height = viewport.height;
284 + canvas.style.width = `${viewport.width / (window.devicePixelRatio > 1 ? 1.5 : 1)}px`;
285 + await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;
286 + const text = await page.getTextContent();
287 + const textLayer = h('div.textLayer', { style: { width: canvas.style.width, height: `${parseFloat(canvas.style.width) * (viewport.height / viewport.width)}px` } });
288 + // Lightweight text layer for selection + search highlighting.
289 + const cssScale = parseFloat(canvas.style.width) / page.getViewport({ scale: 1 }).width;
290 + for (const item of text.items) {
291 + if (!item.str) continue;
292 + const [a, b, , d, e, f] = item.transform;
293 + const span = h('span', {
294 + style: {
295 + left: `${e * cssScale}px`,
296 + bottom: `${f * cssScale}px`,
297 + fontSize: `${Math.hypot(a, b) * cssScale}px`,
298 + fontFamily: 'sans-serif',
299 + },
300 + }, item.str);
301 + span.dataset.txt = item.str.toLowerCase();
302 + textLayer.append(span);
303 + }
304 + entry.holder.innerHTML = '';
305 + entry.holder.append(canvas, textLayer);
306 + entry.rendered = true;
307 + };
308 +
309 + // Lazy render on scroll.
310 + const io = new IntersectionObserver((entries) => {
311 + for (const it of entries) {
312 + if (!it.isIntersecting) continue;
313 + const entry = pages[Number(it.target.dataset.page) - 1];
314 + if (!entry.rendered) renderPage(entry);
315 + pageInput.value = entry.i;
316 + rail.querySelectorAll('.cur').forEach((n) => n.classList.remove('cur'));
317 + rail.querySelector(`[data-rp="${entry.i}"]`)?.classList.add('cur');
318 + }
319 + }, { root: main, threshold: 0.15 });
320 + pages.forEach((p) => io.observe(p.holder));
321 + renderPage(pages[0]);
322 +
323 + // Thumbnail rail.
324 + (async () => {
325 + for (let i = 1; i <= Math.min(doc.numPages, 60); i += 1) {
326 + const page = await doc.getPage(i);
327 + const vp = page.getViewport({ scale: 110 / page.getViewport({ scale: 1 }).width });
328 + const canvas = h('canvas');
329 + canvas.width = vp.width; canvas.height = vp.height;
330 + await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise;
331 + const cell = h('div', { dataset: { rp: i }, onclick: () => pages[i - 1].holder.scrollIntoView() },
332 + canvas, h('div.pn', {}, String(i)));
333 + rail.append(cell);
334 + }
335 + })();
336 +
337 + const rescale = (z) => {
338 + zoom = Math.min(Math.max(z, 0.4), 4);
339 + pages.forEach((p) => { p.rendered = false; p.holder.innerHTML = ''; });
340 + const visible = Number(pageInput.value) - 1;
341 + renderPage(pages[visible]);
342 + };
343 +
344 + pageInput.addEventListener('change', () => {
345 + const target = pages[Math.min(Math.max(Number(pageInput.value), 1), doc.numPages) - 1];
346 + target.holder.scrollIntoView();
347 + });
348 +
349 + searchInput.addEventListener('keydown', async (e) => {
350 + if (e.key !== 'Enter') return;
351 + const q = searchInput.value.trim().toLowerCase();
352 + main.querySelectorAll('.hl').forEach((n) => n.classList.remove('hl'));
353 + if (!q) { searchInfo.textContent = ''; return; }
354 + let hits = 0; let first = null;
355 + for (const entry of pages) {
356 + if (!entry.rendered) {
357 + const page = await doc.getPage(entry.i);
358 + const text = await page.getTextContent();
359 + if (!text.items.some((it) => it.str?.toLowerCase().includes(q))) continue;
360 + await renderPage(entry);
361 + }
362 + entry.holder.querySelectorAll('.textLayer span').forEach((span) => {
363 + if (span.dataset.txt?.includes(q)) {
364 + span.classList.add('hl');
365 + hits += 1;
366 + first ??= span;
367 + }
368 + });
369 + }
370 + searchInfo.textContent = hits ? `${hits} match${hits > 1 ? 'es' : ''}` : 'No matches';
371 + first?.scrollIntoView({ block: 'center' });
372 + });
373 +}
374 +
375 +// ── Office → PDF ─────────────────────────────────────────────────────
376 +async function renderOffice(stage, desc, urls) {
377 + if (desc.office === 'unsupported') {
378 + RENDERERS.fallback(stage, { ...desc, note: 'Office preview needs LibreOffice on the server.' }, urls);
379 + return;
380 + }
381 + const isSheet = /^(xlsx|xls|ods|csv)$/.test(desc.ext);
382 + if (desc.office === 'ready') {
383 + await renderPdf(stage, desc, urls, `${desc.base}/pdf`, 'Converted preview — download for original');
384 + if (isSheet) addSheetToggle(stage, desc, urls);
385 + return;
386 + }
387 + if (isSheet) {
388 + // Fast path: render the workbook natively right away.
389 + await renderSheet(stage, desc, urls);
390 + return;
391 + }
392 + stage.append(processing('Converting document…'));
393 + const poll = async () => {
394 + try {
395 + const res = await fetch(`${desc.base}/pdf`, { method: 'HEAD', credentials: 'same-origin' });
396 + if (res.ok) {
397 + stage.innerHTML = '';
398 + await renderPdf(stage, desc, urls, `${desc.base}/pdf`, 'Converted preview — download for original');
399 + return;
400 + }
401 + if (res.status === 422) {
402 + stage.innerHTML = '';
403 + RENDERERS.fallback(stage, { ...desc, note: 'Conversion unavailable on this server.' }, urls);
404 + return;
405 + }
406 + } catch {}
407 + if (stage.isConnected) setTimeout(poll, 3000);
408 + };
409 + setTimeout(poll, 2500);
410 +}
411 +
412 +function addSheetToggle(stage, desc, urls) {
413 + const strip = stage.querySelector('.pv-toolstrip');
414 + strip?.append(h('button.btn', {
415 + onclick: async () => { stage.innerHTML = ''; await renderSheet(stage, desc, urls); },
416 + }, 'Table view'));
417 +}
418 +
419 +async function renderSheet(stage, desc, urls) {
420 + await loadScript('/vendor/xlsx/xlsx.full.min.js');
421 + const buf = await (await fetch(urls.stream.replace('/stream/', '/dl/').includes('/dl/') ? urls.dl ?? urls.stream : urls.stream, { credentials: 'same-origin' })).arrayBuffer();
422 + const wb = globalThis.XLSX.read(buf, { type: 'array' });
423 + const { el, scroll } = panel();
424 + const strip = h('div.pv-toolstrip');
425 + el.prepend(strip);
426 + const show = (name) => {
427 + scroll.innerHTML = '';
428 + const html = globalThis.XLSX.utils.sheet_to_html(wb.Sheets[name], { header: '', footer: '' });
429 + const wrap = h('div.csv-wrap', { html });
430 + wrap.querySelector('table')?.classList.add('csv-table');
431 + scroll.append(wrap);
432 + strip.querySelectorAll('.chip').forEach((c) => c.classList.toggle('active', c.textContent === name));
433 + };
434 + for (const name of wb.SheetNames) {
435 + strip.append(h('button.chip', { onclick: () => show(name) }, name));
436 + }
437 + stage.append(el);
438 + show(wb.SheetNames[0]);
439 +}
440 +
441 +const loadedScripts = new Set();
442 +function loadScript(src) {
443 + if (loadedScripts.has(src)) return Promise.resolve();
444 + return new Promise((resolve, reject) => {
445 + const s = h('script', { src });
446 + s.onload = () => { loadedScripts.add(src); resolve(); };
447 + s.onerror = reject;
448 + document.head.append(s);
449 + });
450 +}
451 +
452 +// ── Code / text ──────────────────────────────────────────────────────
453 +async function renderCode(stage, desc, urls) {
454 + const res = await fetch(`${desc.base}/text`, { credentials: 'same-origin' });
455 + if (!res.ok) { RENDERERS.fallback(stage, desc, urls); return; }
456 + const { html, lang, clipped, raw } = await res.json();
457 + const { el, scroll } = panel();
458 + const code = h('div.pv-code.linenums', { html });
459 + scroll.append(code);
460 + const strip = h('div.pv-toolstrip', {},
461 + h('span.chip', {}, lang),
462 + clipped ? h('span.chip', {}, 'truncated preview') : null,
463 + h('button.btn', {
464 + onclick: (e) => {
465 + code.classList.toggle('wrap');
466 + e.currentTarget.classList.toggle('primary');
467 + },
468 + }, 'Wrap'),
469 + raw !== null ? h('button.btn', { onclick: () => copyText(raw, 'Source copied') }, 'Copy') : null,
470 + );
471 + el.prepend(strip);
472 + stage.append(el);
473 +}
474 +
475 +// ── Markdown ─────────────────────────────────────────────────────────
476 +async function renderMarkdownFile(stage, desc, urls) {
477 + const res = await fetch(`${desc.base}/markdown`, { credentials: 'same-origin' });
478 + if (!res.ok) { RENDERERS.fallback(stage, desc, urls); return; }
479 + const { html, raw } = await res.json();
480 + const { el, scroll } = panel();
481 + const rendered = h('div.md-body', { html });
482 + const source = h('pre.pv-code', { style: { display: 'none', font: '12.5px/1.6 var(--font-mono)', whiteSpace: 'pre-wrap', margin: 0 } }, raw);
483 + scroll.append(rendered, source);
484 + el.prepend(h('div.pv-toolstrip', {},
485 + h('button.btn.primary', {
486 + onclick: (e) => {
487 + const showSrc = source.style.display === 'none';
488 + source.style.display = showSrc ? '' : 'none';
489 + rendered.style.display = showSrc ? 'none' : '';
490 + e.currentTarget.textContent = showSrc ? 'Rendered' : 'Source';
491 + },
492 + }, 'Source'),
493 + ));
494 + stage.append(el);
495 +}
496 +
497 +// ── CSV / TSV ────────────────────────────────────────────────────────
498 +function parseCsv(text, delim) {
499 + const rows = [];
500 + let row = []; let cell = ''; let quoted = false;
501 + for (let i = 0; i < text.length; i += 1) {
502 + const ch = text[i];
503 + if (quoted) {
504 + if (ch === '"') {
505 + if (text[i + 1] === '"') { cell += '"'; i += 1; } else quoted = false;
506 + } else cell += ch;
507 + } else if (ch === '"') quoted = true;
508 + else if (ch === delim) { row.push(cell); cell = ''; }
509 + else if (ch === '\n' || ch === '\r') {
510 + if (ch === '\r' && text[i + 1] === '\n') i += 1;
511 + row.push(cell); cell = '';
512 + if (row.length > 1 || row[0] !== '') rows.push(row);
513 + row = [];
514 + } else cell += ch;
515 + }
516 + if (cell !== '' || row.length) { row.push(cell); rows.push(row); }
517 + return rows;
518 +}
519 +
520 +async function renderCsv(stage, desc, urls) {
521 + const res = await fetch(`${desc.base}/raw`, { credentials: 'same-origin' });
522 + if (!res.ok) { RENDERERS.fallback(stage, desc, urls); return; }
523 + const text = await res.text();
524 + const firstLine = text.slice(0, text.indexOf('\n'));
525 + const delim = desc.ext === 'tsv' ? '\t'
526 + : [',', ';', '\t', '|'].reduce((best, d) =>
527 + firstLine.split(d).length > firstLine.split(best).length ? d : best, ',');
528 + const all = parseCsv(text, delim);
529 + const header = all[0] ?? [];
530 + let rows = all.slice(1);
531 + const original = rows;
532 +
533 + const { el, scroll } = panel();
534 + const wrap = h('div.csv-wrap');
535 + scroll.style.padding = '0';
536 + scroll.append(wrap);
537 +
538 + const CHUNK = 300;
539 + let shown = 0;
540 + let tbody;
541 + const table = h('table.csv-table');
542 +
543 + const renderHead = () => {
544 + const tr = h('tr');
545 + header.forEach((name, ci) => {
546 + let dir = 1;
547 + tr.append(h('th', {
548 + title: 'Click to sort',
549 + onclick: () => {
550 + rows = [...rows].sort((a, b) => {
551 + const x = a[ci] ?? ''; const y = b[ci] ?? '';
552 + const nx = parseFloat(x); const ny = parseFloat(y);
553 + const cmp = !Number.isNaN(nx) && !Number.isNaN(ny) ? nx - ny : x.localeCompare(y);
554 + return cmp * dir;
555 + });
556 + dir *= -1;
557 + reset();
558 + },
559 + }, name));
560 + });
561 + table.append(h('thead', {}, tr));
562 + };
563 +
564 + const appendChunk = () => {
565 + const frag = document.createDocumentFragment();
566 + for (const row of rows.slice(shown, shown + CHUNK)) {
567 + const tr = h('tr');
568 + for (let c = 0; c < header.length; c += 1) tr.append(h('td', {}, row[c] ?? ''));
569 + frag.append(tr);
570 + }
571 + shown = Math.min(shown + CHUNK, rows.length);
572 + tbody.append(frag);
573 + counter.textContent = `${rows.length.toLocaleString()} rows · delimiter "${delim === '\t' ? '\\t' : delim}"`;
574 + };
575 +
576 + const reset = () => {
577 + table.innerHTML = '';
578 + renderHead();
579 + tbody = h('tbody');
580 + table.append(tbody);
581 + shown = 0;
582 + appendChunk();
583 + };
584 +
585 + wrap.append(table);
586 + wrap.addEventListener('scroll', () => {
587 + if (wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - 600 && shown < rows.length) appendChunk();
588 + });
589 +
590 + const counter = h('span.muted');
591 + const search = h('input', {
592 + type: 'search', placeholder: 'Filter cells…', style: { width: '190px' },
593 + oninput: () => {
594 + const q = search.value.toLowerCase();
595 + rows = q ? original.filter((r) => r.some((c) => c?.toLowerCase().includes(q))) : original;
596 + reset();
597 + },
598 + });
599 + el.prepend(h('div.pv-toolstrip', {}, search, counter));
600 + stage.append(el);
601 + reset();
602 +}
603 +
604 +// ── Structured (json/yaml/toml/xml) ──────────────────────────────────
605 +function jsonTree(value, key) {
606 + const label = key !== undefined ? h('span.json-key', {}, `${JSON.stringify(key)}: `) : '';
607 + if (value === null) return h('div', {}, label, h('span.json-null', {}, 'null'));
608 + if (typeof value === 'string') return h('div', {}, label, h('span.json-str', {}, JSON.stringify(value)));
609 + if (typeof value === 'number') return h('div', {}, label, h('span.json-num', {}, String(value)));
610 + if (typeof value === 'boolean') return h('div', {}, label, h('span.json-bool', {}, String(value)));
611 + const isArr = Array.isArray(value);
612 + const entries = isArr ? value.map((v, i) => [i, v]) : Object.entries(value);
613 + const det = h('details', { open: entries.length <= 24 },
614 + h('summary', {}, label, h('span.muted', {}, isArr ? `Array(${entries.length})` : `Object {${entries.length}}`)));
615 + for (const [k, v] of entries.slice(0, 2000)) det.append(jsonTree(v, isArr ? undefined : k));
616 + if (entries.length > 2000) det.append(h('div.muted', {}, `… ${entries.length - 2000} more`));
617 + return det;
618 +}
619 +
620 +async function renderStructured(stage, desc, urls) {
621 + if (desc.ext === 'json') {
622 + try {
623 + const text = await (await fetch(`${desc.base}/raw`, { credentials: 'same-origin' })).text();
624 + const value = JSON.parse(text);
625 + const { el, scroll } = panel();
626 + const pretty = h('div.pv-code.linenums', { style: { display: 'none' } });
627 + const tree = h('div.json-tree', {}, jsonTree(value));
628 + scroll.append(tree, pretty);
629 + let prettyLoaded = false;
630 + el.prepend(h('div.pv-toolstrip', {},
631 + h('button.btn.primary', {
632 + onclick: async (e) => {
633 + const showPretty = pretty.style.display === 'none';
634 + if (showPretty && !prettyLoaded) {
635 + const res = await fetch(`${desc.base}/text`, { credentials: 'same-origin' });
636 + pretty.innerHTML = (await res.json()).html;
637 + prettyLoaded = true;
638 + }
639 + pretty.style.display = showPretty ? '' : 'none';
640 + tree.style.display = showPretty ? 'none' : '';
641 + e.currentTarget.textContent = showPretty ? 'Tree' : 'Source';
642 + },
643 + }, 'Source'),
644 + h('button.btn', { onclick: () => copyText(text, 'JSON copied') }, 'Copy'),
645 + ));
646 + stage.append(el);
647 + return;
648 + } catch { /* fall through to highlighted source */ }
649 + }
650 + await renderCode(stage, desc, urls);
651 +}
652 +
653 +// ── Notebook (ipynb) ─────────────────────────────────────────────────
654 +function miniMd(text) {
655 + const escd = text.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
656 + return escd
657 + .replace(/^###### (.*)$/gm, '<h6>$1</h6>').replace(/^##### (.*)$/gm, '<h5>$1</h5>')
658 + .replace(/^#### (.*)$/gm, '<h4>$1</h4>').replace(/^### (.*)$/gm, '<h3>$1</h3>')
659 + .replace(/^## (.*)$/gm, '<h2>$1</h2>').replace(/^# (.*)$/gm, '<h1>$1</h1>')
660 + .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
661 + .replace(/`([^`]+)`/g, '<code>$1</code>')
662 + .replace(/\n\n/g, '<br><br>');
663 +}
664 +
665 +async function renderNotebook(stage, desc, urls) {
666 + const res = await fetch(`${desc.base}/raw`, { credentials: 'same-origin' });
667 + if (!res.ok) { RENDERERS.fallback(stage, desc, urls); return; }
668 + let nb;
669 + try { nb = JSON.parse(await res.text()); } catch { RENDERERS.fallback(stage, desc, urls); return; }
670 + const { el, scroll } = panel();
671 + const lang = nb.metadata?.kernelspec?.language ?? 'python';
672 + el.prepend(h('div.pv-toolstrip', {},
673 + h('span.chip', {}, `${nb.cells?.length ?? 0} cells`),
674 + h('span.chip', {}, nb.metadata?.kernelspec?.display_name ?? lang)));
675 + for (const cell of nb.cells ?? []) {
676 + const src = Array.isArray(cell.source) ? cell.source.join('') : cell.source ?? '';
677 + if (cell.cell_type === 'markdown') {
678 + scroll.append(h('div.md-body', { html: miniMd(src), style: { marginBottom: '14px' } }));
679 + } else if (cell.cell_type === 'code') {
680 + scroll.append(h('pre.code-fence', {
681 + style: {
682 + background: 'var(--surface-2)', border: '1px solid var(--border)',
683 + borderRadius: '7px', padding: '11px 14px', overflowX: 'auto',
684 + font: '12.5px/1.6 var(--font-mono)', margin: '0 0 8px',
685 + },
686 + }, src));
687 + for (const out of cell.outputs ?? []) {
688 + const imgB64 = out.data?.['image/png'];
689 + if (imgB64) {
690 + scroll.append(h('img', {
691 + src: `data:image/png;base64,${Array.isArray(imgB64) ? imgB64.join('') : imgB64}`,
692 + style: { maxWidth: '100%', marginBottom: '14px', borderRadius: '7px' },
693 + }));
694 + } else {
695 + const txt = out.text ?? out.data?.['text/plain'];
696 + if (txt) {
697 + scroll.append(h('pre', {
698 + style: { font: '12px/1.5 var(--font-mono)', color: 'var(--muted)', whiteSpace: 'pre-wrap', margin: '0 0 14px', paddingLeft: '12px', borderLeft: '2px solid var(--border)' },
699 + }, Array.isArray(txt) ? txt.join('') : String(txt)));
700 + }
701 + }
702 + }
703 + }
704 + }
705 + stage.append(el);
706 +}
707 +
708 +// ── Archives ─────────────────────────────────────────────────────────
709 +async function renderArchive(stage, desc, urls) {
710 + const res = await fetch(`${desc.base}/archive`, { credentials: 'same-origin' });
711 + if (!res.ok) {
712 + RENDERERS.fallback(stage, { ...desc, note: 'Listing this archive needs the 7z tool on the server.' }, urls);
713 + return;
714 + }
715 + const { entries, truncated } = await res.json();
716 + const { el, scroll } = panel();
717 + el.prepend(h('div.pv-toolstrip', {},
718 + h('span.chip', {}, `${entries.length}${truncated ? '+' : ''} entries`)));
719 + const list = h('div.arc-list');
720 + scroll.append(list);
721 +
722 + const previewable = (p) => /\.(png|jpe?g|gif|webp|txt|md|json|js|mjs|ts|py|csv|html|css|xml|yml|yaml|log|sh)$/i.test(p);
723 + for (const entry of entries) {
724 + if (entry.dir) continue;
725 + const row = h('div.arc-row', {
726 + onclick: () => {
727 + if (!previewable(entry.path)) {
728 + location.href = `${desc.base}/archive/member?path=${encodeURIComponent(entry.path)}&download=1`;
729 + return;
730 + }
731 + showMember(desc, entry);
732 + },
733 + },
734 + h('span', { html: fileIcon(/\.(png|jpe?g|gif|webp)$/i.test(entry.path) ? 'image' : 'text'), style: { display: 'contents' } }),
735 + h('span', {}, entry.path),
736 + h('span.sz', {}, fmtSize(entry.size)));
737 + list.append(row);
738 + }
739 + stage.append(el);
740 +}
741 +
742 +async function showMember(desc, entry) {
743 + const { modal } = await import('./ui.js');
744 + const url = `${desc.base}/archive/member?path=${encodeURIComponent(entry.path)}`;
745 + const body = h('div', { style: { maxHeight: '60vh', overflow: 'auto' } });
746 + if (/\.(png|jpe?g|gif|webp)$/i.test(entry.path)) {
747 + body.append(h('img', { src: url, style: { maxWidth: '100%' } }));
748 + } else {
749 + const text = await (await fetch(url, { credentials: 'same-origin' })).text();
750 + body.append(h('pre', { style: { font: '12px/1.55 var(--font-mono)', whiteSpace: 'pre-wrap' } }, text.slice(0, 400_000)));
751 + }
752 + modal({
753 + title: entry.path.split('/').pop(),
754 + body,
755 + wide: true,
756 + actions: [
757 + { label: 'Download', onClick: () => { location.href = `${url}&download=1`; return false; } },
758 + { label: 'Close', primary: true, onClick: () => {} },
759 + ],
760 + });
761 +}
762 +
763 +// ── Fonts ────────────────────────────────────────────────────────────
764 +async function renderFont(stage, desc, urls) {
765 + const family = `pv-font-${desc.id}`;
766 + const face = new FontFace(family, `url(${urls.stream})`);
767 + await face.load();
768 + document.fonts.add(face);
769 + const { el, scroll } = panel();
770 + const sizeInput = h('input', { type: 'range', min: 10, max: 96, value: 34, style: { width: '180px' } });
771 + el.prepend(h('div.pv-toolstrip', {}, h('span.chip', {}, desc.ext.toUpperCase()), 'Size', sizeInput));
772 + const spec = h('div.font-specimen', { style: { fontFamily: `'${family}'` } },
773 + h('h2', {}, desc.name.replace(/\.[^.]+$/, '')),
774 + h('div.alpha', {}, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 &@%$#!?'),
775 + ...[
776 + 'The quick brown fox jumps over the lazy dog.',
777 + 'Portez ce vieux whisky au juge blond qui fume. 0123456789',
778 + 'Sphinx of black quartz, judge my vow — «SPB Drive».',
779 + ].map((s) => h('p.pangram', { style: { fontSize: '21px' } }, s)));
780 + sizeInput.addEventListener('input', () => {
781 + spec.querySelectorAll('.pangram').forEach((p) => { p.style.fontSize = `${sizeInput.value}px`; });
782 + });
783 + scroll.append(spec);
784 + stage.append(el);
785 +}
786 +
787 +// ── Email (eml) ──────────────────────────────────────────────────────
788 +function decodeQP(text) {
789 + return text.replace(/=\r?\n/g, '').replace(/=([0-9A-F]{2})/gi, (_, hx) => String.fromCharCode(parseInt(hx, 16)));
790 +}
791 +
792 +async function renderEmail(stage, desc, urls) {
793 + const res = await fetch(`${desc.base}/raw`, { credentials: 'same-origin' });
794 + if (!res.ok) { RENDERERS.fallback(stage, desc, urls); return; }
795 + const raw = await res.text();
796 + const headEnd = raw.search(/\r?\n\r?\n/);
797 + const headText = raw.slice(0, headEnd).replace(/\r?\n[ \t]+/g, ' ');
798 + const headers = {};
799 + for (const line of headText.split(/\r?\n/)) {
800 + const m = line.match(/^([\w-]+):\s*(.*)$/);
801 + if (m) headers[m[1].toLowerCase()] = m[2];
802 + }
803 + let body = raw.slice(headEnd).trim();
804 + const attachments = [];
805 + const ctype = headers['content-type'] ?? '';
806 + const boundary = ctype.match(/boundary="?([^";]+)"?/)?.[1];
807 + if (boundary) {
808 + const parts = body.split(`--${boundary}`).slice(1, -1);
809 + let best = '';
810 + for (const part of parts) {
811 + const pEnd = part.search(/\r?\n\r?\n/);
812 + const pHead = part.slice(0, pEnd).toLowerCase();
813 + const pBody = part.slice(pEnd).trim();
814 + const fname = pHead.match(/filename="?([^";\r\n]+)"?/)?.[1];
815 + if (fname) { attachments.push(fname); continue; }
816 + if (pHead.includes('text/plain') && !best) {
817 + best = pHead.includes('quoted-printable') ? decodeQP(pBody)
818 + : pHead.includes('base64') ? atob(pBody.replace(/\s/g, '')) : pBody;
819 + }
820 + }
821 + body = best || '(no plain-text body)';
822 + } else if ((headers['content-transfer-encoding'] ?? '').includes('quoted-printable')) {
823 + body = decodeQP(body);
824 + }
825 + const decodeHdr = (s) => (s ?? '').replace(/=\?utf-8\?([qb])\?([^?]+)\?=/gi, (_, enc, val) =>
826 + enc.toLowerCase() === 'b' ? decodeURIComponent(escape(atob(val))) : decodeQP(val.replace(/_/g, ' ')));
827 + const { el, scroll } = panel();
828 + const kv = h('dl.kv');
829 + for (const key of ['from', 'to', 'cc', 'date', 'subject']) {
830 + if (headers[key]) kv.append(h('dt', {}, key[0].toUpperCase() + key.slice(1)), h('dd', {}, decodeHdr(headers[key])));
831 + }
832 + scroll.append(kv);
833 + if (attachments.length) {
834 + scroll.append(h('div.pv-toolstrip', { style: { border: 'none', padding: '0 0 10px' } },
835 + ...attachments.map((a) => h('span.chip', {}, `📎 ${a}`))));
836 + }
837 + scroll.append(h('pre', { style: { font: '13px/1.6 var(--font-sans)', whiteSpace: 'pre-wrap', borderTop: '1px solid var(--border)', paddingTop: '14px' } }, body));
838 + stage.append(el);
839 +}
840 +
841 +// ── SVG / HEIC ───────────────────────────────────────────────────────
842 +async function renderSvg(stage, desc, urls) {
843 + await renderImage(stage, desc, urls); // /stream serves it sandboxed inline
844 +}
845 +
846 +async function renderHeic(stage, desc, urls) {
847 + if (desc.heicSupported) {
848 + await renderImage(stage, desc, urls, `${desc.base}/heic`);
849 + return;
850 + }
851 + RENDERERS.fallback(stage, { ...desc, note: 'HEIC decoding not available on this server.' }, urls);
852 +}
853 +
854 +// ── Fallback card ────────────────────────────────────────────────────
855 +function renderFallback(stage, desc, urls) {
856 + stage.append(h('div.fallback-card', {},
857 + h('div', { html: fileIcon(desc.icon ?? 'file') }),
858 + h('h3', {}, desc.name),
859 + h('div.fm', {}, `${fmtSize(desc.size)} · ${desc.mime ?? 'unknown type'}`),
860 + desc.note ? h('div.fm', { style: { color: 'var(--warning)' } }, desc.note) : null,
861 + desc.sha ? h('div.sha', {}, `sha256 ${desc.sha}`) : null,
862 + urls.dl ? h('a.btn.primary', { href: urls.dl, download: '' },
863 + h('span', { html: UI.download, style: { display: 'contents' } }), 'Download') : null,
864 + ));
865 +}
866 +
867 +const RENDERERS = {
868 + image: renderImage,
869 + svg: renderSvg,
870 + heic: renderHeic,
871 + video: renderVideo,
872 + audio: renderAudio,
873 + pdf: (s, d, u) => renderPdf(s, d, u),
874 + office: renderOffice,
875 + code: renderCode,
876 + markdown: renderMarkdownFile,
877 + csv: renderCsv,
878 + structured: renderStructured,
879 + notebook: renderNotebook,
880 + archive: renderArchive,
881 + font: renderFont,
882 + email: renderEmail,
883 + epub: renderFallback,
884 + model3d: renderFallback,
885 + fallback: renderFallback,
886 +};
887 +
888 +// ── Full-screen viewer overlay (app + folder shares) ─────────────────
889 +export class Viewer {
890 + /**
891 + * @param {object[]} items sibling file list
892 + * @param {number} index starting position
893 + * @param {{descUrl(n), streamUrl(n), dlUrl(n), actions?: Array}} opts
894 + */
895 + constructor(items, index, opts) {
896 + this.items = items.filter((n) => n.type === 'file');
897 + this.index = Math.max(0, this.items.findIndex((n) => n.id === items[index]?.id));
898 + this.opts = opts;
899 + this.build();
900 + this.show();
901 + }
902 +
903 + build() {
904 + this.overlay = h('div.preview-overlay', { role: 'dialog', 'aria-label': 'Preview' });
905 + this.title = h('span.title');
906 + this.sizeEl = h('span.size');
907 + const actions = h('div.p-actions');
908 + for (const action of this.opts.actions ?? []) {
909 + actions.append(h('button.btn.icon', {
910 + title: action.title, html: action.icon,
911 + onclick: () => action.onClick(this.items[this.index], this),
912 + }));
913 + }
914 + actions.append(h('button.btn.icon', { title: 'Close (Esc)', html: UI.close, onclick: () => this.close() }));
915 + this.stage = h('div.preview-stage');
916 + this.count = h('div.preview-count');
917 + this.body = h('div.preview-body', {},
918 + h('button.preview-nav.prev', { html: UI.chevronL, 'aria-label': 'Previous', onclick: () => this.nav(-1) }),
919 + this.stage,
920 + h('button.preview-nav.next', { html: UI.chevron, 'aria-label': 'Next', onclick: () => this.nav(1) }),
921 + this.count);
922 + this.overlay.append(h('div.preview-head', {}, this.title, this.sizeEl, actions), this.body);
923 + this.onKey = (e) => {
924 + if (e.target.matches('input, textarea')) return;
925 + if (e.key === 'Escape') this.close();
926 + if (e.key === 'ArrowLeft' && !e.target.matches('video')) this.nav(-1);
927 + if (e.key === 'ArrowRight' && !e.target.matches('video')) this.nav(1);
928 + };
929 + document.addEventListener('keydown', this.onKey);
930 + document.body.append(this.overlay);
931 + }
932 +
933 + async show() {
934 + const node = this.items[this.index];
935 + if (!node) { this.close(); return; }
936 + this.title.textContent = node.name;
937 + this.sizeEl.textContent = fmtSize(node.size);
938 + this.count.textContent = this.items.length > 1 ? `${this.index + 1} / ${this.items.length}` : '';
939 + this.stage.innerHTML = '';
940 + this.stage.append(processing('Loading…'));
941 + try {
942 + const res = await fetch(this.opts.descUrl(node), { credentials: 'same-origin' });
943 + if (!res.ok) throw new Error(`HTTP ${res.status}`);
944 + const desc = await res.json();
945 + desc.icon = node.icon;
946 + if (this.items[this.index] !== node) return; // user already navigated
947 + this.stage.innerHTML = '';
948 + await renderPreview(this.stage, desc, {
949 + stream: this.opts.streamUrl(node),
950 + dl: this.opts.dlUrl ? this.opts.dlUrl(node) : null,
951 + });
952 + } catch (err) {
953 + this.stage.innerHTML = '';
954 + renderFallback(this.stage, { name: node.name, size: node.size, mime: node.mime, icon: node.icon }, { dl: this.opts.dlUrl?.(node) ?? null });
955 + }
956 + }
957 +
958 + nav(delta) {
959 + if (this.items.length < 2) return;
960 + this.index = (this.index + delta + this.items.length) % this.items.length;
961 + this.show();
962 + }
963 +
964 + refreshCurrent() { this.show(); }
965 +
966 + close() {
967 + document.removeEventListener('keydown', this.onKey);
968 + this.overlay.remove();
969 + this.opts.onClose?.();
970 + }
971 +}
added src/web/assets/manifest.webmanifest +29 −0
@@ -0,0 +1,29 @@
1 +{
2 + "_comment": "SPB Drive \u2014 Personal Cloud Drive | Author: Simon-Pierre Boucher | Contact: contact@spboucher.ai | File: src/web/assets/manifest.webmanifest | License: MIT (c) Simon-Pierre Boucher",
3 + "name": "SPB Drive",
4 + "short_name": "SPB Drive",
5 + "description": "Personal cloud drive of Simon-Pierre Boucher",
6 + "start_url": "/app",
7 + "display": "standalone",
8 + "background_color": "#0b0e14",
9 + "theme_color": "#0b0e14",
10 + "icons": [
11 + {
12 + "src": "/assets/favicon.svg",
13 + "sizes": "any",
14 + "type": "image/svg+xml",
15 + "purpose": "any"
16 + },
17 + {
18 + "src": "/assets/icon-192.png",
19 + "sizes": "192x192",
20 + "type": "image/png"
21 + },
22 + {
23 + "src": "/assets/icon-512.png",
24 + "sizes": "512x512",
25 + "type": "image/png",
26 + "purpose": "any maskable"
27 + }
28 + ]
29 +}
\ No newline at end of file
added src/web/assets/tokens.css +117 −0
@@ -0,0 +1,117 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Drive — Personal Cloud Drive
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/web/assets/tokens.css
8 + * Purpose : Design tokens — SPB design DNA, self-hosted fonts, themes
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +@font-face {
14 + font-family: 'Inter';
15 + font-style: normal;
16 + font-weight: 400;
17 + font-display: swap;
18 + src: url('/vendor/fonts/inter/inter-latin-400-normal.woff2') format('woff2');
19 +}
20 +@font-face {
21 + font-family: 'Inter';
22 + font-style: normal;
23 + font-weight: 500;
24 + font-display: swap;
25 + src: url('/vendor/fonts/inter/inter-latin-500-normal.woff2') format('woff2');
26 +}
27 +@font-face {
28 + font-family: 'Inter';
29 + font-style: normal;
30 + font-weight: 600;
31 + font-display: swap;
32 + src: url('/vendor/fonts/inter/inter-latin-600-normal.woff2') format('woff2');
33 +}
34 +@font-face {
35 + font-family: 'Inter';
36 + font-style: normal;
37 + font-weight: 700;
38 + font-display: swap;
39 + src: url('/vendor/fonts/inter/inter-latin-700-normal.woff2') format('woff2');
40 +}
41 +@font-face {
42 + font-family: 'JetBrains Mono';
43 + font-style: normal;
44 + font-weight: 400;
45 + font-display: swap;
46 + src: url('/vendor/fonts/jetbrains-mono/jetbrains-mono-latin-400-normal.woff2') format('woff2');
47 +}
48 +@font-face {
49 + font-family: 'JetBrains Mono';
50 + font-style: normal;
51 + font-weight: 600;
52 + font-display: swap;
53 + src: url('/vendor/fonts/jetbrains-mono/jetbrains-mono-latin-600-normal.woff2') format('woff2');
54 +}
55 +
56 +:root {
57 + --bg: #0b0e14;
58 + --surface: #11151c;
59 + --surface-2: #161b24;
60 + --border: #1f2530;
61 + --text: #e6e9ef;
62 + --muted: #8b93a3;
63 + --accent: #4f8cff;
64 + --accent-2: #22d3aa;
65 + --danger: #ff5d5d;
66 + --warning: #ffb454;
67 + --accent-soft: rgba(79, 140, 255, 0.14);
68 + --danger-soft: rgba(255, 93, 93, 0.12);
69 + --scrim: rgba(4, 6, 10, 0.78);
70 + --radius: 10px;
71 + --radius-sm: 7px;
72 + --font-sans: 'Inter', -apple-system, 'Segoe UI', sans-serif;
73 + --font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
74 + --shadow-pop: 0 10px 32px rgba(0, 0, 0, 0.45);
75 + --ease: cubic-bezier(0.25, 0.7, 0.35, 1);
76 + --shiki-dark-bg: transparent;
77 + color-scheme: dark;
78 +}
79 +
80 +[data-theme='light'] {
81 + --bg: #f5f7fa;
82 + --surface: #ffffff;
83 + --surface-2: #eef1f6;
84 + --border: #dde3ec;
85 + --text: #17202e;
86 + --muted: #5d6778;
87 + --accent: #2f6de0;
88 + --accent-2: #0da98a;
89 + --danger: #d92f2f;
90 + --accent-soft: rgba(47, 109, 224, 0.1);
91 + --danger-soft: rgba(217, 47, 47, 0.08);
92 + --scrim: rgba(15, 20, 30, 0.55);
93 + --shadow-pop: 0 10px 32px rgba(30, 40, 60, 0.18);
94 + color-scheme: light;
95 +}
96 +
97 +[data-theme='light'] .shiki,
98 +[data-theme='light'] .shiki span {
99 + color: var(--shiki-light) !important;
100 +}
101 +.shiki,
102 +.shiki span {
103 + color: var(--shiki-dark) !important;
104 + background: transparent !important;
105 +}
106 +
107 +/* Folder palette (8 choices) */
108 +:root {
109 + --folder-blue: #4f8cff;
110 + --folder-teal: #22d3aa;
111 + --folder-green: #7bd88f;
112 + --folder-yellow: #ffd166;
113 + --folder-orange: #ff9f5a;
114 + --folder-red: #ff5d5d;
115 + --folder-purple: #b48cff;
116 + --folder-pink: #ff7ab8;
117 +}
added src/web/views/app.njk +57 −0
@@ -0,0 +1,57 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Drive — Personal Cloud Drive
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/app.njk
8 + Purpose : App shell — topbar, sidebar, main pane, info panel, overlays
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}
12 +{% extends "base.njk" %}
13 +{% block title %}SPB Drive{% endblock %}
14 +{% block body %}
15 +<div class="shell" id="shell">
16 + <header class="topbar">
17 + <button class="btn icon ghost sidebar-toggle" id="sidebarToggle" aria-label="Menu"></button>
18 + <div class="brand"><div class="mark">SPB</div><span>SPB Drive</span></div>
19 + <div class="searchbox">
20 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
21 + <input id="searchInput" type="search" placeholder="Search files, tags, content… ( / )" autocomplete="off">
22 + </div>
23 + <div class="spacer"></div>
24 + <button class="btn primary" id="uploadBtn">Upload</button>
25 + <button class="btn icon ghost" id="viewToggle" title="Toggle view (v)"></button>
26 + <button class="btn icon ghost" id="themeToggle" title="Toggle theme"></button>
27 + <button class="btn icon ghost" id="settingsBtn" title="Settings"></button>
28 + </header>
29 + <div class="body" id="body">
30 + <nav class="sidebar" id="sidebar">
31 + <button class="btn primary new-btn" id="newBtn">+ New</button>
32 + <div id="navSections"></div>
33 + <div class="nav-section">Folders</div>
34 + <div class="tree" id="folderTree"></div>
35 + <div class="nav-section">Tags</div>
36 + <div id="tagList"></div>
37 + <div class="storage-meter" id="storageMeter"></div>
38 + </nav>
39 + <main class="main" id="main">
40 + <div class="toolbar" id="toolbar"></div>
41 + <div class="content" id="content" tabindex="0"></div>
42 + </main>
43 + <aside class="info-panel" id="infoPanel" aria-label="Details"></aside>
44 + </div>
45 +</div>
46 +<div class="drop-overlay" id="dropOverlay">
47 + <div class="inner">
48 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 16V4m0 0 5 5m-5-5-5 5"/><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>
49 + Drop to upload here
50 + </div>
51 +</div>
52 +<div class="upload-panel" id="uploadPanel"></div>
53 +<div class="toasts" id="toasts"></div>
54 +<input type="file" id="filePick" multiple hidden>
55 +<input type="file" id="folderPick" webkitdirectory hidden>
56 +<script type="module" src="/assets/js/app.js"></script>
57 +{% endblock %}
added src/web/views/base.njk +35 −0
@@ -0,0 +1,35 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Drive — Personal Cloud Drive
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/base.njk
8 + Purpose : Base HTML layout — head, fonts, tokens, favicon, manifest
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}
12 +<!doctype html>
13 +<html lang="en" data-theme="dark">
14 +<head>
15 + <meta charset="utf-8">
16 + <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
17 + <title>{% block title %}SPB Drive{% endblock %}</title>
18 + <meta name="theme-color" content="#0b0e14">
19 + <link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
20 + <link rel="apple-touch-icon" href="/assets/icon-192.png">
21 + <link rel="manifest" href="/assets/manifest.webmanifest">
22 + <link rel="stylesheet" href="/assets/tokens.css">
23 + <link rel="stylesheet" href="/assets/app.css">
24 + {% block og %}{% endblock %}
25 + <script>
26 + try {
27 + const t = localStorage.getItem('spbdrive-theme');
28 + if (t) document.documentElement.dataset.theme = t;
29 + } catch (e) {}
30 + </script>
31 +</head>
32 +<body>
33 +{% block body %}{% endblock %}
34 +</body>
35 +</html>
added src/web/views/error.njk +23 −0
@@ -0,0 +1,23 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Drive — Personal Cloud Drive
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/error.njk
8 + Purpose : Custom 404/500 error pages in the design system
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}
12 +{% extends "base.njk" %}
13 +{% block title %}{{ code }} · SPB Drive{% endblock %}
14 +{% block body %}
15 +<div class="center-page">
16 + <div>
17 + <div class="big">{{ code }}</div>
18 + <h1>{% if code == 404 %}Page not found{% else %}Something went wrong{% endif %}</h1>
19 + <p>{{ message }}</p>
20 + <a class="btn primary" href="/app">Back to drive</a>
21 + </div>
22 +</div>
23 +{% endblock %}
added src/web/views/login.njk +33 −0
@@ -0,0 +1,33 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Drive — Personal Cloud Drive
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/login.njk
8 + Purpose : Login page — centered card, monogram, password + remember me
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}
12 +{% extends "base.njk" %}
13 +{% block title %}Sign in · SPB Drive{% endblock %}
14 +{% block body %}
15 +<div class="login-wrap">
16 + <form class="login-card" method="post" action="/login" autocomplete="off">
17 + <div class="login-logo">SPB</div>
18 + <h1>SPB Drive</h1>
19 + <p class="sub">Private cloud of Simon-Pierre Boucher</p>
20 + {% if error %}<div class="login-error" role="alert">{{ error }}</div>{% endif %}
21 + <input type="hidden" name="_csrf" value="{{ csrf }}">
22 + <input type="hidden" name="next" value="{{ next }}">
23 + <label class="field">
24 + <span>Password</span>
25 + <input type="password" name="password" autofocus required autocomplete="current-password" placeholder="••••••••••">
26 + </label>
27 + <label class="login-remember">
28 + <input type="checkbox" name="remember"> Remember me for 30 days
29 + </label>
30 + <button class="btn primary" type="submit">Unlock drive</button>
31 + </form>
32 +</div>
33 +{% endblock %}
added src/web/views/share-expired.njk +25 −0
@@ -0,0 +1,25 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Drive — Personal Cloud Drive
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/share-expired.njk
8 + Purpose : Clean expired/revoked share page — leaks no file details
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}
12 +{% extends "base.njk" %}
13 +{% block title %}Link unavailable · SPB Drive{% endblock %}
14 +{% block body %}
15 +<div class="center-page">
16 + <div>
17 + <div class="big">⏳</div>
18 + <h1>This link has expired</h1>
19 + <p>The share you're looking for is no longer available.<br>Ask the sender for a fresh link.</p>
20 + <div class="share-footer" style="border: none;">
21 + SPB Drive · <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a>
22 + </div>
23 + </div>
24 +</div>
25 +{% endblock %}
added src/web/views/share-file.njk +57 −0
@@ -0,0 +1,57 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Drive — Personal Cloud Drive
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/share-file.njk
8 + Purpose : Public single-file share page — full preview + download
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}
12 +{% extends "base.njk" %}
13 +{% block title %}{{ node.name }} · SPB Drive{% endblock %}
14 +{% block og %}
15 + <meta property="og:site_name" content="SPB Drive">
16 + <meta property="og:title" content="{{ og.title }}">
17 + <meta property="og:type" content="website">
18 + <meta property="og:url" content="{{ og.url }}">
19 + <meta property="og:description" content="{{ node.size | filesize }} · shared by Simon-Pierre Boucher">
20 + {% if og.image %}<meta property="og:image" content="{{ og.image }}">
21 + <meta name="twitter:card" content="summary_large_image">
22 + <meta name="twitter:image" content="{{ og.image }}">{% else %}
23 + <meta name="twitter:card" content="summary">{% endif %}
24 + <meta name="twitter:title" content="{{ og.title }}">
25 +{% endblock %}
26 +{% block body %}
27 +<div class="share-page"
28 + data-token="{{ share.token }}"
29 + data-node-id="{{ node.id }}"
30 + data-name="{{ node.name }}"
31 + data-size="{{ node.size }}"
32 + data-allow-download="{{ '1' if allowDownload else '0' }}">
33 + <header class="share-topbar">
34 + <div class="brand"><div class="mark">SPB</div><span>SPB Drive</span></div>
35 + </header>
36 + <main class="share-main">
37 + <div class="share-file-head">
38 + <span class="fico" id="shareIcon"></span>
39 + <div>
40 + <h1>{{ node.name }}</h1>
41 + <div class="fmeta">{{ node.size | filesize }}</div>
42 + </div>
43 + {% if allowDownload %}
44 + <a class="btn primary" href="/s/{{ share.token }}/dl" download>
45 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M12 4v12m0 0 5-5m-5 5-5-5"/><path d="M4 19h16"/></svg>
46 + Download
47 + </a>
48 + {% endif %}
49 + </div>
50 + <div class="share-stage" id="shareStage"></div>
51 + </main>
52 + <footer class="share-footer">
53 + Shared by Simon-Pierre Boucher · <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a>
54 + </footer>
55 +</div>
56 +<script type="module" src="/assets/js/share-page.js"></script>
57 +{% endblock %}
added src/web/views/share-folder.njk +54 −0
@@ -0,0 +1,54 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Drive — Personal Cloud Drive
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/share-folder.njk
8 + Purpose : Public folder share — read-only browser + download-all ZIP
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}
12 +{% extends "base.njk" %}
13 +{% block title %}{{ node.name }} · SPB Drive{% endblock %}
14 +{% block og %}
15 + <meta property="og:site_name" content="SPB Drive">
16 + <meta property="og:title" content="{{ og.title }} (folder)">
17 + <meta property="og:type" content="website">
18 + <meta property="og:url" content="{{ og.url }}">
19 + <meta property="og:description" content="Shared folder · Simon-Pierre Boucher">
20 + <meta name="twitter:card" content="summary">
21 +{% endblock %}
22 +{% block body %}
23 +<div class="share-page"
24 + data-token="{{ share.token }}"
25 + data-node-id="{{ node.id }}"
26 + data-name="{{ node.name }}"
27 + data-folder="1"
28 + data-allow-download="{{ '1' if allowDownload else '0' }}">
29 + <header class="share-topbar">
30 + <div class="brand"><div class="mark">SPB</div><span>SPB Drive</span></div>
31 + </header>
32 + <main class="share-main wide">
33 + <div class="share-file-head">
34 + <span class="fico" id="shareIcon"></span>
35 + <div>
36 + <h1>{{ node.name }}</h1>
37 + <div class="fmeta">Shared folder</div>
38 + </div>
39 + {% if allowDownload %}
40 + <a class="btn primary" href="/s/{{ share.token }}/zip">
41 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M12 4v12m0 0 5-5m-5 5-5-5"/><path d="M4 19h16"/></svg>
42 + Download all as ZIP
43 + </a>
44 + {% endif %}
45 + </div>
46 + <div class="crumbs share-crumbs" id="shareCrumbs"></div>
47 + <div id="shareBrowser"></div>
48 + </main>
49 + <footer class="share-footer">
50 + Shared by Simon-Pierre Boucher · <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a>
51 + </footer>
52 +</div>
53 +<script type="module" src="/assets/js/share-page.js"></script>
54 +{% endblock %}
added src/web/views/share-gate.njk +29 −0
@@ -0,0 +1,29 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Drive — Personal Cloud Drive
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/share-gate.njk
8 + Purpose : Minimal password gate for protected share links
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}
12 +{% extends "base.njk" %}
13 +{% block title %}Protected link · SPB Drive{% endblock %}
14 +{% block body %}
15 +<div class="login-wrap">
16 + <form class="login-card" method="post" action="/s/{{ token }}/unlock">
17 + <div class="login-logo">SPB</div>
18 + <h1>Protected link</h1>
19 + <p class="sub">This shared item requires a password</p>
20 + {% if error %}<div class="login-error" role="alert">{{ error }}</div>{% endif %}
21 + <input type="hidden" name="_csrf" value="{{ csrf }}">
22 + <label class="field">
23 + <span>Password</span>
24 + <input type="password" name="password" autofocus required placeholder="••••••••">
25 + </label>
26 + <button class="btn primary" type="submit">Open</button>
27 + </form>
28 +</div>
29 +{% endblock %}
30