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 .github/workflows/format-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: 22.18.0
node-version: 22.12.0
cache: 'npm'
cache-dependency-path: package-lock.json

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/format.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: 22.18.0
node-version: 22.12.0
cache: 'npm'
cache-dependency-path: package-lock.json

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.18.0
node-version: 22.12.0
cache: 'npm'
- run: npm ci --legacy-peer-deps
- run: npm test
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ dist/
.idea/
dist-ssr/
node_modules/
coverage/

# --- [Caddy]: mtls
caddy/mtls/*.*
Expand Down
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
22.18.0
22.12.0
1 change: 1 addition & 0 deletions aliases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const aliases: AliasOptions = [
{ find: '@partials', replacement: path.resolve(__dirname, './src/partials') },
{ find: '@stores', replacement: path.resolve(__dirname, './src/stores') },
{ find: '@api', replacement: path.resolve(__dirname, './src/stores/api') },
{ find: '@support', replacement: path.resolve(__dirname, './src/support') },
];

export default aliases;
51 changes: 47 additions & 4 deletions src/pages/PostPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<div class="max-w-[700px]">
<!-- Back -->
<div class="mb-3">
<router-link
<RouterLink
v-lazy-link
class="inline-flex text-fuchsia-500 dark:text-slate-500 dark:hover:text-teal-600 rounded-full border border-slate-200 dark:border-slate-800 dark:bg-linear-to-t dark:from-slate-800 dark:to-slate-800/30"
to="/"
Expand All @@ -24,7 +24,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="34" height="34">
<path class="fill-current" d="m16.414 17 3.293 3.293-1.414 1.414L13.586 17l4.707-4.707 1.414 1.414z" />
</svg>
</router-link>
</RouterLink>
</div>

<PostPageSkeletonPartial v-if="isLoading" class="min-h-[25rem]" />
Expand Down Expand Up @@ -91,11 +91,33 @@
</ul>
</div>
<h1 id="post-top" class="h1 font-aspekta mb-4">{{ post.title }}</h1>
<nav
v-if="post.tags?.length"
class="mt-6 text-xs font-semibold uppercase tracking-wide text-slate-600 dark:text-slate-300"
aria-label="Post tags"
data-testid="post-tags"
>
<ul class="flex flex-wrap items-center gap-y-1">
<li v-for="(tag, index) in post.tags" :key="tag.uuid" class="flex items-center">
<RouterLink
:to="Tags.routeFor(tag.name)"
data-testid="post-tag"
class="transition-colors hover:text-fuchsia-500 dark:hover:text-teal-500"
@click="handleTagClick(tag.name)"
>
{{ Tags.formatLabel(tag.name) }}
</RouterLink>
<span v-if="index < post.tags.length - 1" class="mx-2 text-slate-400 dark:text-slate-600" aria-hidden="true" data-testid="post-tag-separator">
/
</span>
</li>
</ul>
</nav>
</header>
<!-- Post content -->
<div class="text-slate-500 dark:text-slate-400 space-y-8">
<p>{{ post.excerpt }}</p>
<CoverImageLoader class="w-full aspect-[16/9]" :src="post.cover_image_url ?? ''" :alt="post.title" :width="692" :height="390" />
<CoverImageLoader class="w-full aspect-[16/9]" :src="post.cover_image_url || ''" :alt="post.title" :width="692" :height="390" />
<div ref="postContainer" class="post-markdown" v-html="htmlContent"></div>
</div>
</article>
Expand Down Expand Up @@ -126,7 +148,7 @@

<script setup lang="ts">
import DOMPurify from 'dompurify';
import { useRoute } from 'vue-router';
import { RouterLink, useRoute } from 'vue-router';
import { useApiStore } from '@api/store.ts';
import { useDarkMode } from '@/dark-mode.ts';
import highlight from 'highlight.js/lib/core';
Expand All @@ -138,6 +160,7 @@ import PostPageSkeletonPartial from '@partials/PostPageSkeletonPartial.vue';
import SideNavPartial from '@partials/SideNavPartial.vue';
import type { PostResponse } from '@api/response/index.ts';
import { siteUrlFor, useSeoFromPost } from '@/support/seo';
import { Tags } from '@/support/tags.ts';
import WidgetSponsorPartial from '@partials/WidgetSponsorPartial.vue';
import WidgetSocialPartial from '@partials/WidgetSocialPartial.vue';
import BackToTopLink from '@partials/BackToTopLink.vue';
Expand All @@ -164,6 +187,22 @@ const htmlContent = computed(() => {
return '';
});

const searchInput = ref<HTMLInputElement | null>(null);

const handleTagClick = (tagName: string) => {
const label = Tags.formatLabel(tagName);
apiStore.setSearchTerm(label);

const input = searchInput.value;
if (!input) {
return;
}

input.value = label;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.focus();
};

const xURLFor = (post: PostResponse) => {
return `https://x.com/intent/tweet?url=${fullURLFor(post)}&text=${post.title}`;
};
Expand Down Expand Up @@ -220,6 +259,10 @@ watch(htmlContent, async (newContent) => {
});

onMounted(async () => {
if (typeof document !== 'undefined') {
searchInput.value = document.getElementById('search') as HTMLInputElement | null;
}

await initializeHighlighter(highlight);

try {
Expand Down
2 changes: 1 addition & 1 deletion src/pages/ResumePage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ const navigationItems = [
{ href: '#recommendations', text: 'Recommendations' },
] as const;

const resumeSectionMinHeights = Heights.resumeSectionMinHeights();
const _resumeSectionMinHeights = Heights.resumeSectionMinHeights();
const resumeSectionHeights = Heights.resumeSectionHeights();
const resumeSectionsTotalHeight = Heights.resumeSectionsTotalHeight();

Expand Down
181 changes: 181 additions & 0 deletions src/pages/TagPostsPage.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<template>
<div class="max-w-7xl mx-auto">
<div class="min-h-screen flex">
<SideNavPartial />

<main class="grow overflow-hidden px-6">
<div id="tag-posts-top" class="w-full h-full max-w-[1072px] mx-auto flex flex-col">
<HeaderPartial />

<div class="grow md:flex space-y-8 md:space-y-0 md:space-x-8 pt-12 md:pt-16 pb-16 md:pb-20">
<div class="grow">
<div class="max-w-[700px]" data-testid="tag-posts">
<div class="flex flex-col md:flex-row md:items-baseline md:justify-between gap-4 mb-8">
<div>
<p class="text-xs uppercase text-slate-500">
<span class="text-fuchsia-500 dark:text-teal-600">—</span>
Tag
</p>
<h1 class="font-aspekta text-2xl md:text-3xl font-[650] text-slate-700 dark:text-slate-200 mt-1">
Posts tagged <span class="text-fuchsia-500 dark:text-teal-500">{{ formattedTagLabel }}</span>
</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-2" data-testid="tag-posts-summary">
{{ summaryMessage }}
</p>
</div>
<RouterLink
v-lazy-link
class="inline-flex items-center text-sm font-medium text-slate-500 hover:text-fuchsia-500 dark:hover:text-teal-500 transition duration-150 ease-in-out"
to="/"
>
← Back to home
</RouterLink>
</div>

<div role="status">
<div v-if="isLoading" class="space-y-5" data-testid="tag-posts-skeleton">
<ArticleItemSkeletonPartial v-for="skeleton in skeletonCount" :key="`tag-post-skeleton-${skeleton}`" />
</div>
<div v-else-if="hasError" class="py-8 text-slate-500 dark:text-slate-400" data-testid="tag-posts-error">
{{ summaryMessage }}
</div>
<div v-else-if="posts.length === 0" class="py-8 text-slate-500 dark:text-slate-400" data-testid="tag-posts-empty">
{{ summaryMessage }}
</div>
<div v-else class="space-y-5" data-testid="tag-posts-list">
<ArticleItemPartial v-for="post in posts" :key="post.uuid" :item="post" />
</div>
</div>
</div>
</div>

<aside class="md:w-[240px] lg:w-[300px] shrink-0">
<div class="space-y-6">
<WidgetSocialPartial />
<WidgetSponsorPartial />
</div>
</aside>
</div>

<div class="flex justify-end pt-10 mb-10">
<BackToTopLink target="#tag-posts-top" />
</div>

<FooterPartial />
</div>
</main>
</div>
</div>
</template>

<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute, RouterLink } from 'vue-router';
import HeaderPartial from '@partials/HeaderPartial.vue';
import SideNavPartial from '@partials/SideNavPartial.vue';
import FooterPartial from '@partials/FooterPartial.vue';
import ArticleItemPartial from '@partials/ArticleItemPartial.vue';
import ArticleItemSkeletonPartial from '@partials/ArticleItemSkeletonPartial.vue';
import WidgetSponsorPartial from '@partials/WidgetSponsorPartial.vue';
import WidgetSocialPartial from '@partials/WidgetSocialPartial.vue';
import BackToTopLink from '@partials/BackToTopLink.vue';
import { useApiStore } from '@api/store.ts';
import { debugError } from '@api/http-error.ts';
import type { PostResponse, PostsCollectionResponse } from '@api/response/index.ts';
import { SITE_NAME, buildKeywords, siteUrlFor, useSeo } from '@/support/seo';
import { Tags } from '@/support/tags.ts';

const DEFAULT_SKELETON_COUNT = 3;
const apiStore = useApiStore();
const route = useRoute();

const posts = ref<PostResponse[]>([]);
const skeletonCount = ref(DEFAULT_SKELETON_COUNT);
const isLoading = ref(false);
const hasError = ref(false);
let lastRequestId = 0;

const normalizedTag = computed(() => Tags.normalizeParam(route.params.tag));

const formattedTagLabel = computed(() => Tags.formatLabel(normalizedTag.value));

const summaryMessage = computed(() =>
Tags.summaryFor(normalizedTag.value, {
isLoading: isLoading.value,
hasError: hasError.value,
postCount: posts.value.length,
}),
);

const seoOptions = computed(() => {
const tag = normalizedTag.value;

if (!tag) {
return {
title: 'Tagged posts',
description: `Explore tagged articles on ${SITE_NAME}.`,
url: siteUrlFor('/tags'),
};
}

const encodedTag = encodeURIComponent(tag);
const label = formattedTagLabel.value;

return {
title: `Posts tagged ${label}`,
description: `Explore articles tagged ${label} on ${SITE_NAME}.`,
keywords: buildKeywords(tag, `${tag} posts`, `${tag} articles`),
url: siteUrlFor(`/tags/${encodedTag}`),
};
});

useSeo(seoOptions);

const fetchPosts = async (tagName: string) => {
const requestId = ++lastRequestId;
hasError.value = false;

if (!tagName) {
posts.value = [];
skeletonCount.value = DEFAULT_SKELETON_COUNT;
isLoading.value = false;
return;
}

const previousPosts = posts.value;
skeletonCount.value = previousPosts.length > 0 ? previousPosts.length : DEFAULT_SKELETON_COUNT;
isLoading.value = true;

try {
const collection: PostsCollectionResponse = await apiStore.getPosts({ tag: tagName });

if (requestId !== lastRequestId) {
return;
}

posts.value = (collection.data ?? []) as PostResponse[];
} catch (error) {
debugError(error);

if (requestId !== lastRequestId) {
return;
}

hasError.value = true;
posts.value = [];
} finally {
if (requestId === lastRequestId) {
skeletonCount.value = posts.value.length > 0 ? posts.value.length : DEFAULT_SKELETON_COUNT;
isLoading.value = false;
}
}
};

watch(
normalizedTag,
(newTag) => {
fetchPosts(newTag);
},
{ immediate: true },
);
</script>
17 changes: 9 additions & 8 deletions src/partials/ArticleItemPartial.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<article v-if="item" class="py-5 border-b border-slate-100 dark:border-slate-800 group">
<div class="flex items-start">
<router-link
<RouterLink
v-lazy-link
class="relative block mr-4 sm:mr-6 flex-shrink-0 cursor-pointer grayscale-[65%] transition duration-300 ease-out group-hover:grayscale-0 group-focus-within:grayscale-0"
:to="{ name: 'PostDetail', params: { slug: item.slug } }"
Expand Down Expand Up @@ -32,21 +32,21 @@
</svg>
</template>
</CoverImageLoader>
</router-link>
</RouterLink>
<div>
<div class="text-xs text-fuchsia-500 uppercase mb-1 dark:text-teal-500">
{{ date().format(new Date(item.published_at)) }}
</div>
<h3 class="text-slate-700 font-aspekta text-lg font-[650] mb-1 dark:text-slate-300">
<router-link v-lazy-link :class="titleLinkClass" :to="{ name: 'PostDetail', params: { slug: item.slug } }">
<RouterLink v-lazy-link :class="titleLinkClass" :to="{ name: 'PostDetail', params: { slug: item.slug } }">
{{ item.title }}
</router-link>
</RouterLink>
</h3>
<div class="flex">
<router-link v-lazy-link class="grow text-sm text-slate-500 dark:text-slate-600" :to="{ name: 'PostDetail', params: { slug: item.slug } }">
<RouterLink v-lazy-link class="grow text-sm text-slate-500 dark:text-slate-600" :to="{ name: 'PostDetail', params: { slug: item.slug } }">
{{ item.excerpt }}
</router-link>
<router-link
</RouterLink>
<RouterLink
v-lazy-link
class="hidden lg:flex shrink-0 text-fuchsia-500 dark:text-teal-500 items-center justify-center w-12 group"
:to="{ name: 'PostDetail', params: { slug: item.slug } }"
Expand All @@ -55,14 +55,15 @@
<svg class="fill-current group-hover:translate-x-2 duration-150 ease-in-out" xmlns="http://www.w3.org/2000/svg" width="14" height="12">
<path d="M9.586 5 6.293 1.707 7.707.293 13.414 6l-5.707 5.707-1.414-1.414L9.586 7H0V5h9.586Z" />
</svg>
</router-link>
</RouterLink>
</div>
</div>
</div>
</article>
</template>

<script setup lang="ts">
import { RouterLink } from 'vue-router';
import CoverImageLoader from '@components/CoverImageLoader.vue';
import { date } from '@/public.ts';
import type { PostResponse } from '@api/response/index.ts';
Expand Down
Loading