|
2 | 2 | import { onMount } from 'svelte'; |
3 | 3 | import { onSSEEvent } from '$lib/stores/sse.svelte'; |
4 | 4 | import { showConfirm } from '$lib/stores/modal.svelte'; |
5 | | - import { addToast } from '$lib/stores/toast.svelte'; |
| 5 | + import { addToast, removeToast } from '$lib/stores/toast.svelte'; |
6 | 6 | import { csrfFetch, safeFetchJson, isFetchError, type FetchError } from '$lib/utils/fetch'; |
7 | 7 | import Skeleton from '$lib/components/ui/Skeleton.svelte'; |
8 | 8 | import EmptyState from '$lib/components/ui/EmptyState.svelte'; |
|
26 | 26 | let subsError = $state<FetchError | null>(null); |
27 | 27 | let profilesError = $state<FetchError | null>(null); |
28 | 28 | let checkingNow = $state<Set<string>>(new Set()); |
29 | | - let checkResult = $state<{ id: string; message: string } | null>(null); |
| 29 | + const checkTimeouts = new Map<string, ReturnType<typeof setTimeout>>(); |
| 30 | + const checkPendingToastDismiss = new Map<string, () => void>(); |
30 | 31 | let showSubsForm = $state(false); |
31 | 32 | let subFormUrl = $state(''); |
32 | 33 | let subFormProfileId = $state(''); |
|
95 | 96 | loadSubscriptions(); |
96 | 97 |
|
97 | 98 | const unsubChecked = onSSEEvent('subscription:checked', ({ id, name, newVideos }) => { |
98 | | - const message = |
99 | | - newVideos > 0 |
100 | | - ? `Found ${newVideos} new video${newVideos > 1 ? 's' : ''} for ${name}` |
101 | | - : `No new videos for ${name}`; |
102 | | - checkResult = { id, message }; |
103 | | - setTimeout(() => { |
104 | | - if (checkResult?.id === id) checkResult = null; |
105 | | - }, 5000); |
| 99 | + const safetyTimeout = checkTimeouts.get(id); |
| 100 | + if (safetyTimeout) { clearTimeout(safetyTimeout); checkTimeouts.delete(id); } |
| 101 | + const dismiss = checkPendingToastDismiss.get(id); |
| 102 | + if (dismiss) { dismiss(); checkPendingToastDismiss.delete(id); } |
| 103 | + checkingNow = new Set([...checkingNow].filter((x) => x !== id)); |
| 104 | + const message = newVideos > 0 |
| 105 | + ? `Found ${newVideos} new video${newVideos > 1 ? 's' : ''} for ${name}` |
| 106 | + : `No new videos for ${name}`; |
| 107 | + addToast(newVideos > 0 ? 'success' : 'info', message); |
106 | 108 | loadSubscriptions(); |
107 | 109 | }); |
108 | 110 | const unsubBackfill = onSSEEvent('subscription:backfill', ({ name, totalVideos, newVideos }) => { |
|
196 | 198 | subFormSaveToLibrary = libraryConfigured; |
197 | 199 | subFormOptions = { sponsorblock: false, subtitles: false, metadata: false }; |
198 | 200 | showSubsForm = false; |
| 201 | + addToast('success', 'Subscription added'); |
199 | 202 | await loadSubscriptions(); |
200 | 203 | } else { |
201 | 204 | const data = await res.json().catch(() => null); |
|
207 | 210 | } |
208 | 211 |
|
209 | 212 | async function toggleSubscription(id: string, enabled: boolean) { |
| 213 | + let toastId: string | null = null; |
| 214 | + const timer = setTimeout(() => { |
| 215 | + toastId = addToast('info', enabled ? 'Pausing subscription...' : 'Resuming subscription...', 10000); |
| 216 | + }, 350); |
210 | 217 | try { |
211 | 218 | await csrfFetch(`/api/subscriptions/${id}`, { |
212 | 219 | method: 'PATCH', |
|
215 | 222 | }); |
216 | 223 | await loadSubscriptions(); |
217 | 224 | } catch (e) { |
218 | | - console.error('Failed to toggle subscription:', e); |
219 | 225 | addToast('error', 'Failed to update subscription'); |
| 226 | + } finally { |
| 227 | + clearTimeout(timer); |
| 228 | + if (toastId) removeToast(toastId); |
220 | 229 | } |
221 | 230 | } |
222 | 231 |
|
|
239 | 248 | async function checkNow(id: string) { |
240 | 249 | if (checkingNow.has(id)) return; |
241 | 250 | checkingNow = new Set([...checkingNow, id]); |
| 251 | +
|
| 252 | + let toastId: string | null = null; |
| 253 | + const toastTimer = setTimeout(() => { |
| 254 | + toastId = addToast('info', 'Checking for new videos...', 30000); |
| 255 | + }, 350); |
| 256 | + const dismissToast = () => { clearTimeout(toastTimer); if (toastId) removeToast(toastId); }; |
| 257 | + checkPendingToastDismiss.set(id, dismissToast); |
| 258 | +
|
| 259 | + const safetyTimeout = setTimeout(() => { |
| 260 | + dismissToast(); |
| 261 | + checkPendingToastDismiss.delete(id); |
| 262 | + checkingNow = new Set([...checkingNow].filter((x) => x !== id)); |
| 263 | + }, 60000); |
| 264 | + checkTimeouts.set(id, safetyTimeout); |
| 265 | +
|
242 | 266 | try { |
243 | 267 | await csrfFetch(`/api/subscriptions/${id}/check`, { method: 'POST' }); |
244 | 268 | } catch (e) { |
245 | | - console.error('Failed to check subscription:', e); |
246 | | - } finally { |
| 269 | + clearTimeout(safetyTimeout); |
| 270 | + checkTimeouts.delete(id); |
| 271 | + dismissToast(); |
| 272 | + checkPendingToastDismiss.delete(id); |
247 | 273 | checkingNow = new Set([...checkingNow].filter((x) => x !== id)); |
| 274 | + addToast('error', 'Failed to start check'); |
248 | 275 | } |
249 | 276 | } |
250 | 277 |
|
|
548 | 575 | </p> |
549 | 576 | {/if} |
550 | 577 |
|
551 | | - {#if checkResult && checkResult.id === sub.id} |
552 | | - <p class="check-result">{checkResult.message}</p> |
553 | | - {/if} |
554 | | - |
555 | 578 | <div class="actions"> |
556 | 579 | <button |
557 | | - class="btn btn-sm btn-icon btn-primary" |
| 580 | + class="btn btn-sm btn-primary" |
558 | 581 | onclick={() => checkNow(sub.id)} |
559 | 582 | disabled={checkingNow.has(sub.id)} |
560 | 583 | aria-label="Check now" |
561 | 584 | title="Check now" |
562 | 585 | > |
563 | 586 | {#if checkingNow.has(sub.id)} |
564 | 587 | <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="spin"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg> |
| 588 | + Checking |
565 | 589 | {:else} |
566 | 590 | <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg> |
| 591 | + Check |
567 | 592 | {/if} |
568 | 593 | </button> |
569 | | - <button class="btn btn-sm btn-icon btn-secondary" onclick={() => startEditSub(sub)} aria-label="Edit" title="Edit"> |
| 594 | + <button class="btn btn-sm btn-secondary" onclick={() => startEditSub(sub)} aria-label="Edit" title="Edit"> |
570 | 595 | <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> |
| 596 | + Edit |
571 | 597 | </button> |
572 | 598 | <button |
573 | | - class="btn btn-sm btn-icon btn-secondary" |
| 599 | + class="btn btn-sm btn-secondary" |
574 | 600 | onclick={() => toggleSubscription(sub.id, sub.enabled)} |
575 | 601 | aria-label={sub.enabled ? 'Pause' : 'Resume'} |
576 | 602 | title={sub.enabled ? 'Pause' : 'Resume'} |
577 | 603 | > |
578 | 604 | {#if sub.enabled} |
579 | 605 | <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg> |
| 606 | + Pause |
580 | 607 | {:else} |
581 | 608 | <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg> |
| 609 | + Resume |
582 | 610 | {/if} |
583 | 611 | </button> |
584 | 612 | <button |
585 | | - class="btn btn-sm btn-icon btn-secondary" |
| 613 | + class="btn btn-sm btn-secondary" |
586 | 614 | onclick={() => (showBackfillMenu = showBackfillMenu === sub.id ? null : sub.id)} |
587 | 615 | aria-label={showBackfillMenu === sub.id ? 'Close backfill' : 'Backfill'} |
588 | 616 | title={showBackfillMenu === sub.id ? 'Close backfill' : 'Backfill'} |
589 | 617 | > |
590 | 618 | <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> |
| 619 | + {showBackfillMenu === sub.id ? 'Close' : 'Backfill'} |
591 | 620 | </button> |
592 | | - <button class="btn btn-sm btn-icon btn-danger" onclick={() => deleteSubscription(sub.id)} aria-label="Delete" title="Delete"> |
| 621 | + <button class="btn btn-sm btn-danger" onclick={() => deleteSubscription(sub.id)} aria-label="Delete" title="Delete"> |
593 | 622 | <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> |
| 623 | + Delete |
594 | 624 | </button> |
595 | 625 | </div> |
596 | 626 |
|
|
850 | 880 | color: var(--color-text-secondary); |
851 | 881 | } |
852 | 882 |
|
853 | | - .check-result { |
854 | | - font-size: 0.85rem; |
855 | | - color: var(--color-accent-primary); |
856 | | - margin: var(--spacing-xs) 0 0; |
857 | | - } |
858 | | -
|
859 | 883 | .form-error { |
860 | 884 | color: var(--color-status-error, #ef4444); |
861 | 885 | font-size: 0.85rem; |
|
0 commit comments