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",
"@playwright/test": "^1.54.2",
"@sveltejs/enhanced-img": "^0.5.1",
"@sveltejs/kit": "^2.28.0",
"@sveltejs/kit": "^2.29.0",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
"@unpic/svelte": "^1.0.0",
"@zerodevx/svelte-img": "^2.1.2",

View file

@ -13,7 +13,7 @@ importers:
version: 2.6.2
'@sveltejs/adapter-node':
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':
specifier: ^0.6.8
version: 0.6.8
@ -52,8 +52,8 @@ importers:
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))
'@sveltejs/kit':
specifier: ^2.28.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))
specifier: ^2.29.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':
specifier: ^5.1.1
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
vite: '>= 5.0.0'
'@sveltejs/kit@2.28.0':
resolution: {integrity: sha512-qrhygwHV5r6JrvCw4gwNqqxYGDi5YbajocxfKgFXmSFpFo8wQobUvsM0OfakN4h+0LEmXtqHRrC6BcyAkOwyoQ==}
'@sveltejs/kit@2.29.0':
resolution: {integrity: sha512-gOynQRBThrtF/RjljB8Oybs9VHVmLbk9q7E7ALJT6ImppJtc/yx3sTGiBV64y+lwmagnBCmEMmJ40CVChGy8lA==}
engines: {node: '>=18.13'}
hasBin: true
peerDependencies:
@ -3303,12 +3303,12 @@ snapshots:
dependencies:
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:
'@rollup/plugin-commonjs': 28.0.2(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)
'@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
'@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:
- 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:
'@standard-schema/spec': 1.0.0
'@sveltejs/acorn-typescript': 1.0.5(acorn@8.15.0)

View file

@ -1,7 +1,10 @@
<script lang="ts">
import type { Article } from '$lib/types/article';
import { ArrowRight } from 'lucide-svelte';
import ExternalLink from './ExternalLink.svelte';
import { ArrowRight } from "lucide-svelte";
import { beforeNavigate, onNavigate } from "$app/navigation";
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 = {
articles: Article[];
@ -11,50 +14,55 @@
};
const { data }: { data: LoadData } = $props();
// Use $derived to maintain reactivity when data prop changes
const articles = $derived(data.articles || []);
const totalArticles = $derived(data.totalArticles || 0);
const compact = $derived(data.compact);
const classes = $derived(data.classes || []);
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>
<section class="articles">
<h2>Favorite Articles</h2>
<div class={classes.join(' ')}>
<!-- {#await data.articles}
{#each Array(6) as _, i (i)}
<article class="card skeleton">
<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} -->
<div class={classes.join(" ")}>
{#if loadingArticles}
<ArticlesSkeleton count={6} />
{:else}
{#each articlesData as article (article.hashed_url)}
<article class="card">
<section>
<h3>
<ExternalLink
textData={{
text: compact ? article.title.substring(0, 50).trim() : article.title,
location: 'left',
text: compact
? article.title.substring(0, 50).trim()
: article.title,
location: "left",
showIcon: true,
}}
linkData={{
href: article.url.toString(),
ariaLabel: `Link to ${article.title}`,
title: `Link to ${article.title}`,
target: '_blank',
target: "_blank",
}}
iconData={{ iconClass: 'center' }}
iconData={{ iconClass: "center" }}
/>
</h3>
<p>{article.domain_name}</p>
@ -70,22 +78,23 @@
</section>
</article>
{/each}
<!-- {:catch error}
<p>There was an error loading the articles.</p>
{/await} -->
{/if}
</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>
<style lang="postcss">
article {
article {
margin: 1.5rem 0;
& p {
margin: 0.25rem 0rem;
}
}
}
.articles {
display: grid;
@ -95,17 +104,17 @@
.columns {
display: grid;
grid-template-columns: repeat(2, minmax(250px, 1fr));
min-height: 800px;
min-height: 800px;
@media (max-width: 1000px) {
grid-template-columns: repeat(2, minmax(250px, 1fr));
}
@media (max-width: 1000px) {
grid-template-columns: repeat(2, minmax(250px, 1fr));
}
@media (max-width: 650px) {
grid-template-columns: minmax(250px, 1fr);
}
@media (max-width: 650px) {
grid-template-columns: minmax(250px, 1fr);
}
gap: 2.5rem;
gap: 2.5rem;
}
.tagsStyles {
@ -137,4 +146,4 @@
font-size: var(--h3);
}
}
</style>
</style>

View file

@ -4,42 +4,45 @@
classes?: string[];
}
let { count = 6, classes = ['columns'] }: Props = $props();
let { count = 6, classes = [""] }: Props = $props();
const placeholders = Array.from({ length: count });
</script>
<div class={classes.join(' ')} role="status" aria-live="polite" aria-busy="true">
{#each placeholders as _, i (i)}
<article class="card skeleton">
<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}
</div>
{#each placeholders as _, i (i)}
<article class={`card skeleton ${classes.join(" ")}`}>
<section>
<h3>
<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>
<p>
<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>
</article>
{/each}
<style lang="postcss">
.columns {
display: grid;
grid-template-columns: repeat(2, minmax(250px, 1fr));
min-height: 800px;
@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;
p {
min-height: 1em;
line-height: 1.2;
padding: 0.25rem 0.5rem;
}
.skeleton {
position: relative;
overflow: hidden;
@ -51,7 +54,6 @@
.skeleton-text {
display: block;
height: 1rem;
margin: 0.5rem 0;
border-radius: 6px;
background: linear-gradient(
@ -62,19 +64,52 @@
);
background-size: 200% 100%;
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 {
height: 1.25rem;
width: 80%;
height: 1.6em;
}
.skeleton-domain { width: 40%; }
.skeleton-reading { width: 55%; }
.skeleton-tags { width: 65%; }
.skeleton-domain {
width: 40%;
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 {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { onNavigate, beforeNavigate } from "$app/navigation";
import { LoaderCircle } from "lucide-svelte";
let visible = $state(false);
let progress = $state(0);
@ -57,6 +58,9 @@
{#if visible}
<div class="progress" style="width: {progress}%;"></div>
<div class="loader-container">
<LoaderCircle class="loader-icon" size={20} />
</div>
{/if}
<style lang="postcss">
@ -70,4 +74,29 @@
z-index: 50;
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>

View file

@ -1,60 +1,59 @@
import { PUBLIC_SITE_URL } from '$env/static/public';
import type { ArticlePageLoad } from '$lib/types/article';
import type { MetaTagsProps } from 'svelte-meta-tags';
import { PUBLIC_SITE_URL } from '$env/static/public';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch, params, setHeaders, url, parent }) => {
const { page } = params;
const { cacheControl } = await parent();
const { page } = params;
const { cacheControl } = await parent();
if (cacheControl?.includes('no-cache')) {
setHeaders({
'cache-control': cacheControl,
});
} else {
setHeaders({
'cache-control': 'max-age=43200', // 12 hours
});
}
if (cacheControl?.includes('no-cache')) {
setHeaders({
'cache-control': cacheControl,
});
} else {
setHeaders({
'cache-control': 'max-age=43200', // 12 hours
});
}
const baseUrl = new URL(url.origin).href || PUBLIC_SITE_URL || 'https://bradleyshellnut.com';
const currentPageUrl = new URL(url.pathname, url.origin).href;
const baseUrl = new URL(url.origin).href || PUBLIC_SITE_URL || 'https://bradleyshellnut.com';
const currentPageUrl = new URL(url.pathname, url.origin).href;
const metaTags: MetaTagsProps = Object.freeze({
title: 'Favorite Articles',
description: 'My favorite articles',
openGraph: {
title: 'Favorite Articles',
description: 'My favorite articles',
url: currentPageUrl,
siteName: 'Bradley Shellnut Personal Website',
type: 'website',
locale: 'en_US',
images: [
{
url: `${baseUrl}og?header=Articles Page ${page} | bradleyshellnut.com&page=My favorite articles`,
alt: `Bradley Shellnut Articles Page ${page}`,
width: 1200,
height: 630,
},
],
},
twitter: {
title: 'Favorite Articles',
description: 'My favorite articles',
card: 'summary_large_image',
image: `${baseUrl}og?header=Articles Page ${page} | bradleyshellnut.com&page=My favorite articles`,
imageAlt: 'Bradley Shellnut Website Logo',
},
url: currentPageUrl,
});
const metaTags: MetaTagsProps = Object.freeze({
title: 'Favorite Articles',
description: 'My favorite articles',
openGraph: {
title: 'Favorite Articles',
description: 'My favorite articles',
url: currentPageUrl,
siteName: 'Bradley Shellnut Personal Website',
type: 'website',
locale: 'en_US',
images: [
{
url: `${baseUrl}og?header=Articles Page ${page} | bradleyshellnut.com&page=My favorite articles`,
alt: `Bradley Shellnut Articles Page ${page}`,
width: 1200,
height: 630,
},
],
},
twitter: {
title: 'Favorite Articles',
description: 'My favorite articles',
card: 'summary_large_image',
image: `${baseUrl}og?header=Articles Page ${page} | bradleyshellnut.com&page=My favorite articles`,
imageAlt: 'Bradley Shellnut Website Logo',
},
url: currentPageUrl,
});
const articlesData = await fetch(`/api/articles?page=${page}`);
const { articles, currentPage } = await articlesData.json();
return {
articles,
currentPage,
metaTagsChild: metaTags,
};
const articlesData = await fetch(`/api/articles?page=${page}`);
const { articles, currentPage } = await articlesData.json();
return {
articles,
currentPage,
metaTagsChild: metaTags,
};
};

View file

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