fix: refresh page on new articles banner clickUnverified
d8c42c5 parent: 5493a74 modified
web/src/lib/components/NewArticlesBanner.svelte +22 -9 | @@ -1,6 +1,5 @@ | ||
| 1 | 1 | <script lang="ts"> |
| 2 | 2 | import { onMount } from "svelte"; |
| 3 | - import { invalidateAll } from "$app/navigation"; | |
| 4 | 3 | import { endpoints } from "$lib/api"; |
| 5 | 4 | import Icon from "./Icon.svelte"; |
| 6 | 5 | |
| @@ -9,10 +8,16 @@ | ||
| 9 | 8 | since: number; |
| 10 | 9 | /** Polling interval in milliseconds. */ |
| 11 | 10 | interval?: number; |
| 11 | + /** | |
| 12 | + * Reload handler invoked when the user clicks "Show". Should perform | |
| 13 | + * a full route reload so the new articles render. | |
| 14 | + */ | |
| 15 | + onrefresh: () => Promise<unknown>; | |
| 12 | 16 | } |
| 13 | - let { since, interval = 60_000 }: Props = $props(); | |
| 17 | + let { since, interval = 60_000, onrefresh }: Props = $props(); | |
| 14 | 18 | |
| 15 | 19 | let count = $state(0); |
| 20 | + let refreshing = $state(false); | |
| 16 | 21 | let timer: ReturnType<typeof setInterval>; |
| 17 | 22 | |
| 18 | 23 | onMount(() => { |
| @@ -22,7 +27,7 @@ | ||
| 22 | 27 | |
| 23 | 28 | async function poll() { |
| 24 | 29 | // Hide the banner for inactive tabs; the next visibility change polls. |
| 25 | - if (document.hidden) return; | |
| 30 | + if (document.hidden || refreshing) return; | |
| 26 | 31 | try { |
| 27 | 32 | const res = await endpoints.newArticleCount(since); |
| 28 | 33 | count = res.count; |
| @@ -32,25 +37,33 @@ | ||
| 32 | 37 | } |
| 33 | 38 | |
| 34 | 39 | async function refresh() { |
| 35 | - since = Math.floor(Date.now() / 1000); | |
| 36 | - count = 0; | |
| 37 | - await invalidateAll(); | |
| 40 | + if (refreshing) return; | |
| 41 | + refreshing = true; | |
| 42 | + try { | |
| 43 | + await onrefresh(); | |
| 44 | + count = 0; | |
| 45 | + } finally { | |
| 46 | + refreshing = false; | |
| 47 | + } | |
| 38 | 48 | } |
| 39 | 49 | </script> |
| 40 | 50 | |
| 41 | -{#if count > 0} | |
| 51 | +{#if count > 0 || refreshing} | |
| 42 | 52 | <div |
| 43 | 53 | class="mb-4 flex items-center justify-between gap-3 border-2 border-[var(--border)] bg-[var(--accent)] px-4 py-2.5 text-[var(--accent-ink)] shadow-[4px_4px_0_0_var(--border)] dark:text-white" |
| 44 | 54 | role="status" |
| 45 | 55 | > |
| 46 | 56 | <span class="flex items-center gap-2 text-sm font-bold"> |
| 47 | 57 | <Icon name="sparkles" class="h-4 w-4" /> |
| 48 | - {count} new article{count === 1 ? "" : "s"} | |
| 58 | + {refreshing | |
| 59 | + ? "Loading…" | |
| 60 | + : `${count} new article${count === 1 ? "" : "s"}`} | |
| 49 | 61 | </span> |
| 50 | 62 | <button |
| 51 | 63 | type="button" |
| 52 | 64 | onclick={refresh} |
| 53 | - class="border-2 border-[var(--border)] bg-[var(--bg)] px-3 py-1 text-xs font-bold uppercase tracking-wide text-[var(--fg)] shadow-[2px_2px_0_0_var(--border)] transition-transform hover:-translate-x-px hover:-translate-y-px" | |
| 65 | + disabled={refreshing} | |
| 66 | + class="border-2 border-[var(--border)] bg-[var(--bg)] px-3 py-1 text-xs font-bold uppercase tracking-wide text-[var(--fg)] shadow-[2px_2px_0_0_var(--border)] transition-transform hover:-translate-x-px hover:-translate-y-px disabled:cursor-wait disabled:opacity-60" | |
| 54 | 67 | > |
| 55 | 68 | Show |
| 56 | 69 | </button> |
| @@ -1,6 +1,5 @@ | |||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> |
| 2 | import { onMount } from "svelte"; | 2 | import { onMount } from "svelte"; |
| 3 | - import { invalidateAll } from "$app/navigation"; | ||
| 4 | import { endpoints } from "$lib/api"; | 3 | import { endpoints } from "$lib/api"; |
| 5 | import Icon from "./Icon.svelte"; | 4 | import Icon from "./Icon.svelte"; |
| 6 | 5 | ||
| @@ -9,10 +8,16 @@ | |||
| 9 | since: number; | 8 | since: number; |
| 10 | /** Polling interval in milliseconds. */ | 9 | /** Polling interval in milliseconds. */ |
| 11 | interval?: number; | 10 | interval?: number; |
| 11 | + /** | ||
| 12 | + * Reload handler invoked when the user clicks "Show". Should perform | ||
| 13 | + * a full route reload so the new articles render. | ||
| 14 | + */ | ||
| 15 | + onrefresh: () => Promise<unknown>; | ||
| 12 | } | 16 | } |
| 13 | - let { since, interval = 60_000 }: Props = $props(); | 17 | + let { since, interval = 60_000, onrefresh }: Props = $props(); |
| 14 | 18 | ||
| 15 | let count = $state(0); | 19 | let count = $state(0); |
| 20 | + let refreshing = $state(false); | ||
| 16 | let timer: ReturnType<typeof setInterval>; | 21 | let timer: ReturnType<typeof setInterval>; |
| 17 | 22 | ||
| 18 | onMount(() => { | 23 | onMount(() => { |
| @@ -22,7 +27,7 @@ | |||
| 22 | 27 | ||
| 23 | async function poll() { | 28 | async function poll() { |
| 24 | // Hide the banner for inactive tabs; the next visibility change polls. | 29 | // Hide the banner for inactive tabs; the next visibility change polls. |
| 25 | - if (document.hidden) return; | 30 | + if (document.hidden || refreshing) return; |
| 26 | try { | 31 | try { |
| 27 | const res = await endpoints.newArticleCount(since); | 32 | const res = await endpoints.newArticleCount(since); |
| 28 | count = res.count; | 33 | count = res.count; |
| @@ -32,25 +37,33 @@ | |||
| 32 | } | 37 | } |
| 33 | 38 | ||
| 34 | async function refresh() { | 39 | async function refresh() { |
| 35 | - since = Math.floor(Date.now() / 1000); | 40 | + if (refreshing) return; |
| 36 | - count = 0; | 41 | + refreshing = true; |
| 37 | - await invalidateAll(); | 42 | + try { |
| 43 | + await onrefresh(); | ||
| 44 | + count = 0; | ||
| 45 | + } finally { | ||
| 46 | + refreshing = false; | ||
| 47 | + } | ||
| 38 | } | 48 | } |
| 39 | </script> | 49 | </script> |
| 40 | 50 | ||
| 41 | -{#if count > 0} | 51 | +{#if count > 0 || refreshing} |
| 42 | <div | 52 | <div |
| 43 | class="mb-4 flex items-center justify-between gap-3 border-2 border-[var(--border)] bg-[var(--accent)] px-4 py-2.5 text-[var(--accent-ink)] shadow-[4px_4px_0_0_var(--border)] dark:text-white" | 53 | class="mb-4 flex items-center justify-between gap-3 border-2 border-[var(--border)] bg-[var(--accent)] px-4 py-2.5 text-[var(--accent-ink)] shadow-[4px_4px_0_0_var(--border)] dark:text-white" |
| 44 | role="status" | 54 | role="status" |
| 45 | > | 55 | > |
| 46 | <span class="flex items-center gap-2 text-sm font-bold"> | 56 | <span class="flex items-center gap-2 text-sm font-bold"> |
| 47 | <Icon name="sparkles" class="h-4 w-4" /> | 57 | <Icon name="sparkles" class="h-4 w-4" /> |
| 48 | - {count} new article{count === 1 ? "" : "s"} | 58 | + {refreshing |
| 59 | + ? "Loading…" | ||
| 60 | + : `${count} new article${count === 1 ? "" : "s"}`} | ||
| 49 | </span> | 61 | </span> |
| 50 | <button | 62 | <button |
| 51 | type="button" | 63 | type="button" |
| 52 | onclick={refresh} | 64 | onclick={refresh} |
| 53 | - class="border-2 border-[var(--border)] bg-[var(--bg)] px-3 py-1 text-xs font-bold uppercase tracking-wide text-[var(--fg)] shadow-[2px_2px_0_0_var(--border)] transition-transform hover:-translate-x-px hover:-translate-y-px" | 65 | + disabled={refreshing} |
| 66 | + class="border-2 border-[var(--border)] bg-[var(--bg)] px-3 py-1 text-xs font-bold uppercase tracking-wide text-[var(--fg)] shadow-[2px_2px_0_0_var(--border)] transition-transform hover:-translate-x-px hover:-translate-y-px disabled:cursor-wait disabled:opacity-60" | ||
| 54 | > | 67 | > |
| 55 | Show | 68 | Show |
| 56 | </button> | 69 | </button> |
modified
web/src/routes/articles/+page.svelte +7 -1 | @@ -54,6 +54,12 @@ | ||
| 54 | 54 | .then(() => goto("/articles", { invalidateAll: true })); |
| 55 | 55 | } |
| 56 | 56 | |
| 57 | + // Reload the current route (preserving filters) so newly-fetched articles | |
| 58 | + // appear without a manual hard refresh. | |
| 59 | + function onrefresh() { | |
| 60 | + return goto(buildURL({}), { invalidateAll: true, noScroll: false }); | |
| 61 | + } | |
| 62 | + | |
| 57 | 63 | // Expanded view: mark articles read on scroll. |
| 58 | 64 | function readOnScroll(node: HTMLElement, id: number) { |
| 59 | 65 | if (!data.expanded_view) return; |
| @@ -283,7 +289,7 @@ | ||
| 283 | 289 | {/if} |
| 284 | 290 | |
| 285 | 291 | <!-- Live banner: surfaces newly-fetched articles since the page loaded --> |
| 286 | -<NewArticlesBanner since={data.now} /> | |
| 292 | +<NewArticlesBanner since={data.now} {onrefresh} /> | |
| 287 | 293 | |
| 288 | 294 | <!-- List --> |
| 289 | 295 | <div class="space-y-3"> |
| @@ -54,6 +54,12 @@ | |||
| 54 | .then(() => goto("/articles", { invalidateAll: true })); | 54 | .then(() => goto("/articles", { invalidateAll: true })); |
| 55 | } | 55 | } |
| 56 | 56 | ||
| 57 | + // Reload the current route (preserving filters) so newly-fetched articles | ||
| 58 | + // appear without a manual hard refresh. | ||
| 59 | + function onrefresh() { | ||
| 60 | + return goto(buildURL({}), { invalidateAll: true, noScroll: false }); | ||
| 61 | + } | ||
| 62 | + | ||
| 57 | // Expanded view: mark articles read on scroll. | 63 | // Expanded view: mark articles read on scroll. |
| 58 | function readOnScroll(node: HTMLElement, id: number) { | 64 | function readOnScroll(node: HTMLElement, id: number) { |
| 59 | if (!data.expanded_view) return; | 65 | if (!data.expanded_view) return; |
| @@ -283,7 +289,7 @@ | |||
| 283 | {/if} | 289 | {/if} |
| 284 | 290 | ||
| 285 | <!-- Live banner: surfaces newly-fetched articles since the page loaded --> | 291 | <!-- Live banner: surfaces newly-fetched articles since the page loaded --> |
| 286 | -<NewArticlesBanner since={data.now} /> | 292 | +<NewArticlesBanner since={data.now} {onrefresh} /> |
| 287 | 293 | ||
| 288 | <!-- List --> | 294 | <!-- List --> |
| 289 | <div class="space-y-3"> | 295 | <div class="space-y-3"> |
modified
web/src/routes/dashboard/+page.svelte +7 -1 | @@ -7,6 +7,7 @@ | ||
| 7 | 7 | import EmptyState from "$lib/components/EmptyState.svelte"; |
| 8 | 8 | import Icon from "$lib/components/Icon.svelte"; |
| 9 | 9 | import NewArticlesBanner from "$lib/components/NewArticlesBanner.svelte"; |
| 10 | + import { goto } from "$app/navigation"; | |
| 10 | 11 | import { endpoints } from "$lib/api"; |
| 11 | 12 | import type { |
| 12 | 13 | Article, |
| @@ -66,6 +67,11 @@ | ||
| 66 | 67 | discover = discover.filter((p) => p.did !== did); |
| 67 | 68 | } |
| 68 | 69 | |
| 70 | + // Full route reload so newly-fetched articles render without a hard refresh. | |
| 71 | + function onrefresh() { | |
| 72 | + return goto("/dashboard", { invalidateAll: true }); | |
| 73 | + } | |
| 74 | + | |
| 69 | 75 | let digestOpen = $state(false); |
| 70 | 76 | async function markDigestRead() { |
| 71 | 77 | if (!digest) return; |
| @@ -106,7 +112,7 @@ | ||
| 106 | 112 | </div> |
| 107 | 113 | |
| 108 | 114 | {#if data.subscription_count > 0} |
| 109 | - <NewArticlesBanner since={data.now} /> | |
| 115 | + <NewArticlesBanner since={data.now} {onrefresh} /> | |
| 110 | 116 | {/if} |
| 111 | 117 | |
| 112 | 118 | {#if data.subscription_count === 0} |
| @@ -7,6 +7,7 @@ | |||
| 7 | import EmptyState from "$lib/components/EmptyState.svelte"; | 7 | import EmptyState from "$lib/components/EmptyState.svelte"; |
| 8 | import Icon from "$lib/components/Icon.svelte"; | 8 | import Icon from "$lib/components/Icon.svelte"; |
| 9 | import NewArticlesBanner from "$lib/components/NewArticlesBanner.svelte"; | 9 | import NewArticlesBanner from "$lib/components/NewArticlesBanner.svelte"; |
| 10 | + import { goto } from "$app/navigation"; | ||
| 10 | import { endpoints } from "$lib/api"; | 11 | import { endpoints } from "$lib/api"; |
| 11 | import type { | 12 | import type { |
| 12 | Article, | 13 | Article, |
| @@ -66,6 +67,11 @@ | |||
| 66 | discover = discover.filter((p) => p.did !== did); | 67 | discover = discover.filter((p) => p.did !== did); |
| 67 | } | 68 | } |
| 68 | 69 | ||
| 70 | + // Full route reload so newly-fetched articles render without a hard refresh. | ||
| 71 | + function onrefresh() { | ||
| 72 | + return goto("/dashboard", { invalidateAll: true }); | ||
| 73 | + } | ||
| 74 | + | ||
| 69 | let digestOpen = $state(false); | 75 | let digestOpen = $state(false); |
| 70 | async function markDigestRead() { | 76 | async function markDigestRead() { |
| 71 | if (!digest) return; | 77 | if (!digest) return; |
| @@ -106,7 +112,7 @@ | |||
| 106 | </div> | 112 | </div> |
| 107 | 113 | ||
| 108 | {#if data.subscription_count > 0} | 114 | {#if data.subscription_count > 0} |
| 109 | - <NewArticlesBanner since={data.now} /> | 115 | + <NewArticlesBanner since={data.now} {onrefresh} /> |
| 110 | {/if} | 116 | {/if} |
| 111 | 117 | ||
| 112 | {#if data.subscription_count === 0} | 118 | {#if data.subscription_count === 0} |