|
42 | 42 | let offlineDone = $state(0); |
43 | 43 | let offlineTotal = $state(0); |
44 | 44 | let offlineMessage = $state(''); |
| 45 | + let offlineCurrentAsset = $state(''); |
45 | 46 | const offlineFeatureEnabled = import.meta.env.PROD; |
46 | 47 | const LEGACY_COI_CLEANUP_ONCE_KEY = 'svt:legacy-coi-cleanup-once'; |
47 | 48 |
|
|
67 | 68 | return new URL(String(url), window.location.href).href; |
68 | 69 | } |
69 | 70 |
|
| 71 | + function basenameFromUrl(url) { |
| 72 | + try { |
| 73 | + const pathname = new URL(String(url), window.location.href).pathname; |
| 74 | + const base = pathname.split('/').filter(Boolean).pop() || pathname; |
| 75 | + return decodeURIComponent(base); |
| 76 | + } catch { |
| 77 | + return String(url || ''); |
| 78 | + } |
| 79 | + } |
| 80 | +
|
| 81 | + function compactAssetLabel(text, max = 18) { |
| 82 | + const value = String(text || '').trim(); |
| 83 | + if (!value) return ''; |
| 84 | + if (value.length <= max) return value; |
| 85 | + return `${value.slice(0, Math.max(1, max - 1))}...`; |
| 86 | + } |
| 87 | +
|
70 | 88 | function offlineReadyMarkerUrl() { |
71 | 89 | return toAbsolute(offlineReadySentinelUrl(runtimeBasePath())); |
72 | 90 | } |
|
121 | 139 | return { removed, controlledByLegacy }; |
122 | 140 | } |
123 | 141 |
|
124 | | - async function clearOfflineArtifactsForDev() { |
| 142 | + async function clearOfflineArtifacts() { |
125 | 143 | if (!browser) return; |
126 | 144 | localStorage.removeItem(OFFLINE_STATE_KEY); |
127 | 145 | offlineMode = 'idle'; |
128 | 146 | offlineDone = 0; |
129 | 147 | offlineTotal = 0; |
130 | 148 | offlineMessage = ''; |
| 149 | + offlineCurrentAsset = ''; |
131 | 150 |
|
132 | 151 | if ('serviceWorker' in navigator) { |
133 | 152 | const scopeRoot = new URL(`${base || ''}/`, window.location.href).href; |
|
149 | 168 | } |
150 | 169 | } |
151 | 170 |
|
| 171 | + async function resetOfflineBundle() { |
| 172 | + if (!browser || offlineMode === 'downloading') return; |
| 173 | + await clearOfflineArtifacts(); |
| 174 | + } |
| 175 | +
|
152 | 176 | async function resolveOfflineAssetUrls(config) { |
153 | 177 | const basePath = runtimeBasePath(); |
154 | 178 | const urls = buildConfiguredRuntimeUrls({ |
|
195 | 219 | return 'Offline bundle download is available in preview/prod builds'; |
196 | 220 | } |
197 | 221 | if (offlineMode === 'downloading') { |
198 | | - const total = offlineTotal || '?'; |
199 | | - return `Downloading offline bundle (${offlineDone}/${total})`; |
| 222 | + if (offlineTotal <= 0) return 'Preparing offline bundle...'; |
| 223 | + if (offlineDone >= offlineTotal) return 'Finalizing offline bundle...'; |
| 224 | + const current = offlineCurrentAsset ? ` (${offlineCurrentAsset})` : ''; |
| 225 | + return `Downloading offline bundle (${offlineDone + 1}/${offlineTotal})${current}`; |
200 | 226 | } |
201 | 227 | if (offlineMode === 'ready') { |
202 | 228 | return offlineMessage || 'Offline bundle is ready'; |
|
209 | 235 |
|
210 | 236 | function offlineButtonLabel() { |
211 | 237 | if (!offlineFeatureEnabled) return 'Offline bundle unavailable in dev mode'; |
212 | | - if (offlineMode === 'downloading') return 'Downloading offline bundle'; |
| 238 | + if (offlineMode === 'downloading') { |
| 239 | + if (offlineTotal <= 0) return 'Preparing offline bundle'; |
| 240 | + if (offlineDone >= offlineTotal) return 'Finalizing offline bundle'; |
| 241 | + return `Downloading offline bundle ${offlineDone + 1}/${offlineTotal}`; |
| 242 | + } |
213 | 243 | if (offlineMode === 'ready') return 'Offline bundle ready'; |
214 | 244 | if (offlineMode === 'error') return 'Retry offline bundle download'; |
215 | 245 | return 'Download offline bundle'; |
216 | 246 | } |
217 | 247 |
|
218 | 248 | function offlineProgressText() { |
219 | 249 | if (offlineMode === 'downloading') { |
220 | | - const total = offlineTotal || '?'; |
221 | | - return `${offlineDone}/${total}`; |
| 250 | + if (offlineTotal <= 0) return 'preparing...'; |
| 251 | + if (offlineDone >= offlineTotal) return 'finalizing...'; |
| 252 | + const current = offlineCurrentAsset ? ` ${offlineCurrentAsset}` : ''; |
| 253 | + return `${offlineDone + 1}/${offlineTotal}${current}`; |
222 | 254 | } |
223 | 255 | if (offlineMode === 'ready') return 'offline ready'; |
224 | 256 | if (offlineMode === 'error') return 'download failed'; |
|
231 | 263 | offlineDone = 0; |
232 | 264 | offlineTotal = 0; |
233 | 265 | offlineMessage = 'Preparing offline bundle...'; |
| 266 | + offlineCurrentAsset = ''; |
234 | 267 |
|
235 | 268 | try { |
236 | 269 | await ensureOfflineServiceWorker(); |
|
251 | 284 | let success = 0; |
252 | 285 |
|
253 | 286 | for (const url of urls) { |
| 287 | + offlineCurrentAsset = compactAssetLabel(basenameFromUrl(url)); |
254 | 288 | try { |
255 | 289 | const request = new Request(url, { |
256 | 290 | method: 'GET', |
|
273 | 307 | failures.push(`${url}: ${String(error?.message || error)}`); |
274 | 308 | } finally { |
275 | 309 | offlineDone += 1; |
| 310 | + if (offlineDone >= offlineTotal) offlineCurrentAsset = ''; |
276 | 311 | } |
277 | 312 | } |
278 | 313 |
|
|
296 | 331 | if (skippedCrossOrigin > 0) { |
297 | 332 | offlineMessage += ` Skipped ${skippedCrossOrigin} cross-origin URLs.`; |
298 | 333 | } |
| 334 | + offlineCurrentAsset = ''; |
299 | 335 | localStorage.setItem(OFFLINE_STATE_KEY, 'ready'); |
300 | 336 | } |
301 | 337 | } catch (error) { |
302 | 338 | offlineMode = 'error'; |
303 | 339 | offlineMessage = String(error?.message || error || 'Offline download failed'); |
| 340 | + offlineCurrentAsset = ''; |
304 | 341 | localStorage.removeItem(OFFLINE_STATE_KEY); |
305 | 342 | if ('caches' in window) { |
306 | 343 | caches |
|
386 | 423 | } |
387 | 424 |
|
388 | 425 | if (!offlineFeatureEnabled) { |
389 | | - clearOfflineArtifactsForDev().catch(() => {}); |
| 426 | + clearOfflineArtifacts().catch(() => {}); |
390 | 427 | return; |
391 | 428 | } |
392 | 429 | const offlineRequested = localStorage.getItem(OFFLINE_STATE_KEY) === 'ready'; |
|
509 | 546 | </button> |
510 | 547 | {#if offlineMode !== 'idle'} |
511 | 548 | <span |
512 | | - class="text-[0.68rem] leading-none whitespace-nowrap px-2 py-[0.32rem] rounded-[8px] border {offlineMode === 'ready' ? 'text-teal border-teal/40 bg-teal/5' : offlineMode === 'error' ? 'text-destructive border-destructive/40 bg-destructive/5' : 'text-muted-foreground border-border bg-surface-2'}" |
| 549 | + class="text-[0.68rem] leading-none whitespace-nowrap max-w-[170px] overflow-hidden text-ellipsis px-2 py-[0.32rem] rounded-[8px] border {offlineMode === 'ready' ? 'text-teal border-teal/40 bg-teal/5' : offlineMode === 'error' ? 'text-destructive border-destructive/40 bg-destructive/5' : 'text-muted-foreground border-border bg-surface-2'}" |
513 | 550 | data-testid="offline-download-status" |
514 | 551 | aria-live="polite" |
515 | 552 | title={offlineButtonTitle()} |
516 | 553 | > |
517 | 554 | {offlineProgressText()} |
518 | 555 | </span> |
| 556 | + {#if offlineMode === 'ready' || offlineMode === 'error'} |
| 557 | + <button |
| 558 | + onclick={resetOfflineBundle} |
| 559 | + class="flex items-center justify-center w-8 h-8 rounded-[8px] hover:bg-surface-2 transition-colors text-muted-foreground" |
| 560 | + data-testid="offline-reset-button" |
| 561 | + aria-label="Reset offline bundle" |
| 562 | + title="Reset offline bundle cache" |
| 563 | + > |
| 564 | + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> |
| 565 | + <path d="M3 6h18" /> |
| 566 | + <path d="M8 6V4h8v2" /> |
| 567 | + <path d="M6 6l1 14h10l1-14" /> |
| 568 | + </svg> |
| 569 | + </button> |
| 570 | + {/if} |
519 | 571 | {/if} |
520 | 572 | <button |
521 | 573 | onclick={toggleDarkMode} |
|
0 commit comments