Keep using Photos.app like you always do. Attic quietly backs up your originals and edits to an S3 bucket you control. One-way, append-only.
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(viewer): Progressive loading, cascading filters, dynamic UTType lookups

Start server immediately and load metadata in background so the browser
opens right away. Filter dropdowns update as assets arrive with a progress
bar showing loading state. Cascading filters: selecting a year shows only
albums from that year and vice versa. Unavailable type options are greyed
out. Clear filters button resets all.

Replace hardcoded UTI/extension/MIME maps with UTType system lookups in
S3Paths, ExportProviding, and ViewerDataStore. Normalize jpeg->jpg for
backward compatibility with existing S3 keys.

Includes all code review fixes: single-pass filterOptions, CSP header,
thread-safe date formatting, consolidated JS filter functions, extracted
URL decoding helper, removed Google Fonts CDN dependency.

+700 -248
+532 -127
Sources/AtticCLI/Resources/viewer.html
··· 3 3 <head> 4 4 <meta charset="UTF-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 - <title>Attic Viewer</title> 6 + <title>Attic</title> 7 7 <style> 8 8 :root { 9 - --bg: #fff; 10 - --bg-secondary: #f5f5f7; 11 - --text: #1d1d1f; 12 - --text-secondary: #86868b; 13 - --border: #d2d2d7; 14 - --accent: #0071e3; 15 - --grid-gap: 2px; 16 - --thumb-size: 200px; 17 - } 18 - @media (prefers-color-scheme: dark) { 19 - :root { 20 - --bg: #1d1d1f; 21 - --bg-secondary: #2d2d2f; 22 - --text: #f5f5f7; 23 - --text-secondary: #86868b; 24 - --border: #424245; 25 - --accent: #2997ff; 26 - } 9 + --bg: #111113; 10 + --bg-raised: #1a1a1e; 11 + --bg-hover: #232328; 12 + --surface: #27272c; 13 + --text: #ececef; 14 + --text-dim: #8b8b93; 15 + --text-faint: #55555e; 16 + --border: #2e2e35; 17 + --accent: #6e8efb; 18 + --accent-dim: rgba(110,142,251,0.12); 19 + --radius: 8px; 20 + --radius-sm: 5px; 21 + --font: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif; 22 + --grid-gap: 3px; 23 + --thumb-size: 180px; 24 + --header-h: 64px; 27 25 } 26 + 28 27 * { margin: 0; padding: 0; box-sizing: border-box; } 28 + 29 + html { background: var(--bg); } 30 + 29 31 body { 30 - font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif; 32 + font-family: var(--font); 31 33 background: var(--bg); 32 34 color: var(--text); 33 35 min-height: 100vh; 36 + -webkit-font-smoothing: antialiased; 34 37 } 38 + 39 + /* --- Header --- */ 40 + 35 41 header { 36 42 position: sticky; top: 0; z-index: 100; 37 - background: var(--bg); 43 + height: var(--header-h); 44 + display: flex; align-items: center; justify-content: space-between; 45 + padding: 0 24px; 46 + background: rgba(17,17,19,0.82); 47 + backdrop-filter: saturate(180%) blur(20px); 48 + -webkit-backdrop-filter: saturate(180%) blur(20px); 38 49 border-bottom: 1px solid var(--border); 39 - padding: 12px 20px; 50 + } 51 + 52 + .brand { 53 + display: flex; align-items: center; gap: 10px; 54 + } 55 + 56 + .brand-icon { 57 + width: 28px; height: 28px; 58 + background: linear-gradient(135deg, var(--accent), #b47afc); 59 + border-radius: 7px; 60 + display: flex; align-items: center; justify-content: center; 61 + font-size: 14px; 62 + color: #fff; 63 + font-weight: 500; 64 + letter-spacing: -0.5px; 40 65 } 41 - .header-top { 42 - display: flex; align-items: center; justify-content: space-between; 43 - margin-bottom: 10px; 66 + 67 + .brand h1 { 68 + font-size: 15px; 69 + font-weight: 500; 70 + letter-spacing: -0.2px; 71 + color: var(--text); 72 + } 73 + 74 + .stats { 75 + font-size: 12px; 76 + font-weight: 400; 77 + color: var(--text-faint); 78 + letter-spacing: 0.2px; 44 79 } 45 - .header-top h1 { font-size: 20px; font-weight: 600; } 46 - .stats { font-size: 13px; color: var(--text-secondary); } 80 + 47 81 .filters { 48 - display: flex; gap: 8px; flex-wrap: wrap; align-items: center; 82 + display: flex; gap: 6px; align-items: center; 49 83 } 50 - .filters select, .filters button { 51 - font-family: inherit; font-size: 13px; 52 - padding: 5px 10px; border-radius: 6px; 84 + 85 + .filters select { 86 + font-family: var(--font); 87 + font-size: 12px; 88 + font-weight: 400; 89 + padding: 6px 28px 6px 10px; 90 + border-radius: var(--radius-sm); 53 91 border: 1px solid var(--border); 54 - background: var(--bg-secondary); 92 + background: var(--bg-raised); 93 + color: var(--text-dim); 94 + cursor: pointer; 95 + outline: none; 96 + transition: border-color 0.15s, color 0.15s; 97 + appearance: none; 98 + -webkit-appearance: none; 99 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%2355555e' stroke-width='1.4' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); 100 + background-repeat: no-repeat; 101 + background-position: right 8px center; 102 + } 103 + 104 + .filters select:hover { border-color: #444; color: var(--text); } 105 + .filters select:focus { border-color: var(--accent); } 106 + 107 + .filters select option { 108 + background: var(--bg-raised); 55 109 color: var(--text); 110 + } 111 + 112 + .filters select option:disabled { 113 + color: var(--text-faint); 114 + opacity: 0.5; 115 + } 116 + 117 + .filters select.has-value { 118 + border-color: var(--accent); 119 + color: var(--accent); 120 + } 121 + 122 + .fav-btn { 123 + font-family: var(--font); 124 + font-size: 12px; font-weight: 400; 125 + padding: 6px 12px; 126 + border-radius: var(--radius-sm); 127 + border: 1px solid var(--border); 128 + background: var(--bg-raised); 129 + color: var(--text-dim); 56 130 cursor: pointer; 131 + transition: all 0.15s; 132 + display: flex; align-items: center; gap: 5px; 57 133 } 58 - .filters button.active { 59 - background: var(--accent); color: #fff; border-color: var(--accent); 134 + 135 + .fav-btn:hover { border-color: #444; color: var(--text); } 136 + .fav-btn.active { 137 + background: var(--accent-dim); 138 + border-color: var(--accent); 139 + color: var(--accent); 60 140 } 141 + 142 + .fav-btn svg { width: 13px; height: 13px; } 143 + 144 + .clear-btn { 145 + font-family: var(--font); 146 + font-size: 11px; font-weight: 400; 147 + padding: 5px 10px; 148 + border-radius: var(--radius-sm); 149 + border: 1px solid transparent; 150 + background: transparent; 151 + color: var(--text-faint); 152 + cursor: pointer; 153 + transition: all 0.15s; 154 + display: none; 155 + white-space: nowrap; 156 + } 157 + 158 + .clear-btn.visible { display: flex; } 159 + .clear-btn:hover { color: var(--text); border-color: var(--border); } 160 + 161 + /* --- Progress bar --- */ 162 + 163 + .progress-wrap { 164 + position: fixed; top: var(--header-h); left: 0; right: 0; 165 + height: 3px; z-index: 101; 166 + background: var(--border); 167 + opacity: 0; 168 + transition: opacity 0.3s; 169 + } 170 + 171 + .progress-wrap.active { opacity: 1; } 172 + 173 + .progress-fill { 174 + height: 100%; 175 + background: linear-gradient(90deg, var(--accent), #b47afc); 176 + width: 0%; 177 + transition: width 0.4s ease; 178 + border-radius: 0 2px 2px 0; 179 + } 180 + 181 + .progress-text { 182 + position: fixed; top: calc(var(--header-h) + 8px); right: 24px; 183 + font-size: 11px; color: var(--text-faint); 184 + z-index: 101; 185 + opacity: 0; 186 + transition: opacity 0.3s; 187 + letter-spacing: 0.2px; 188 + } 189 + 190 + .progress-text.active { opacity: 1; } 191 + 192 + /* --- Grid --- */ 193 + 61 194 .grid { 62 195 display: grid; 63 196 grid-template-columns: repeat(auto-fill, minmax(var(--thumb-size), 1fr)); 64 197 gap: var(--grid-gap); 65 198 padding: var(--grid-gap); 66 199 } 200 + 67 201 .thumb { 68 202 aspect-ratio: 1; 69 203 overflow: hidden; 70 204 cursor: pointer; 71 205 position: relative; 72 - background: var(--bg-secondary); 206 + background: var(--bg-raised); 207 + border-radius: 2px; 73 208 } 209 + 74 210 .thumb img { 75 211 width: 100%; height: 100%; 76 212 object-fit: cover; 77 213 display: block; 214 + opacity: 0; 215 + transition: opacity 0.3s ease, transform 0.3s ease; 78 216 } 79 - .thumb .badge { 80 - position: absolute; bottom: 4px; left: 4px; 81 - background: rgba(0,0,0,0.6); color: #fff; 82 - font-size: 10px; padding: 2px 5px; border-radius: 3px; 217 + 218 + .thumb img.loaded { opacity: 1; } 219 + 220 + .thumb:hover img.loaded { 221 + transform: scale(1.04); 222 + } 223 + 224 + .thumb::after { 225 + content: ''; 226 + position: absolute; inset: 0; 227 + border-radius: 2px; 228 + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.04); 229 + pointer-events: none; 230 + transition: box-shadow 0.2s; 83 231 } 84 - .thumb .fav { 85 - position: absolute; top: 4px; right: 4px; 86 - font-size: 12px; 232 + 233 + .thumb:hover::after { 234 + box-shadow: inset 0 0 0 2px var(--accent); 87 235 } 236 + 237 + /* Video play indicator */ 88 238 .thumb .play-indicator { 89 - position: absolute; top: 50%; left: 50%; 90 - transform: translate(-50%, -50%); 91 - width: 40px; height: 40px; 92 - background: rgba(0,0,0,0.5); 93 - border-radius: 50%; 94 - display: flex; align-items: center; justify-content: center; 239 + position: absolute; bottom: 8px; left: 8px; 240 + display: flex; align-items: center; gap: 4px; 241 + background: rgba(0,0,0,0.65); 242 + backdrop-filter: blur(4px); 243 + padding: 3px 8px 3px 6px; 244 + border-radius: 4px; 245 + pointer-events: none; 246 + opacity: 0.9; 247 + } 248 + 249 + .thumb .play-indicator svg { 250 + width: 10px; height: 10px; 251 + fill: #fff; 252 + } 253 + 254 + .thumb .play-indicator span { 255 + font-size: 10px; 256 + font-weight: 500; 257 + color: rgba(255,255,255,0.9); 258 + letter-spacing: 0.3px; 259 + text-transform: uppercase; 260 + } 261 + 262 + /* Favorite badge */ 263 + .thumb .fav-badge { 264 + position: absolute; top: 6px; right: 6px; 265 + color: #ff6b8a; 266 + font-size: 11px; 267 + filter: drop-shadow(0 1px 2px rgba(0,0,0,0.5)); 95 268 pointer-events: none; 96 269 } 97 - .thumb .play-indicator::after { 270 + 271 + /* --- States --- */ 272 + 273 + .empty-state { 274 + text-align: center; 275 + padding: 120px 20px 80px; 276 + color: var(--text-faint); 277 + } 278 + 279 + .empty-state .empty-icon { 280 + font-size: 48px; 281 + margin-bottom: 16px; 282 + opacity: 0.4; 283 + } 284 + 285 + .empty-state p { 286 + font-size: 14px; 287 + font-weight: 400; 288 + } 289 + 290 + .loading-bar { 291 + position: fixed; top: var(--header-h); left: 0; right: 0; 292 + height: 2px; z-index: 102; 293 + background: transparent; 294 + overflow: hidden; 295 + } 296 + 297 + .loading-bar.active { 298 + background: var(--border); 299 + } 300 + 301 + .loading-bar.active::after { 98 302 content: ''; 99 - display: block; 100 - width: 0; height: 0; 101 - border-style: solid; 102 - border-width: 8px 0 8px 14px; 103 - border-color: transparent transparent transparent #fff; 104 - margin-left: 3px; 303 + position: absolute; 304 + left: -40%; 305 + width: 40%; 306 + height: 100%; 307 + background: linear-gradient(90deg, transparent, var(--accent), transparent); 308 + animation: loadSlide 1s ease-in-out infinite; 309 + } 310 + 311 + @keyframes loadSlide { 312 + 0% { left: -40%; } 313 + 100% { left: 100%; } 105 314 } 106 - .empty { 107 - text-align: center; padding: 80px 20px; 108 - color: var(--text-secondary); font-size: 15px; 315 + 316 + #scrollSentinel { height: 1px; } 317 + 318 + /* --- Lightbox --- */ 319 + 320 + .lightbox { 321 + display: none; 322 + position: fixed; inset: 0; z-index: 200; 323 + background: rgba(0,0,0,0.96); 324 + flex-direction: column; 325 + align-items: center; 326 + justify-content: center; 109 327 } 110 - .loading { 111 - text-align: center; padding: 30px; 112 - color: var(--text-secondary); font-size: 14px; 328 + 329 + .lightbox.open { 330 + display: flex; 331 + animation: lbFadeIn 0.2s ease; 113 332 } 114 333 115 - /* Lightbox */ 116 - .lightbox { 117 - display: none; position: fixed; inset: 0; z-index: 200; 118 - background: rgba(0,0,0,0.92); 119 - justify-content: center; align-items: center; 334 + @keyframes lbFadeIn { 335 + from { opacity: 0; } 336 + to { opacity: 1; } 120 337 } 121 - .lightbox.open { display: flex; } 338 + 122 339 .lightbox-content { 123 340 position: relative; 124 - max-width: 95vw; max-height: 95vh; 341 + max-width: 95vw; max-height: 88vh; 342 + display: flex; 343 + align-items: center; 344 + justify-content: center; 125 345 } 346 + 126 347 .lightbox-content img, .lightbox-content video { 127 - max-width: 95vw; max-height: 90vh; 128 - object-fit: contain; display: block; 129 - border-radius: 4px; 348 + max-width: 95vw; max-height: 88vh; 349 + object-fit: contain; 350 + display: block; 351 + border-radius: 3px; 352 + animation: lbImgIn 0.25s ease; 130 353 } 354 + 355 + @keyframes lbImgIn { 356 + from { opacity: 0; transform: scale(0.97); } 357 + to { opacity: 1; transform: scale(1); } 358 + } 359 + 131 360 .lightbox-info { 132 - position: absolute; bottom: -30px; left: 0; right: 0; 133 - text-align: center; color: #999; font-size: 13px; 361 + margin-top: 16px; 362 + text-align: center; 363 + font-size: 12px; 364 + color: var(--text-faint); 365 + font-weight: 400; 366 + letter-spacing: 0.2px; 134 367 } 368 + 135 369 .lightbox-close { 136 370 position: fixed; top: 16px; right: 20px; 137 - background: none; border: none; color: #fff; 138 - font-size: 28px; cursor: pointer; z-index: 210; 371 + background: none; border: none; 372 + color: var(--text-dim); 373 + font-size: 20px; 374 + cursor: pointer; 375 + z-index: 210; 376 + width: 36px; height: 36px; 377 + display: flex; align-items: center; justify-content: center; 378 + border-radius: 50%; 379 + transition: background 0.15s, color 0.15s; 380 + } 381 + 382 + .lightbox-close:hover { 383 + background: rgba(255,255,255,0.08); 384 + color: var(--text); 139 385 } 386 + 140 387 .lightbox-nav { 141 388 position: fixed; top: 50%; transform: translateY(-50%); 142 - background: none; border: none; color: #fff; 143 - font-size: 36px; cursor: pointer; z-index: 210; 144 - padding: 20px; 389 + background: none; border: none; 390 + color: var(--text-dim); 391 + font-size: 24px; 392 + cursor: pointer; 393 + z-index: 210; 394 + width: 44px; height: 44px; 395 + display: flex; align-items: center; justify-content: center; 396 + border-radius: 50%; 397 + transition: background 0.15s, color 0.15s; 398 + } 399 + 400 + .lightbox-nav:hover { 401 + background: rgba(255,255,255,0.08); 402 + color: var(--text); 403 + } 404 + 405 + .lightbox-nav.prev { left: 12px; } 406 + .lightbox-nav.next { right: 12px; } 407 + 408 + .lightbox-counter { 409 + position: fixed; bottom: 20px; left: 50%; 410 + transform: translateX(-50%); 411 + font-size: 12px; 412 + color: var(--text-faint); 413 + font-weight: 400; 414 + letter-spacing: 0.3px; 415 + z-index: 210; 145 416 } 146 - .lightbox-nav.prev { left: 8px; } 147 - .lightbox-nav.next { right: 8px; } 148 - .lightbox-nav:hover, .lightbox-close:hover { opacity: 0.7; } 149 417 </style> 150 418 </head> 151 419 <body> 152 420 153 421 <header> 154 - <div class="header-top"> 155 - <h1>Attic Viewer</h1> 422 + <div class="brand"> 423 + <div class="brand-icon">A</div> 424 + <h1>Attic</h1> 156 425 <span class="stats" id="stats"></span> 157 426 </div> 158 427 <div class="filters"> ··· 163 432 <option value="photo">Photos</option> 164 433 <option value="video">Videos</option> 165 434 </select> 166 - <button id="favFilter">Favorites</button> 435 + <button class="fav-btn" id="favFilter"> 436 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg> 437 + Favorites 438 + </button> 439 + <button class="clear-btn" id="clearFilters">Clear filters</button> 167 440 </div> 168 441 </header> 169 442 443 + <div class="progress-wrap" id="progressWrap"><div class="progress-fill" id="progressFill"></div></div> 444 + <div class="progress-text" id="progressText"></div> 445 + <div class="loading-bar" id="loadingBar"></div> 170 446 <div class="grid" id="grid"></div> 171 - <div class="loading" id="loadingIndicator" style="display:none">Loading more...</div> 172 - <div id="scrollSentinel" style="height:1px"></div> 173 - <div class="empty" id="emptyState" style="display:none">No assets match the current filters.</div> 447 + <div id="scrollSentinel"></div> 448 + <div class="empty-state" id="emptyState" style="display:none"> 449 + <div class="empty-icon">&#9744;</div> 450 + <p>No assets match the current filters.</p> 451 + </div> 174 452 175 453 <div class="lightbox" id="lightbox"> 176 - <button class="lightbox-close" id="lbClose">&times;</button> 177 - <button class="lightbox-nav prev" id="lbPrev">&#8249;</button> 178 - <button class="lightbox-nav next" id="lbNext">&#8250;</button> 454 + <button class="lightbox-close" id="lbClose"> 455 + <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg> 456 + </button> 457 + <button class="lightbox-nav prev" id="lbPrev"> 458 + <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg> 459 + </button> 460 + <button class="lightbox-nav next" id="lbNext"> 461 + <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg> 462 + </button> 179 463 <div class="lightbox-content" id="lbContent"></div> 464 + <div class="lightbox-info" id="lbInfo"></div> 465 + <div class="lightbox-counter" id="lbCounter"></div> 180 466 </div> 181 467 182 468 <script> 183 469 const PLACEHOLDER = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; 184 470 const state = { 185 471 page: 1, 186 - pageSize: 50, 472 + pageSize: 60, 187 473 totalCount: 0, 188 474 loading: false, 189 475 done: false, 190 476 assets: [], 191 477 filters: { year: '', album: '', type: '', favorites: false }, 192 478 lightboxIndex: -1, 479 + metadataLoading: true, 480 + pollTimer: null, 193 481 }; 194 482 195 483 const grid = document.getElementById('grid'); 196 - const loadingIndicator = document.getElementById('loadingIndicator'); 484 + const loadingBar = document.getElementById('loadingBar'); 197 485 const emptyState = document.getElementById('emptyState'); 198 486 const statsEl = document.getElementById('stats'); 487 + const progressWrap = document.getElementById('progressWrap'); 488 + const progressFill = document.getElementById('progressFill'); 489 + const progressText = document.getElementById('progressText'); 490 + const clearBtn = document.getElementById('clearFilters'); 199 491 200 - // --- Filters --- 492 + // --- Cascading filters --- 201 493 202 - async function loadFilters() { 203 - const res = await fetch('/api/filters'); 494 + function buildFilterParams() { 495 + const params = new URLSearchParams(); 496 + if (state.filters.year) params.set('year', state.filters.year); 497 + if (state.filters.album) params.set('album', state.filters.album); 498 + if (state.filters.type) params.set('type', state.filters.type); 499 + if (state.filters.favorites) params.set('favorites', 'true'); 500 + return params; 501 + } 502 + 503 + async function fetchFilters({ reload = false } = {}) { 504 + const params = buildFilterParams(); 505 + const res = await fetch(`/api/filters?${params}`); 204 506 const data = await res.json(); 205 507 206 - const yearSelect = document.getElementById('yearFilter'); 207 - data.years.forEach(y => { 508 + updateYearDropdown(data.years); 509 + updateAlbumDropdown(data.albums); 510 + updateTypeDropdown(data.totalPhotos, data.totalVideos); 511 + updateStats(data); 512 + updateProgress(data); 513 + 514 + if (data.isLoading && !state.pollTimer) { 515 + state.metadataLoading = true; 516 + state.pollTimer = setInterval(() => fetchFilters(), 2000); 517 + } 518 + 519 + if (!data.isLoading && state.pollTimer) { 520 + state.metadataLoading = false; 521 + clearInterval(state.pollTimer); 522 + state.pollTimer = null; 523 + reload = true; 524 + } 525 + 526 + if (reload) resetAndLoad(); 527 + } 528 + 529 + function updateYearDropdown(years) { 530 + const select = document.getElementById('yearFilter'); 531 + const current = select.value; 532 + select.innerHTML = '<option value="">All years</option>'; 533 + years.forEach(y => { 208 534 const opt = document.createElement('option'); 209 535 opt.value = y.year; 210 536 opt.textContent = `${y.year} (${y.count})`; 211 - yearSelect.appendChild(opt); 537 + select.appendChild(opt); 212 538 }); 539 + // Restore selection if still available 540 + if (current && years.some(y => String(y.year) === current)) { 541 + select.value = current; 542 + } else if (current) { 543 + // Year no longer available with current filters — keep in dropdown as disabled 544 + state.filters.year = ''; 545 + select.value = ''; 546 + } 547 + select.classList.toggle('has-value', !!select.value); 548 + } 213 549 214 - const albumSelect = document.getElementById('albumFilter'); 215 - data.albums.forEach(a => { 550 + function updateAlbumDropdown(albums) { 551 + const select = document.getElementById('albumFilter'); 552 + const current = select.value; 553 + select.innerHTML = '<option value="">All albums</option>'; 554 + albums.forEach(a => { 216 555 const opt = document.createElement('option'); 217 556 opt.value = a.album; 218 557 opt.textContent = `${a.album} (${a.count})`; 219 - albumSelect.appendChild(opt); 558 + select.appendChild(opt); 220 559 }); 560 + if (current && albums.some(a => a.album === current)) { 561 + select.value = current; 562 + } else if (current) { 563 + state.filters.album = ''; 564 + select.value = ''; 565 + } 566 + select.classList.toggle('has-value', !!select.value); 567 + } 221 568 222 - statsEl.textContent = `${data.totalAssets.toLocaleString()} assets \u2022 ${data.totalPhotos.toLocaleString()} photos \u2022 ${data.totalVideos.toLocaleString()} videos`; 569 + function updateTypeDropdown(photoCount, videoCount) { 570 + const select = document.getElementById('typeFilter'); 571 + const opts = select.options; 572 + // "Photos" option 573 + opts[1].textContent = `Photos (${photoCount.toLocaleString()})`; 574 + opts[1].disabled = photoCount === 0; 575 + // "Videos" option 576 + opts[2].textContent = `Videos (${videoCount.toLocaleString()})`; 577 + opts[2].disabled = videoCount === 0; 578 + select.classList.toggle('has-value', !!select.value); 223 579 } 224 580 581 + function updateStats(data) { 582 + const total = data.totalAssets.toLocaleString(); 583 + const photos = data.totalPhotos.toLocaleString(); 584 + const videos = data.totalVideos.toLocaleString(); 585 + if (data.isLoading) { 586 + const loaded = data.loaded.toLocaleString(); 587 + const lib = data.totalInLibrary.toLocaleString(); 588 + statsEl.textContent = `${total} matching \u00b7 loading ${loaded} / ${lib}`; 589 + } else { 590 + statsEl.textContent = `${total} assets \u00b7 ${photos} photos \u00b7 ${videos} videos`; 591 + } 592 + } 593 + 594 + function updateProgress(data) { 595 + if (data.isLoading) { 596 + const pct = data.totalInLibrary > 0 597 + ? Math.round((data.loaded / data.totalInLibrary) * 100) 598 + : 0; 599 + progressWrap.classList.add('active'); 600 + progressFill.style.width = pct + '%'; 601 + progressText.classList.add('active'); 602 + progressText.textContent = `Loading metadata\u2026 ${data.loaded.toLocaleString()} / ${data.totalInLibrary.toLocaleString()}`; 603 + } else { 604 + progressWrap.classList.remove('active'); 605 + progressText.classList.remove('active'); 606 + } 607 + } 608 + 609 + function updateClearButton() { 610 + const hasFilters = state.filters.year || state.filters.album 611 + || state.filters.type || state.filters.favorites; 612 + clearBtn.classList.toggle('visible', !!hasFilters); 613 + } 614 + 615 + // --- Filter event handlers --- 616 + 225 617 document.getElementById('yearFilter').addEventListener('change', e => { 226 618 state.filters.year = e.target.value; 227 - resetAndLoad(); 619 + updateClearButton(); 620 + fetchFilters({ reload: true }); 228 621 }); 229 622 document.getElementById('albumFilter').addEventListener('change', e => { 230 623 state.filters.album = e.target.value; 231 - resetAndLoad(); 624 + updateClearButton(); 625 + fetchFilters({ reload: true }); 232 626 }); 233 627 document.getElementById('typeFilter').addEventListener('change', e => { 234 628 state.filters.type = e.target.value; 235 - resetAndLoad(); 629 + updateClearButton(); 630 + fetchFilters({ reload: true }); 236 631 }); 237 632 document.getElementById('favFilter').addEventListener('click', e => { 238 633 state.filters.favorites = !state.filters.favorites; 239 - e.target.classList.toggle('active', state.filters.favorites); 240 - resetAndLoad(); 634 + e.currentTarget.classList.toggle('active', state.filters.favorites); 635 + updateClearButton(); 636 + fetchFilters({ reload: true }); 637 + }); 638 + clearBtn.addEventListener('click', () => { 639 + state.filters = { year: '', album: '', type: '', favorites: false }; 640 + document.getElementById('favFilter').classList.remove('active'); 641 + updateClearButton(); 642 + fetchFilters({ reload: true }); 241 643 }); 242 644 243 645 function resetAndLoad() { ··· 254 656 async function loadPage() { 255 657 if (state.loading || state.done) return; 256 658 state.loading = true; 257 - loadingIndicator.style.display = 'block'; 659 + loadingBar.classList.add('active'); 258 660 259 661 const params = new URLSearchParams({ page: state.page, pageSize: state.pageSize }); 260 662 if (state.filters.year) params.set('year', state.filters.year); ··· 283 685 img.alt = asset.filename; 284 686 img.dataset.thumbUrl = `/api/thumb/${asset.uuid}`; 285 687 img.src = PLACEHOLDER; 688 + img.onload = function() { 689 + if (this.src !== PLACEHOLDER) this.classList.add('loaded'); 690 + }; 286 691 div.appendChild(img); 287 692 288 693 if (asset.isVideo) { 289 694 const play = document.createElement('div'); 290 695 play.className = 'play-indicator'; 696 + play.innerHTML = '<svg viewBox="0 0 10 10"><polygon points="2,1 9,5 2,9"/></svg><span>Video</span>'; 291 697 div.appendChild(play); 292 698 } 293 699 294 700 if (asset.isFavorite) { 295 701 const fav = document.createElement('span'); 296 - fav.className = 'fav'; 702 + fav.className = 'fav-badge'; 297 703 fav.textContent = '\u2665'; 298 704 div.appendChild(fav); 299 705 } ··· 304 710 }); 305 711 306 712 if (state.assets.length >= data.totalCount) { 307 - state.done = true; 713 + // If metadata is still loading, more may arrive — don't mark done 714 + if (!state.metadataLoading) { 715 + state.done = true; 716 + } 308 717 } else { 309 718 state.page++; 310 719 } 311 720 312 721 state.loading = false; 313 - loadingIndicator.style.display = 'none'; 722 + loadingBar.classList.remove('active'); 314 723 } 315 724 316 725 // --- Viewport-based memory management --- 317 - // Two-zone strategy: load images near viewport, UNLOAD images far from it. 318 - // A 4032x3024 photo decodes to ~49MB bitmap. Without unloading, scrolling 319 - // through hundreds of photos accumulates gigabytes and crashes the tab. 320 726 321 727 const visibilityObserver = new IntersectionObserver((entries) => { 322 728 entries.forEach(entry => { ··· 326 732 if (!img) return; 327 733 328 734 if (entry.isIntersecting) { 329 - // Entering the load zone — set the thumbnail src 330 735 if (!img.dataset.active) { 331 736 img.src = img.dataset.thumbUrl || state.assets[idx].imageURL; 332 737 img.dataset.active = '1'; 333 738 } 334 739 } else { 335 - // Left the load zone — release memory 336 740 if (img.dataset.active) { 337 741 img.src = PLACEHOLDER; 742 + img.classList.remove('loaded'); 338 743 delete img.dataset.active; 339 744 } 340 745 } 341 746 }); 342 - }, { rootMargin: '800px' }); // load zone: viewport + 800px each side 747 + }, { rootMargin: '800px' }); 343 748 344 749 // --- Infinite scroll --- 345 750 ··· 354 759 state.lightboxIndex = idx; 355 760 renderLightbox(); 356 761 document.getElementById('lightbox').classList.add('open'); 762 + document.body.style.overflow = 'hidden'; 357 763 } 358 764 359 765 function closeLightbox() { 360 766 const content = document.getElementById('lbContent'); 361 - // Stop any playing video before clearing 362 767 const video = content.querySelector('video'); 363 768 if (video) { video.pause(); video.src = ''; } 364 769 content.innerHTML = ''; 770 + document.getElementById('lbInfo').textContent = ''; 771 + document.getElementById('lbCounter').textContent = ''; 365 772 document.getElementById('lightbox').classList.remove('open'); 773 + document.body.style.overflow = ''; 366 774 state.lightboxIndex = -1; 367 775 } 368 776 ··· 371 779 if (!asset) return; 372 780 373 781 const content = document.getElementById('lbContent'); 374 - 375 - // Stop previous video if navigating 376 782 const prevVideo = content.querySelector('video'); 377 783 if (prevVideo) { prevVideo.pause(); prevVideo.src = ''; } 378 784 379 - // Fetch fresh pre-signed URL for full-size 380 785 const res = await fetch(`/api/assets/${asset.uuid}`); 381 786 const detail = await res.json(); 382 787 ··· 386 791 video.controls = true; 387 792 video.preload = 'metadata'; 388 793 video.src = detail.imageURL; 389 - video.style.cssText = 'max-width:95vw;max-height:90vh'; 794 + video.style.cssText = 'max-width:95vw;max-height:88vh'; 390 795 content.appendChild(video); 391 796 } else { 392 797 const img = document.createElement('img'); ··· 394 799 img.alt = asset.filename; 395 800 content.appendChild(img); 396 801 } 397 - const info = document.createElement('div'); 398 - info.className = 'lightbox-info'; 399 - info.textContent = asset.filename; 400 - content.appendChild(info); 802 + 803 + document.getElementById('lbInfo').textContent = asset.filename; 804 + document.getElementById('lbCounter').textContent = 805 + `${state.lightboxIndex + 1} / ${state.assets.length}`; 401 806 } 402 807 403 808 document.getElementById('lbClose').addEventListener('click', closeLightbox); ··· 433 838 434 839 // --- Init --- 435 840 436 - loadFilters(); 841 + fetchFilters(); 437 842 loadPage(); 438 843 </script> 439 844 </body>
+14 -15
Sources/AtticCLI/ViewerCommand.swift
··· 21 21 } 22 22 23 23 let dataStore = ViewerDataStore() 24 - let total = manifest.entries.count 25 - 26 - let spinner = PreparationSpinner() 27 - spinner.updateStatus("Loading metadata... 0 / \(formatCount(total)) assets") 28 - spinner.start() 29 - 30 - await dataStore.load(manifest: manifest, s3: s3) { loaded, total in 31 - spinner.updateStatus( 32 - "Loading metadata... \(formatCount(loaded)) / \(formatCount(total)) assets" 33 - ) 34 - } 35 - 36 - spinner.stop() 37 - print(" Loaded \(formatCount(total)) assets") 38 - 39 24 let thumbnailService = ThumbnailService(s3: s3, dataStore: dataStore) 40 25 let server = ViewerServer( 41 26 dataStore: dataStore, s3: s3, 42 27 thumbnailProvider: thumbnailService, port: port 43 28 ) 29 + 30 + // Start metadata loading in the background — assets become 31 + // queryable as they arrive, and the browser polls for progress. 32 + let total = manifest.entries.count 33 + print(" Loading metadata for \(formatCount(total)) assets in background...") 34 + 35 + Task { 36 + do { 37 + await dataStore.load(manifest: manifest, s3: s3) { _, _ in } 38 + print(" Metadata loading complete.") 39 + } catch { 40 + print(" Error loading metadata: \(error)") 41 + } 42 + } 44 43 45 44 try await server.start { actualPort in 46 45 let url = "http://127.0.0.1:\(actualPort)"
+30 -5
Sources/AtticCLI/ViewerServer.swift
··· 1 1 import AtticCore 2 2 import Foundation 3 3 import Hummingbird 4 + import HummingbirdCore 4 5 5 6 /// API response for a paginated asset list. 6 7 struct AssetListResponse: ResponseEncodable { ··· 56 57 try await app.runService() 57 58 } 58 59 60 + // swiftlint:disable:next line_length 61 + private static let csp = "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self' https://*.amazonaws.com; media-src 'self' https://*.amazonaws.com; font-src 'self'; connect-src 'self'" 62 + 59 63 func buildRouter() -> Router<BasicRequestContext> { 60 64 let router = Router() 65 + addHTMLRoute(router) 66 + addAPIRoutes(router) 67 + return router 68 + } 61 69 70 + private func addHTMLRoute(_ router: Router<BasicRequestContext>) { 62 71 router.get("/") { _, _ -> Response in 63 72 let html = loadViewerHTML() 64 73 return Response( ··· 67 76 .contentType: "text/html; charset=utf-8", 68 77 .init("X-Content-Type-Options")!: "nosniff", 69 78 .init("X-Frame-Options")!: "DENY", 79 + .init("Content-Security-Policy")!: Self.csp, 70 80 ], 71 81 body: .init(byteBuffer: .init(string: html)) 72 82 ) 73 83 } 84 + } 74 85 75 - router.get("/api/filters") { _, _ -> Response in 76 - let opts = await dataStore.filterOptions() 86 + private func addAPIRoutes(_ router: Router<BasicRequestContext>) { 87 + router.get("/api/filters") { request, _ -> Response in 88 + let params = request.uri.queryParameters 89 + let year = params.get("year", as: Int.self) 90 + let album = decodedParam(request.uri, "album") 91 + let favorites = params.get("favorites", as: Bool.self) 92 + let mediaType = params.get("type", as: String.self) 93 + 94 + let opts = await dataStore.filterOptions( 95 + year: year, album: album, favorites: favorites, mediaType: mediaType 96 + ) 77 97 let data = try JSONEncoder().encode(opts) 78 98 return Response( 79 99 status: .ok, ··· 87 107 let page = max(params.get("page", as: Int.self) ?? 1, 1) 88 108 let pageSize = min(max(params.get("pageSize", as: Int.self) ?? 50, 1), 200) 89 109 let year = params.get("year", as: Int.self) 90 - let album = params.get("album", as: String.self) 110 + let album = decodedParam(request.uri, "album") 91 111 let favorites = params.get("favorites", as: Bool.self) 92 112 let mediaType = params.get("type", as: String.self) 93 113 ··· 145 165 return Response(status: .notFound) 146 166 } 147 167 } 148 - 149 - return router 150 168 } 151 169 152 170 private func assetResponse(_ asset: AssetView, expires: Int) -> AssetResponse { ··· 164 182 .absoluteString 165 183 ) 166 184 } 185 + } 186 + 187 + /// Decode a query parameter, converting `+` to space. 188 + /// URLSearchParams encodes spaces as `+` but Hummingbird only decodes `%XX`. 189 + private func decodedParam(_ uri: URI, _ name: String) -> String? { 190 + uri.queryParameters.get(name, as: String.self)? 191 + .replacingOccurrences(of: "+", with: " ") 167 192 } 168 193 169 194 /// Load the embedded viewer HTML from the resource bundle.
+7 -17
Sources/AtticCore/ExportProviding.swift
··· 1 1 import Foundation 2 2 import LadderKit 3 + import UniformTypeIdentifiers 3 4 4 5 /// Abstraction over the photo export mechanism for testability. 5 6 /// ··· 39 40 } 40 41 } 41 42 42 - /// Extension-to-content-type lookup table. 43 - private let contentTypeMap: [String: String] = [ 44 - "jpg": "image/jpeg", 45 - "jpeg": "image/jpeg", 46 - "heic": "image/heic", 47 - "png": "image/png", 48 - "tiff": "image/tiff", 49 - "gif": "image/gif", 50 - "mp4": "video/mp4", 51 - "mov": "video/quicktime", 52 - "m4v": "video/x-m4v", 53 - "avi": "video/x-msvideo", 54 - "orf": "image/x-olympus-orf", 55 - ] 56 - 57 - /// Map a file extension to its MIME content type. 43 + /// Map a file extension to its MIME content type using the system type database. 58 44 public func contentTypeForExtension(_ ext: String) -> String { 59 - contentTypeMap[ext] ?? "application/octet-stream" 45 + if let utType = UTType(filenameExtension: ext), 46 + let mimeType = utType.preferredMIMEType { 47 + return mimeType 48 + } 49 + return "application/octet-stream" 60 50 }
+12 -14
Sources/AtticCore/S3Paths.swift
··· 1 1 import Foundation 2 + import UniformTypeIdentifiers 2 3 3 4 /// S3 key generation and path safety for photo backup storage. 4 5 public enum S3Paths { ··· 8 9 private nonisolated(unsafe) static let s3KeyPattern = /^[A-Za-z0-9\/._\-]+$/ 9 10 private nonisolated(unsafe) static let extPattern = /^[a-z0-9]+$/ 10 11 11 - /// UTI-to-extension lookup table. 12 - private static let utiMap: [String: String] = [ 13 - "public.jpeg": "jpg", 14 - "public.heic": "heic", 15 - "public.png": "png", 16 - "public.tiff": "tiff", 17 - "com.compuserve.gif": "gif", 18 - "public.mpeg-4": "mp4", 19 - "com.apple.quicktime-movie": "mov", 20 - "com.apple.m4v-video": "m4v", 21 - "public.avi": "avi", 22 - "com.olympus.raw-image": "orf", 12 + /// Normalize extensions where the system canonical form differs from convention. 13 + private static let extensionOverrides: [String: String] = [ 14 + "jpeg": "jpg", 23 15 ] 16 + 17 + /// Resolve a UTI string to its preferred file extension using the system type database. 18 + private static func extensionFromUTI(_ uti: String) -> String? { 19 + guard let ext = UTType(uti)?.preferredFilenameExtension else { return nil } 20 + return extensionOverrides[ext] ?? ext 21 + } 24 22 25 23 // MARK: - Key generation 26 24 ··· 73 71 uti: String?, 74 72 filename: String, 75 73 ) -> String { 76 - if let uti, let mapped = utiMap[uti] { 77 - return mapped 74 + if let uti, let ext = extensionFromUTI(uti) { 75 + return ext 78 76 } 79 77 80 78 if let dotIndex = filename.lastIndex(of: ".") {
+103 -68
Sources/AtticCore/ViewerDataStore.swift
··· 1 1 import Foundation 2 + import UniformTypeIdentifiers 2 3 3 4 /// Lightweight view of an asset for the viewer UI. 4 5 public struct AssetView: Codable, Sendable { ··· 38 39 public var totalAssets: Int 39 40 public var totalPhotos: Int 40 41 public var totalVideos: Int 42 + public var isLoading: Bool 43 + public var loaded: Int 44 + public var totalInLibrary: Int 41 45 } 42 46 43 47 /// Year with asset count. ··· 62 66 public actor ViewerDataStore { 63 67 private var assets: [AssetView] = [] 64 68 private var assetsByUUID: [String: AssetView] = [:] 65 - private var cachedFilterOptions: FilterOptions? 69 + private var _isLoading = false 70 + private var _loadedCount = 0 71 + private var _expectedTotal = 0 66 72 67 73 public init() {} 68 74 75 + // MARK: - Loading 76 + 69 77 /// Load metadata for all manifest entries from S3. 70 - /// Calls `onProgress` with (loaded, total) counts. 78 + /// Assets become queryable as they arrive. Sorts after completion. 71 79 public func load( 72 80 manifest: Manifest, 73 81 s3: S3Providing, 74 82 onProgress: @escaping @Sendable (Int, Int) -> Void = { _, _ in } 75 83 ) async { 76 84 let entries = Array(manifest.entries.values) 77 - let total = entries.count 78 - if total == 0 { return } 85 + _expectedTotal = entries.count 86 + _loadedCount = 0 87 + _isLoading = true 88 + 89 + if entries.isEmpty { 90 + _isLoading = false 91 + return 92 + } 79 93 80 94 let loaded = LoadCounter() 81 - 82 95 let maxConcurrency = 20 83 96 84 97 await withTaskGroup(of: AssetView?.self) { group in 85 98 for (index, entry) in entries.enumerated() { 86 - // Limit concurrency by waiting for a result before adding more 87 99 if index >= maxConcurrency { 88 100 if let view = await group.next() ?? nil { 89 - assets.append(view) 101 + appendAsset(view) 90 102 } 91 103 } 92 104 ··· 96 108 let data = try await s3.getObject(key: key) 97 109 let meta = try JSONDecoder().decode(AssetMetadata.self, from: data) 98 110 let count = await loaded.increment() 99 - onProgress(count, total) 111 + onProgress(count, entries.count) 100 112 return Self.assetView(from: meta) 101 113 } catch { 102 114 let count = await loaded.increment() 103 - onProgress(count, total) 115 + onProgress(count, entries.count) 104 116 return nil 105 117 } 106 118 } 107 119 } 108 120 109 - // Drain remaining tasks 110 121 for await view in group { 111 - if let view { assets.append(view) } 122 + if let view { appendAsset(view) } 112 123 } 113 124 } 114 125 ··· 121 132 } 122 133 } 123 134 124 - rebuildIndexes() 135 + _isLoading = false 125 136 } 126 137 127 138 /// Load from pre-built asset views (for testing). 128 139 public func load(assets: [AssetView]) { 129 140 self.assets = assets 130 - rebuildIndexes() 141 + assetsByUUID = Dictionary(uniqueKeysWithValues: assets.map { ($0.uuid, $0) }) 131 142 } 132 143 133 - private func rebuildIndexes() { 134 - assetsByUUID = Dictionary(uniqueKeysWithValues: assets.map { ($0.uuid, $0) }) 135 - cachedFilterOptions = buildFilterOptions() 144 + private func appendAsset(_ view: AssetView) { 145 + assets.append(view) 146 + assetsByUUID[view.uuid] = view 147 + _loadedCount += 1 136 148 } 149 + 150 + // MARK: - Queries 137 151 138 152 /// Query assets with optional filters, paginated. 139 153 public func query( ··· 144 158 page: Int = 1, 145 159 pageSize: Int = 50 146 160 ) -> AssetPage { 147 - var filtered = assets 148 - 149 - if let year { 150 - filtered = filtered.filter { $0.year == year } 151 - } 152 - if let album { 153 - filtered = filtered.filter { $0.albums.contains(album) } 154 - } 155 - if let favorites, favorites { 156 - filtered = filtered.filter { $0.isFavorite } 157 - } 158 - if let mediaType { 159 - switch mediaType { 160 - case "photo": filtered = filtered.filter { !$0.isVideo } 161 - case "video": filtered = filtered.filter { $0.isVideo } 162 - default: break 163 - } 164 - } 161 + let filtered = applyFilters( 162 + year: year, album: album, favorites: favorites, mediaType: mediaType 163 + ) 165 164 166 165 let totalCount = filtered.count 167 166 let startIndex = (page - 1) * pageSize ··· 170 169 ? Array(filtered[startIndex..<endIndex]) 171 170 : [] 172 171 173 - return AssetPage( 174 - assets: pageAssets, 175 - totalCount: totalCount 176 - ) 172 + return AssetPage(assets: pageAssets, totalCount: totalCount) 177 173 } 178 174 179 - /// Available filter options derived from loaded data. 180 - public func filterOptions() -> FilterOptions { 181 - cachedFilterOptions ?? FilterOptions( 182 - years: [], albums: [], 183 - totalAssets: 0, totalPhotos: 0, totalVideos: 0 184 - ) 185 - } 186 - 187 - /// Find a single asset by UUID (O(1) dictionary lookup). 188 - public func asset(uuid: String) -> AssetView? { 189 - assetsByUUID[uuid] 190 - } 191 - 192 - // MARK: - Private 193 - 194 - private func buildFilterOptions() -> FilterOptions { 175 + /// Cascading filter options: each dimension is filtered by the OTHER active 176 + /// filters, so counts and available options update as filters are applied. 177 + /// Single-pass implementation — one loop over assets, three accumulators. 178 + public func filterOptions( 179 + year: Int? = nil, 180 + album: String? = nil, 181 + favorites: Bool? = nil, 182 + mediaType: String? = nil 183 + ) -> FilterOptions { 195 184 var yearCounts: [Int: Int] = [:] 196 185 var albumCounts: [String: Int] = [:] 197 186 var photoCount = 0 198 187 var videoCount = 0 199 188 200 189 for asset in assets { 201 - if let year = asset.year { 202 - yearCounts[year, default: 0] += 1 190 + let matchesYear = year == nil || asset.year == year 191 + let matchesAlbum = album == nil || asset.albums.contains(album!) 192 + let matchesFav = favorites != true || asset.isFavorite 193 + let matchesType: Bool 194 + switch mediaType { 195 + case "photo": matchesType = !asset.isVideo 196 + case "video": matchesType = asset.isVideo 197 + default: matchesType = true 198 + } 199 + 200 + // Year counts: apply all filters except year 201 + if matchesAlbum && matchesFav && matchesType { 202 + if let y = asset.year { yearCounts[y, default: 0] += 1 } 203 + } 204 + // Album counts: apply all filters except album 205 + if matchesYear && matchesFav && matchesType { 206 + for a in asset.albums { albumCounts[a, default: 0] += 1 } 203 207 } 204 - for album in asset.albums { 205 - albumCounts[album, default: 0] += 1 208 + // Totals: all filters applied 209 + if matchesYear && matchesAlbum && matchesFav && matchesType { 210 + if asset.isVideo { videoCount += 1 } else { photoCount += 1 } 206 211 } 207 - if asset.isVideo { videoCount += 1 } else { photoCount += 1 } 208 212 } 209 213 210 214 return FilterOptions( ··· 212 216 .sorted { $0.year > $1.year }, 213 217 albums: albumCounts.map { AlbumCount(album: $0.key, count: $0.value) } 214 218 .sorted { $0.count > $1.count }, 215 - totalAssets: assets.count, 219 + totalAssets: photoCount + videoCount, 216 220 totalPhotos: photoCount, 217 - totalVideos: videoCount 221 + totalVideos: videoCount, 222 + isLoading: _isLoading, 223 + loaded: _loadedCount, 224 + totalInLibrary: _isLoading ? _expectedTotal : assets.count 218 225 ) 219 226 } 220 227 221 - private static let videoUTIs: Set<String> = [ 222 - "public.mpeg-4", "com.apple.quicktime-movie", 223 - "com.apple.m4v-video", "public.avi", 224 - ] 228 + /// Find a single asset by UUID (O(1) dictionary lookup). 229 + public func asset(uuid: String) -> AssetView? { 230 + assetsByUUID[uuid] 231 + } 225 232 226 - private nonisolated(unsafe) static let isoFormatter = ISO8601DateFormatter() 233 + // MARK: - Private 234 + 235 + private func applyFilters( 236 + year: Int?, album: String?, favorites: Bool?, mediaType: String? 237 + ) -> [AssetView] { 238 + var filtered = assets 239 + if let year { 240 + filtered = filtered.filter { $0.year == year } 241 + } 242 + if let album { 243 + filtered = filtered.filter { $0.albums.contains(album) } 244 + } 245 + if let favorites, favorites { 246 + filtered = filtered.filter { $0.isFavorite } 247 + } 248 + if let mediaType { 249 + switch mediaType { 250 + case "photo": filtered = filtered.filter { !$0.isVideo } 251 + case "video": filtered = filtered.filter { $0.isVideo } 252 + default: break 253 + } 254 + } 255 + return filtered 256 + } 257 + 258 + private static func isVideoUTI(_ uti: String) -> Bool { 259 + guard let utType = UTType(uti) else { return false } 260 + return utType.conforms(to: .movie) 261 + } 227 262 228 263 private static func assetView(from meta: AssetMetadata) -> AssetView { 229 264 let year: Int? 230 265 if let dateStr = meta.dateCreated, 231 - let date = isoFormatter.date(from: dateStr) { 266 + let date = try? Date.ISO8601FormatStyle().parse(dateStr) { 232 267 year = Calendar.current.component(.year, from: date) 233 268 } else { 234 269 year = nil 235 270 } 236 271 237 - let isVideo = meta.type.map { videoUTIs.contains($0) } ?? false 272 + let isVideo = meta.type.map { isVideoUTI($0) } ?? false 238 273 239 274 return AssetView( 240 275 uuid: meta.uuid,
+2 -2
Tests/AtticCoreTests/ViewerDataStoreTests.swift
··· 166 166 #expect(result.assets.isEmpty) 167 167 } 168 168 169 - @Test func corruptMetadataIsSkipped() async { 169 + @Test func corruptMetadataIsSkipped() async throws { 170 170 let store = ViewerDataStore() 171 171 let s3 = MockS3Provider(objects: [ 172 - "metadata/assets/good-uuid.json": try! JSONEncoder().encode( 172 + "metadata/assets/good-uuid.json": try JSONEncoder().encode( 173 173 AssetMetadata( 174 174 uuid: "good-uuid", originalFilename: "IMG.HEIC", 175 175 dateCreated: "2024-01-15T12:00:00Z",