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>
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 | + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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) => ({ '&': '&', '<': '<', '>': '>' }[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 | ||