Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/pages/AboutPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ import WidgetSocialPartial from '@partials/WidgetSocialPartial.vue';
import WidgetSkillsPartial from '@partials/WidgetSkillsPartial.vue';
import WidgetSkillsSkeletonPartial from '@partials/WidgetSkillsSkeletonPartial.vue';
import AboutConnectSkeletonPartial from '@partials/AboutConnectSkeletonPartial.vue';
import CoverImageLoader from '@/components/CoverImageLoader.vue';
import CoverImageLoader from '@/partials/CoverImageLoader.vue';
import { useSeo, SITE_NAME, ABOUT_IMAGE, siteUrlFor, buildKeywords, PERSON_JSON_LD } from '@/support/seo';

import { useApiStore } from '@api/store.ts';
Expand Down
2 changes: 1 addition & 1 deletion src/pages/PostPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ import { siteUrlFor, useSeoFromPost } from '@/support/seo';
import WidgetSponsorPartial from '@partials/WidgetSponsorPartial.vue';
import { onMounted, ref, computed, watch, nextTick, watchEffect } from 'vue';
import { initializeHighlighter, renderMarkdown } from '@/support/markdown.ts';
import CoverImageLoader from '@/components/CoverImageLoader.vue';
import CoverImageLoader from '@/partials/CoverImageLoader.vue';

// --- Component
const route = useRoute();
Expand Down
87 changes: 25 additions & 62 deletions src/partials/ArticleItemPartial.vue
Original file line number Diff line number Diff line change
@@ -1,41 +1,33 @@
<template>
<article v-if="item" class="py-5 border-b border-slate-100 dark:border-slate-800">
<div class="flex items-start">
<router-link
v-lazy-link
class="relative block aspect-square w-20 sm:w-28 mr-4 sm:mr-6 overflow-hidden rounded-lg bg-slate-200/80 dark:bg-slate-800/80 flex-shrink-0 cursor-pointer shadow-sm ring-1 ring-inset ring-slate-200/70 dark:ring-slate-700/70"
:class="isImageError ? 'animate-none' : showSkeleton ? 'animate-pulse' : 'animate-none'"
:to="{ name: 'PostDetail', params: { slug: item.slug } }"
>
<img
v-if="!isImageError"
class="absolute inset-0 w-full h-full object-cover transition-opacity duration-300"
:class="isImageLoaded ? 'opacity-100' : 'opacity-0'"
<router-link v-lazy-link class="relative block mr-4 sm:mr-6 flex-shrink-0 cursor-pointer" :to="{ name: 'PostDetail', params: { slug: item.slug } }">
<CoverImageLoader
class="block aspect-square w-20 sm:w-28 overflow-hidden rounded-lg bg-slate-200/80 dark:bg-slate-800/80 shadow-sm ring-1 ring-inset ring-slate-200/70 dark:ring-slate-700/70"
:src="item.cover_image_url"
width="112"
height="112"
:alt="item.title"
decoding="async"
:width="112"
:height="112"
loading="lazy"
@load="handleImageLoad"
@error="handleImageError"
/>
<div v-if="showSkeleton" class="absolute inset-0 flex items-center justify-center">
<svg
v-if="isImageError"
class="w-6 h-6 text-slate-400 dark:text-slate-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4.5A1.5 1.5 0 0 1 4.5 3h15A1.5 1.5 0 0 1 21 4.5v15a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 19.5v-15Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="m3 14.25 3.955-3.955a2.25 2.25 0 0 1 3.182 0L15 15.75" />
<path stroke-linecap="round" stroke-linejoin="round" d="m13.5 12 1.955-1.955a2.25 2.25 0 0 1 3.182 0L21 13.5" />
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 8.25h.008v.008H8.25z" />
</svg>
</div>
fetchpriority="low"
>
<template #skeleton="{ isError }">
<svg
v-if="isError"
class="w-6 h-6 text-slate-400 dark:text-slate-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4.5A1.5 1.5 0 0 1 4.5 3h15A1.5 1.5 0 0 1 21 4.5v15a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 19.5v-15Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="m3 14.25 3.955-3.955a2.25 2.25 0 0 1 3.182 0L15 15.75" />
<path stroke-linecap="round" stroke-linejoin="round" d="m13.5 12 1.955-1.955a2.25 2.25 0 0 1 3.182 0L21 13.5" />
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 8.25h.008v.008H8.25z" />
</svg>
</template>
</CoverImageLoader>
</router-link>
<div>
<div class="text-xs text-slate-700 uppercase mb-1 dark:text-slate-500">
Expand Down Expand Up @@ -71,40 +63,11 @@
</template>

<script setup lang="ts">
import CoverImageLoader from '@/partials/CoverImageLoader.vue';
import { date } from '@/public.ts';
import { computed, ref, watch } from 'vue';
import type { PostResponse } from '@api/response/index.ts';

const props = defineProps<{

Check warning on line 70 in src/partials/ArticleItemPartial.vue

View workflow job for this annotation

GitHub Actions / format

'props' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 70 in src/partials/ArticleItemPartial.vue

View workflow job for this annotation

GitHub Actions / format

'props' is assigned a value but never used. Allowed unused vars must match /^_/u
item: PostResponse;
}>();

type ImageStatus = 'loading' | 'loaded' | 'error';

const imageStatus = ref<ImageStatus>('loading');

const handleImageLoad = () => {
imageStatus.value = 'loaded';
};

const handleImageError = () => {
imageStatus.value = 'error';
};

const isImageError = computed(() => imageStatus.value === 'error');
const isImageLoaded = computed(() => imageStatus.value === 'loaded');
const showSkeleton = computed(() => imageStatus.value !== 'loaded');

watch(
() => props.item?.cover_image_url,
(newSrc) => {
if (!newSrc) {
imageStatus.value = 'error';
return;
}

imageStatus.value = 'loading';
},
{ immediate: true },
);
</script>
14 changes: 8 additions & 6 deletions src/partials/ArticlesListPartial.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@
</ul>

<!-- Articles list -->
<div v-if="isLoading" aria-busy="true">
<ArticleItemSkeletonPartial v-for="skeleton in skeletonCount" :key="`article-skeleton-${skeleton}`" />
<div class="min-h-[24rem]">
<div v-if="isLoading" aria-busy="true">
<ArticleItemSkeletonPartial v-for="skeleton in skeletonCount" :key="`article-skeleton-${skeleton}`" />
</div>
<div v-else-if="items.length > 0">
<ArticleItemPartial v-for="item in items" :key="item.uuid" :item="item" />
</div>
<p v-else class="text-slate-500 dark:text-slate-400 py-8">No articles found.</p>
</div>
<div v-else-if="items.length > 0">
<ArticleItemPartial v-for="item in items" :key="item.uuid" :item="item" />
</div>
<p v-else class="text-slate-500 dark:text-slate-400 py-8">No articles found.</p>
</section>
</template>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<template>
<div class="relative overflow-hidden rounded-2xl bg-slate-200/80 dark:bg-slate-800/80 shadow-sm ring-1 ring-inset ring-slate-200/70 dark:ring-slate-700/70" :class="[animationClass]">
<img
v-if="!isError && hasSource"
v-if="!isError"
class="absolute inset-0 w-full h-full object-cover transition-opacity duration-300"
:class="isLoaded ? 'opacity-100' : 'opacity-0'"
:src="src"
:src="resolvedSrc"
:alt="alt"
:width="width"
:height="height"
Expand All @@ -30,6 +30,9 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';

const placeholderCoverImage =
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="120" height="120" fill="none"%3E%3Crect width="120" height="120" rx="24" fill="%23e5e7eb" /%3E%3C/svg%3E';

const props = withDefaults(
defineProps<{
src?: string;
Expand All @@ -56,7 +59,13 @@ type ImageStatus = 'loading' | 'loaded' | 'error';

const imageStatus = ref<ImageStatus>('loading');

const hasSource = computed(() => Boolean(props.src));
const resolvedSrc = computed(() => {
if (!props.src) {
return placeholderCoverImage;
}

return props.src;
});

const isLoaded = computed(() => imageStatus.value === 'loaded');
const isError = computed(() => imageStatus.value === 'error');
Expand Down Expand Up @@ -84,7 +93,7 @@ watch(
() => props.src,
(newSrc, oldSrc) => {
if (!newSrc) {
imageStatus.value = 'error';
imageStatus.value = 'loaded';
return;
}

Expand Down
20 changes: 19 additions & 1 deletion tests/components/CoverImageLoader.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import CoverImageLoader from '@/components/CoverImageLoader.vue';
import CoverImageLoader from '@/partials/CoverImageLoader.vue';

describe('CoverImageLoader', () => {
it('shows skeleton until the image loads', async () => {
Expand Down Expand Up @@ -44,4 +44,22 @@ describe('CoverImageLoader', () => {
expect(wrapper.find('svg').exists()).toBe(true);
expect(wrapper.find('.absolute.inset-0.flex').exists()).toBe(true);
});

it('renders the built-in placeholder when no source is provided', () => {
const wrapper = mount(CoverImageLoader, {
props: {
alt: 'Placeholder image',
},
});

const container = wrapper.find('div.relative');
expect(container.classes()).not.toContain('animate-pulse');

const image = wrapper.find('img');
expect(image.exists()).toBe(true);
expect(image.attributes('src')).toContain('data:image/svg+xml');
expect(image.classes()).toContain('opacity-100');

expect(wrapper.find('.absolute.inset-0.flex').exists()).toBe(false);
});
});
27 changes: 26 additions & 1 deletion tests/partials/ArticleItemPartial.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils';
import { faker } from '@faker-js/faker';
import { describe, it, expect, vi } from 'vitest';
import ArticleItemPartial from '@partials/ArticleItemPartial.vue';
import CoverImageLoader from '@partials/CoverImageLoader.vue';
import type { PostResponse } from '@api/response/index.ts';

vi.mock('@/public.ts', () => ({
Expand Down Expand Up @@ -40,6 +41,30 @@ describe('ArticleItemPartial', () => {
});
expect(wrapper.text()).toContain('formatted');
expect(wrapper.text()).toContain(item.title);
expect(wrapper.find('img').attributes('src')).toBe(item.cover_image_url);

const coverLoader = wrapper.findComponent(CoverImageLoader);
expect(coverLoader.exists()).toBe(true);
expect(coverLoader.props('src')).toBe(item.cover_image_url);
expect(coverLoader.props('alt')).toBe(item.title);
});

it('relies on the cover loader placeholder when no image url is provided', () => {
const wrapper = mount(ArticleItemPartial, {
props: {
item: {
...item,
cover_image_url: undefined as unknown as string,
} as PostResponse,
},
global: { stubs: { RouterLink: { template: '<a><slot /></a>' } } },
});

const coverLoader = wrapper.findComponent(CoverImageLoader);
expect(coverLoader.exists()).toBe(true);
expect(coverLoader.props('src')).toBe('');

const placeholderImage = coverLoader.find('img');
expect(placeholderImage.exists()).toBe(true);
expect(placeholderImage.attributes('src')).toContain('data:image/svg+xml');
});
});
Loading