image component to handle loading and error states

This commit is contained in:
Benjamin Palko 2026-01-23 17:26:01 -05:00
parent 7b0880b53b
commit 52d46f3584
6 changed files with 64 additions and 22 deletions

View file

@ -7,9 +7,11 @@
"dependencies": { "dependencies": {
"@humanspeak/svelte-markdown": "^0.8.7", "@humanspeak/svelte-markdown": "^0.8.7",
"@lucide/svelte": "^0.516.0", "@lucide/svelte": "^0.516.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"nodemailer": "^6.10.1", "nodemailer": "^6.10.1",
"pino": "^9.6.0", "pino": "^9.6.0",
"tailwind-merge": "^3.4.0",
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.8", "@eslint/compat": "^1.2.8",
@ -695,6 +697,8 @@
"svelte-eslint-parser": ["svelte-eslint-parser@1.1.3", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-DUc/z/vk+AFVoxGv54+BOBFqUrmUgNg2gSO2YqrE3OL6ro19/0azPmQj/4wN3s9RxuF5l7G0162q/Ddk4LJhZA=="], "svelte-eslint-parser": ["svelte-eslint-parser@1.1.3", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-DUc/z/vk+AFVoxGv54+BOBFqUrmUgNg2gSO2YqrE3OL6ro19/0azPmQj/4wN3s9RxuF5l7G0162q/Ddk4LJhZA=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwindcss": ["tailwindcss@4.1.4", "", {}, "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A=="], "tailwindcss": ["tailwindcss@4.1.4", "", {}, "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A=="],
"tapable": ["tapable@2.2.1", "", {}, "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ=="], "tapable": ["tapable@2.2.1", "", {}, "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ=="],

View file

@ -44,8 +44,10 @@
"dependencies": { "dependencies": {
"@humanspeak/svelte-markdown": "^0.8.7", "@humanspeak/svelte-markdown": "^0.8.7",
"@lucide/svelte": "^0.516.0", "@lucide/svelte": "^0.516.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"nodemailer": "^6.10.1", "nodemailer": "^6.10.1",
"pino": "^9.6.0" "pino": "^9.6.0",
"tailwind-merge": "^3.4.0"
} }
} }

View file

@ -1,17 +1,20 @@
<script lang="ts"> <script lang="ts">
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Link } from '@lucide/svelte'; import { Link } from '@lucide/svelte';
import Image from './Image.svelte';
interface Props { interface Props {
id: string; id: string;
img: string; img: {
'img-alt': string | null; url: string;
alt?: string;
};
title: string; title: string;
published: string; published: string;
topics: string[]; topics: string[];
} }
let { id, img, 'img-alt': alt, title, published, topics }: Props = $props(); let { id, img, title, published, topics }: Props = $props();
let blogLink = $derived(`/blog/${id}`); let blogLink = $derived(`/blog/${id}`);
</script> </script>
@ -23,14 +26,14 @@
<div class="badge badge-soft badge-info">{topic}</div> <div class="badge badge-soft badge-info">{topic}</div>
{/each} {/each}
</div> </div>
<div class="absolute z-10 opacity-0 transition-opacity group-hover:opacity-75"> <div class="absolute z-20 opacity-0 transition-opacity group-hover:opacity-75">
<Link size={32} /> <Link size={32} />
</div> </div>
<a href={blogLink}> <a href={blogLink} class="h-full w-full">
<img <Image
class="z-0 aspect-4/2 w-full object-cover transition-transform group-hover:scale-110 group-hover:blur-xs" class="z-10 aspect-4/2 w-full object-cover transition-transform group-hover:scale-110 group-hover:blur-xs"
src={img} src={img.url}
{alt} alt={img.alt}
/> />
</a> </a>
</figure> </figure>

View file

@ -1,16 +1,46 @@
<script lang="ts"> <script lang="ts">
import { ImageOff } from '@lucide/svelte';
import clsx from 'clsx';
import { onMount } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
interface Props { interface Props {
src: string; src: string;
alt: string; alt?: string;
caption?: string; class?: ClassValue;
} }
let { src, alt, caption }: Props = $props(); let { src, alt, class: className }: Props = $props();
let loading = $state(true);
let failed = $state(false);
onMount(() => {});
onMount(() => {
const img = new Image();
img.src = src;
loading = true;
img.onload = () => {
loading = false;
};
img.onerror = () => {
loading = false;
failed = true;
};
});
</script> </script>
<figure class="flex flex-col"> {#if loading}
<img class="aspect-4/2 w-full object-cover" {src} {alt} /> <div class={twMerge('skeleton rounded-none', clsx(className))}></div>
{#if caption} {:else if failed}
<figcaption class="mt-2 text-center text-sm text-gray-500 italic">{caption}</figcaption> <div class={twMerge('skeleton text-base-content relative rounded-none', clsx(className))}>
<ImageOff
class="absolute top-[50%] left-[50%] h-1/4 w-1/4 translate-x-[-50%] translate-y-[-50%] opacity-20"
/>
</div>
{:else}
<img class={clsx(className)} {src} {alt} onload={() => (loading = false)} />
{/if} {/if}
</figure>

View file

@ -14,8 +14,7 @@
{#each posts as post (post.slug)} {#each posts as post (post.slug)}
<BlogCard <BlogCard
id={post.slug} id={post.slug}
img={post.image.url} img={post.image}
img-alt={post.image.alt}
title={post.title} title={post.title}
published={post.date} published={post.date}
topics={post.categories} topics={post.categories}

View file

@ -11,7 +11,11 @@
<p class="text-gray-500">{dayjs(data.meta.date).format('DD MMMM YYYY')}</p> <p class="text-gray-500">{dayjs(data.meta.date).format('DD MMMM YYYY')}</p>
<h1 class="text-4xl font-light">{data.meta.title}</h1> <h1 class="text-4xl font-light">{data.meta.title}</h1>
</div> </div>
<Image src={data.meta.image.url} alt={data.meta.image.alt} /> <Image
src={data.meta.image.url}
alt={data.meta.image.alt}
class="aspect-4/2 w-full object-cover"
/>
<div class="w-full max-w-5xl px-4"> <div class="w-full max-w-5xl px-4">
<data.content class="content" /> <data.content class="content" />
</div> </div>