Updating loading of articles to use a skeleton article while loading.

This commit is contained in:
Bradley Shellnut 2025-08-13 12:22:02 -07:00
parent 5752c125ee
commit bc367cf7d2
7 changed files with 220 additions and 145 deletions

View file

@ -21,7 +21,7 @@
"@internationalized/date": "^3.8.2", "@internationalized/date": "^3.8.2",
"@playwright/test": "^1.54.2", "@playwright/test": "^1.54.2",
"@sveltejs/enhanced-img": "^0.5.1", "@sveltejs/enhanced-img": "^0.5.1",
"@sveltejs/kit": "^2.28.0", "@sveltejs/kit": "^2.29.0",
"@sveltejs/vite-plugin-svelte": "^5.1.1", "@sveltejs/vite-plugin-svelte": "^5.1.1",
"@unpic/svelte": "^1.0.0", "@unpic/svelte": "^1.0.0",
"@zerodevx/svelte-img": "^2.1.2", "@zerodevx/svelte-img": "^2.1.2",

View file

@ -13,7 +13,7 @@ importers:
version: 2.6.2 version: 2.6.2
'@sveltejs/adapter-node': '@sveltejs/adapter-node':
specifier: ^5.2.14 specifier: ^5.2.14
version: 5.2.14(@sveltejs/kit@2.28.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0)))(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0))) version: 5.2.14(@sveltejs/kit@2.29.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0)))(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0)))
'@vercel/og': '@vercel/og':
specifier: ^0.6.8 specifier: ^0.6.8
version: 0.6.8 version: 0.6.8
@ -52,8 +52,8 @@ importers:
specifier: ^0.5.1 specifier: ^0.5.1
version: 0.5.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0)))(rollup@4.34.8)(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0)) version: 0.5.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0)))(rollup@4.34.8)(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0))
'@sveltejs/kit': '@sveltejs/kit':
specifier: ^2.28.0 specifier: ^2.29.0
version: 2.28.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0)))(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0)) version: 2.29.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0)))(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0))
'@sveltejs/vite-plugin-svelte': '@sveltejs/vite-plugin-svelte':
specifier: ^5.1.1 specifier: ^5.1.1
version: 5.1.1(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0)) version: 5.1.1(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0))
@ -1210,8 +1210,8 @@ packages:
svelte: ^5.0.0 svelte: ^5.0.0
vite: '>= 5.0.0' vite: '>= 5.0.0'
'@sveltejs/kit@2.28.0': '@sveltejs/kit@2.29.0':
resolution: {integrity: sha512-qrhygwHV5r6JrvCw4gwNqqxYGDi5YbajocxfKgFXmSFpFo8wQobUvsM0OfakN4h+0LEmXtqHRrC6BcyAkOwyoQ==} resolution: {integrity: sha512-gOynQRBThrtF/RjljB8Oybs9VHVmLbk9q7E7ALJT6ImppJtc/yx3sTGiBV64y+lwmagnBCmEMmJ40CVChGy8lA==}
engines: {node: '>=18.13'} engines: {node: '>=18.13'}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@ -3303,12 +3303,12 @@ snapshots:
dependencies: dependencies:
acorn: 8.15.0 acorn: 8.15.0
'@sveltejs/adapter-node@5.2.14(@sveltejs/kit@2.28.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0)))(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0)))': '@sveltejs/adapter-node@5.2.14(@sveltejs/kit@2.29.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0)))(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0)))':
dependencies: dependencies:
'@rollup/plugin-commonjs': 28.0.2(rollup@4.34.8) '@rollup/plugin-commonjs': 28.0.2(rollup@4.34.8)
'@rollup/plugin-json': 6.1.0(rollup@4.34.8) '@rollup/plugin-json': 6.1.0(rollup@4.34.8)
'@rollup/plugin-node-resolve': 16.0.0(rollup@4.34.8) '@rollup/plugin-node-resolve': 16.0.0(rollup@4.34.8)
'@sveltejs/kit': 2.28.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0)))(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0)) '@sveltejs/kit': 2.29.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0)))(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0))
rollup: 4.34.8 rollup: 4.34.8
'@sveltejs/enhanced-img@0.5.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0)))(rollup@4.34.8)(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0))': '@sveltejs/enhanced-img@0.5.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0)))(rollup@4.34.8)(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0))':
@ -3324,7 +3324,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- rollup - rollup
'@sveltejs/kit@2.28.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0)))(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0))': '@sveltejs/kit@2.29.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0)))(svelte@5.38.1)(vite@6.3.5(yaml@2.7.0))':
dependencies: dependencies:
'@standard-schema/spec': 1.0.0 '@standard-schema/spec': 1.0.0
'@sveltejs/acorn-typescript': 1.0.5(acorn@8.15.0) '@sveltejs/acorn-typescript': 1.0.5(acorn@8.15.0)

View file

@ -1,7 +1,10 @@
<script lang="ts"> <script lang="ts">
import type { Article } from '$lib/types/article'; import { ArrowRight } from "lucide-svelte";
import { ArrowRight } from 'lucide-svelte'; import { beforeNavigate, onNavigate } from "$app/navigation";
import ExternalLink from './ExternalLink.svelte'; import { page } from "$app/state";
import ArticlesSkeleton from "$lib/components/ArticlesSkeleton.svelte";
import type { Article } from "$lib/types/article";
import ExternalLink from "./ExternalLink.svelte";
type LoadData = { type LoadData = {
articles: Article[]; articles: Article[];
@ -19,42 +22,47 @@
const classes = $derived(data.classes || []); const classes = $derived(data.classes || []);
const articlesData = $derived(articles); const articlesData = $derived(articles);
let loadingArticles = $state(false);
beforeNavigate(() => {
loadingArticles = true;
});
onNavigate((navigation) => {
loadingArticles = true;
// Resolve the promise when the page is done loading
navigation?.complete.then(() => {
loadingArticles = false;
});
});
</script> </script>
<section class="articles"> <section class="articles">
<h2>Favorite Articles</h2> <h2>Favorite Articles</h2>
<div class={classes.join(' ')}> <div class={classes.join(" ")}>
<!-- {#await data.articles} {#if loadingArticles}
{#each Array(6) as _, i (i)} <ArticlesSkeleton count={6} />
<article class="card skeleton"> {:else}
<section>
<h3><span class="skeleton-text skeleton-title" aria-hidden="true">Loading article title...</span></h3>
<span class="skeleton-text skeleton-domain" aria-hidden="true">Loading domain...</span>
</section>
<section>
<span class="skeleton-text skeleton-reading" aria-hidden="true">Loading reading time...</span>
<span class="skeleton-text skeleton-tags" aria-hidden="true">Loading tags...</span>
</section>
</article>
{/each}
{:then articles} -->
{#each articlesData as article (article.hashed_url)} {#each articlesData as article (article.hashed_url)}
<article class="card"> <article class="card">
<section> <section>
<h3> <h3>
<ExternalLink <ExternalLink
textData={{ textData={{
text: compact ? article.title.substring(0, 50).trim() : article.title, text: compact
location: 'left', ? article.title.substring(0, 50).trim()
: article.title,
location: "left",
showIcon: true, showIcon: true,
}} }}
linkData={{ linkData={{
href: article.url.toString(), href: article.url.toString(),
ariaLabel: `Link to ${article.title}`, ariaLabel: `Link to ${article.title}`,
title: `Link to ${article.title}`, title: `Link to ${article.title}`,
target: '_blank', target: "_blank",
}} }}
iconData={{ iconClass: 'center' }} iconData={{ iconClass: "center" }}
/> />
</h3> </h3>
<p>{article.domain_name}</p> <p>{article.domain_name}</p>
@ -70,14 +78,15 @@
</section> </section>
</article> </article>
{/each} {/each}
<!-- {:catch error} {/if}
<p>There was an error loading the articles.</p>
{/await} -->
</div> </div>
<a class="moreArticles" href="/articles">{`${totalArticles} more articles`} <ArrowRight /></a> {#if page.url.pathname === "/"}
<a class="moreArticles" href="/articles"
>{`${totalArticles} more articles`} <ArrowRight /></a
>
{/if}
</section> </section>
<style lang="postcss"> <style lang="postcss">
article { article {
margin: 1.5rem 0; margin: 1.5rem 0;

View file

@ -4,42 +4,45 @@
classes?: string[]; classes?: string[];
} }
let { count = 6, classes = ['columns'] }: Props = $props(); let { count = 6, classes = [""] }: Props = $props();
const placeholders = Array.from({ length: count }); const placeholders = Array.from({ length: count });
</script> </script>
<div class={classes.join(' ')} role="status" aria-live="polite" aria-busy="true">
{#each placeholders as _, i (i)} {#each placeholders as _, i (i)}
<article class="card skeleton"> <article class={`card skeleton ${classes.join(" ")}`}>
<section> <section>
<h3><span class="skeleton-text skeleton-title" aria-hidden="true">Loading article title...</span></h3> <h3>
<span class="skeleton-text skeleton-domain" aria-hidden="true">Loading domain...</span> <span class="skeleton-text skeleton-title" aria-hidden="true"
>Loading article title...</span
>
</h3>
<p>
<span class="skeleton-text skeleton-domain" aria-hidden="true"
>Loading domain...</span
>
</p>
</section> </section>
<section> <section>
<span class="skeleton-text skeleton-reading" aria-hidden="true">Loading reading time...</span> <p>
<span class="skeleton-text skeleton-tags" aria-hidden="true">Loading tags...</span> <span class="skeleton-text skeleton-reading" aria-hidden="true"
>Loading reading time...</span
>
</p>
<p>
<span class="skeleton-text skeleton-tags" aria-hidden="true"
>Loading tags...</span
>
</p>
</section> </section>
</article> </article>
{/each} {/each}
</div>
<style lang="postcss"> <style lang="postcss">
.columns { p {
display: grid; min-height: 1em;
grid-template-columns: repeat(2, minmax(250px, 1fr)); line-height: 1.2;
min-height: 800px; padding: 0.25rem 0.5rem;
@media (max-width: 1000px) {
grid-template-columns: repeat(2, minmax(250px, 1fr));
} }
@media (max-width: 650px) {
grid-template-columns: minmax(250px, 1fr);
}
gap: 2.5rem;
}
.skeleton { .skeleton {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@ -51,7 +54,6 @@
.skeleton-text { .skeleton-text {
display: block; display: block;
height: 1rem;
margin: 0.5rem 0; margin: 0.5rem 0;
border-radius: 6px; border-radius: 6px;
background: linear-gradient( background: linear-gradient(
@ -62,19 +64,52 @@
); );
background-size: 200% 100%; background-size: 200% 100%;
animation: shimmer 1.2s ease-in-out infinite; animation: shimmer 1.2s ease-in-out infinite;
min-height: 1.25em;
line-height: 1.25em;
padding: 0.25rem 0.5rem;
color: transparent; /* hide placeholder text */
overflow: hidden;
} }
.skeleton-title { .skeleton-title {
height: 1.25rem; height: 1.6em;
width: 80%;
} }
.skeleton-domain { width: 40%; } .skeleton-domain {
.skeleton-reading { width: 55%; } width: 40%;
.skeleton-tags { width: 65%; } height: 1.25em;
}
.skeleton-reading {
width: 55%;
height: 1.25em;
margin-top: 0.25rem;
}
.skeleton-tags {
width: 65%;
height: 1.6em; /* closer to tag chip height */
border-radius: 9999px; /* pill-like to match tags */
}
/* layout tweaks to avoid overlap and match spacing */
.card.skeleton {
align-items: start;
}
.skeleton > section {
display: grid;
gap: 0.5rem;
}
.skeleton > section + section {
margin-top: 0.75rem;
}
@keyframes shimmer { @keyframes shimmer {
0% { background-position: 200% 0; } 0% {
100% { background-position: -200% 0; } background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
} }
</style> </style>

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onNavigate, beforeNavigate } from "$app/navigation"; import { onNavigate, beforeNavigate } from "$app/navigation";
import { LoaderCircle } from "lucide-svelte";
let visible = $state(false); let visible = $state(false);
let progress = $state(0); let progress = $state(0);
@ -57,6 +58,9 @@
{#if visible} {#if visible}
<div class="progress" style="width: {progress}%;"></div> <div class="progress" style="width: {progress}%;"></div>
<div class="loader-container">
<LoaderCircle class="loader-icon" size={20} />
</div>
{/if} {/if}
<style lang="postcss"> <style lang="postcss">
@ -70,4 +74,29 @@
z-index: 50; z-index: 50;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
} }
.loader-container {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 50;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
}
:global(.loader-icon) {
animation: spin 1s linear infinite;
color: var(--lightGrey);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style> </style>

View file

@ -1,6 +1,5 @@
import { PUBLIC_SITE_URL } from '$env/static/public';
import type { ArticlePageLoad } from '$lib/types/article';
import type { MetaTagsProps } from 'svelte-meta-tags'; import type { MetaTagsProps } from 'svelte-meta-tags';
import { PUBLIC_SITE_URL } from '$env/static/public';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch, params, setHeaders, url, parent }) => { export const load: PageServerLoad = async ({ fetch, params, setHeaders, url, parent }) => {

View file

@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import Articles from '$lib/components/Articles.svelte'; import Articles from "$lib/components/Articles.svelte";
import Pagination from '$lib/components/Pagination.svelte'; import Pagination from "$lib/components/Pagination.svelte";
import ArticlesSkeleton from '$lib/components/ArticlesSkeleton.svelte'; import type { PageData } from "./$types";
import type { PageData } from './$types';
interface Props { interface Props {
data: PageData; data: PageData;
@ -16,6 +15,8 @@
let totalArticles: number = $derived(data?.totalArticles || 0); let totalArticles: number = $derived(data?.totalArticles || 0);
let limit: number = $derived(data?.limit || 10); let limit: number = $derived(data?.limit || 10);
let totalPages: number = $derived(data?.totalPages || 1); let totalPages: number = $derived(data?.totalPages || 1);
</script> </script>
<div class="articles-content"> <div class="articles-content">
@ -28,7 +29,9 @@
base="/articles" base="/articles"
/> />
<Articles data={{ articles, totalArticles, classes: ['columns'], compact: false }} /> <Articles
data={{ articles, totalArticles, classes: ["columns"], compact: false }}
/>
<Pagination <Pagination
additionalClasses="bottom-pagination" additionalClasses="bottom-pagination"