diff --git a/public/images/recommendation/grant-riggle.jpeg b/public/images/recommendation/grant-riggle.jpeg new file mode 100644 index 00000000..2885f3d5 Binary files /dev/null and b/public/images/recommendation/grant-riggle.jpeg differ diff --git a/src/components/AccessibleDialog.vue b/src/components/AccessibleDialog.vue index 79c69a50..e3bc951f 100644 --- a/src/components/AccessibleDialog.vue +++ b/src/components/AccessibleDialog.vue @@ -149,15 +149,3 @@ onBeforeUnmount(() => { } }); - - diff --git a/src/css/support/blog.css b/src/css/support/blog.css index c1533dae..21c6ee6e 100644 --- a/src/css/support/blog.css +++ b/src/css/support/blog.css @@ -21,6 +21,10 @@ @apply after:right-0 after:top-0 after:bottom-0; } +.blog-side-nav-icon { + @apply size-5 flex-none; +} + .blog-side-nav-router-link-a-resting { @apply text-slate-200 hover:text-fuchsia-500 hover:after:bg-fuchsia-600; @apply dark:text-slate-700 dark:hover:text-teal-600 dark:hover:after:bg-teal-600; @@ -205,3 +209,14 @@ @apply shadow-sm dark:shadow-none rounded-md; max-width: 100%; } + +/* --- transitions --- */ +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.3s ease; +} + +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} diff --git a/src/pages/AboutPage.vue b/src/pages/AboutPage.vue index 5f6b1198..bd1a4fd6 100644 --- a/src/pages/AboutPage.vue +++ b/src/pages/AboutPage.vue @@ -52,12 +52,14 @@

Let's Connect

-

- I’m happy to connect by - email - to discuss projects and ideas. While I’m not always available for freelance or long-term work, please don’t hesitate to reach out anytime. -

- + +

+ I'm happy to connect by + email + to discuss projects and ideas. While I'm not always available for freelance or long-term work, please don't hesitate to reach out anytime. +

+ +
@@ -69,8 +71,10 @@ diff --git a/src/pages/HomePage.vue b/src/pages/HomePage.vue index 896b8c0d..79b47491 100644 --- a/src/pages/HomePage.vue +++ b/src/pages/HomePage.vue @@ -26,12 +26,10 @@ diff --git a/src/pages/PostPage.vue b/src/pages/PostPage.vue index c045e762..16f572b3 100644 --- a/src/pages/PostPage.vue +++ b/src/pages/PostPage.vue @@ -27,102 +27,107 @@ - - -
- -
-
- -
- {{ date().format(new Date(post.published_at)) }} - · - {{ getReadingTime(post.content) }} + + + +
+ +
+
+ +
+ {{ date().format(new Date(post.published_at)) }} + · + {{ getReadingTime(post.content) }} +
+ +
- - + +

{{ post.title }}

+ + +
+ + +
+

{{ post.excerpt }}

+ +
-

{{ post.title }}

- -
- -
-

{{ post.excerpt }}

- -
-
-
- -

We couldn't load this post.

+ + +

We couldn't load this post.

+ diff --git a/src/pages/ProjectsPage.vue b/src/pages/ProjectsPage.vue index 8da75f39..0138f47e 100644 --- a/src/pages/ProjectsPage.vue +++ b/src/pages/ProjectsPage.vue @@ -21,7 +21,7 @@

- Over the years, I’ve built and shared command-line tools and frameworks to tackle real engineering challenges—complete with clear docs and automated + Over the years, I've built and shared command-line tools and frameworks to tackle real engineering challenges—complete with clear docs and automated tests—and partnered with banks, insurers, and fintech to deliver custom software that balances performance, security, and scalability.

@@ -30,17 +30,20 @@

Open Source / Client Projects

-
- - +
+ +
+ +
+
+ +
+

Projects will be added soon. Check back later!

+
@@ -52,8 +55,10 @@ diff --git a/src/pages/ResumePage.vue b/src/pages/ResumePage.vue index cf7e406f..1e6826dc 100644 --- a/src/pages/ResumePage.vue +++ b/src/pages/ResumePage.vue @@ -30,23 +30,25 @@
-
- -
-
-
- - + +
+
-
- - +
+
+ + +
+
+ + +
+
+ + +
-
- - -
-
+
@@ -59,8 +61,10 @@
diff --git a/src/pages/TagPostsPage.vue b/src/pages/TagPostsPage.vue index 6e1ea3c4..65369a6b 100644 --- a/src/pages/TagPostsPage.vue +++ b/src/pages/TagPostsPage.vue @@ -3,52 +3,69 @@
+
-
+
+
+
-
-
-

- - Tag -

-

- Posts tagged {{ formattedTagLabel }} -

-

- {{ summaryMessage }} -

+
+ +

Topics & Tags Explorer

+ + +
+
+
+ + Back + + + + +
+
+

+ Post tags help you quickly find the themes, tools or ideas you care about most across the blog. Browse through the tags below to group related posts + together and dive deeper into specific topics, from high-level concepts to hands-on guides. +

+

+ {{ summaryMessage }} +

+
+
+ +
+

Articles

+ +
+ +
+
+ {{ summaryMessage }} +
+
+ {{ summaryMessage }} +
+
+ +
+
+
- - ← Back to home - -
- -
-
- -
-
- {{ summaryMessage }} -
-
- {{ summaryMessage }} -
-
- -
-
+
+
-
    +
    • -
      -
      -
      - -
      -
      -
      {{ item.person.full_name }}
      -
      {{ item.person.company }}
      -
      - {{ item.person.designation }} -
      +
      +
      + +
      +
      +
      {{ item.person.full_name }}
      +
      {{ item.person.company }}
      +
      + {{ item.person.designation }}
      -
      -
      +
      {{ item.relation }}
      {{ item.formattedDate }}
      -
      +
      +
    @@ -36,12 +34,14 @@ diff --git a/src/partials/SideNavPartial.vue b/src/partials/SideNavPartial.vue index 2dbb82ae..c17c505f 100644 --- a/src/partials/SideNavPartial.vue +++ b/src/partials/SideNavPartial.vue @@ -17,7 +17,7 @@ Home - + @@ -30,7 +30,7 @@ About - + Projects - + @@ -58,7 +58,7 @@ Resume - + @@ -67,10 +67,10 @@
- @@ -102,6 +107,8 @@ import { Social, type SocialNavLink } from '../support/social.ts'; const currentRoute: RouteLocationNormalizedLoaded = useRoute(); const socialService = new Social(); const socialNavLinks = ref([]); +const isLoadingSocialLinks = ref(true); +const socialSkeletonCount = 2; const isHome = computed(() => { // `path` excludes query strings, ensuring the avatar is hidden on the homepage @@ -109,12 +116,22 @@ const isHome = computed(() => { return currentRoute.path === '/'; }); +const shouldDisplaySocialSection = computed(() => { + return isLoadingSocialLinks.value || socialNavLinks.value.length > 0; +}); + function bindIconClassFor(isActive: boolean): string { return isActive ? 'blog-side-nav-router-link-a-active' : 'blog-side-nav-router-link-a-resting'; } onMounted(async () => { - const social = await socialService.fetch(); - socialNavLinks.value = socialService.buildNavLinks(social, ['github', 'linkedin']); + isLoadingSocialLinks.value = true; + + try { + const social = await socialService.fetch(); + socialNavLinks.value = socialService.buildNavLinks(social, ['github', 'linkedin']); + } finally { + isLoadingSocialLinks.value = false; + } }); diff --git a/src/partials/TalksPartial.vue b/src/partials/TalksPartial.vue index 6e5e0cdf..1428354a 100644 --- a/src/partials/TalksPartial.vue +++ b/src/partials/TalksPartial.vue @@ -3,41 +3,45 @@

Popular Talks

-
+ -
+ diff --git a/src/support/tags.ts b/src/support/tags.ts index e9f85ed2..694b66fa 100644 --- a/src/support/tags.ts +++ b/src/support/tags.ts @@ -11,12 +11,12 @@ export class Tags { static normalizeParam(value: unknown): string { if (typeof value === 'string') { - return value.trim(); + return value.trim().toLowerCase(); } if (Array.isArray(value)) { - const [first] = value as Array; - return typeof first === 'string' ? first.trim() : ''; + const [first] = value; + return typeof first === 'string' ? first.trim().toLowerCase() : ''; } return ''; @@ -31,10 +31,16 @@ export class Tags { return `#${normalized.toUpperCase()}`; } + private static formatParam(tag: string): string { + return tag.trim().toLowerCase(); + } + static routeFor(tag: string): RouteLocationRaw { + const param = this.formatParam(tag); + return { name: 'TagPosts', - params: { tag }, + params: { tag: param }, }; } diff --git a/tests/pages/PostPage.test.ts b/tests/pages/PostPage.test.ts index a5130c3d..aecf32e3 100644 --- a/tests/pages/PostPage.test.ts +++ b/tests/pages/PostPage.test.ts @@ -4,6 +4,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { defineComponent, ref } from 'vue'; import type { PostResponse } from '@api/response/index.ts'; +const createTag = () => { + const tag = faker.lorem.word(); + const normalized = `${tag.charAt(0).toUpperCase()}${tag.slice(1)}`; + + return { + uuid: faker.string.uuid(), + name: normalized, + description: faker.lorem.sentence(), + }; +}; + const post: PostResponse = { uuid: faker.string.uuid(), slug: faker.lorem.slug(), @@ -25,18 +36,7 @@ const post: PostResponse = { profile_picture_url: faker.image.avatar(), }, categories: [], - tags: [ - { - uuid: faker.string.uuid(), - name: faker.lorem.word(), - description: faker.lorem.sentence(), - }, - { - uuid: faker.string.uuid(), - name: faker.lorem.word(), - description: faker.lorem.sentence(), - }, - ], + tags: [createTag(), createTag()], }; const getPost = vi.fn<[], Promise>(() => Promise.resolve(post)); @@ -148,7 +148,7 @@ describe('PostPage', () => { }); const firstTag = tags[0]; const firstTagLink = firstTag.findComponent(RouterLinkStub); - expect(firstTagLink.props('to')).toEqual({ name: 'TagPosts', params: { tag: post.tags[0]?.name } }); + expect(firstTagLink.props('to')).toEqual({ name: 'TagPosts', params: { tag: post.tags[0]!.name.toLowerCase() } }); }); it('populates the search term when a tag is clicked', async () => { diff --git a/tests/pages/ProjectsPage.test.ts b/tests/pages/ProjectsPage.test.ts index bd56bbec..84db616a 100644 --- a/tests/pages/ProjectsPage.test.ts +++ b/tests/pages/ProjectsPage.test.ts @@ -75,7 +75,7 @@ describe('ProjectsPage', () => { expect(wrapper.text()).toContain(projects[0].title); }); - it('renders static skeletons when no projects are returned', async () => { + it('renders empty state message when no projects are returned', async () => { getProjects.mockResolvedValueOnce({ version: '1.0.0', data: [] }); const wrapper = mount(ProjectsPage, { @@ -96,10 +96,9 @@ describe('ProjectsPage', () => { await nextTick(); const skeletons = wrapper.findAllComponents(ProjectCardSkeletonPartial); - expect(skeletons).toHaveLength(4); - skeletons.forEach((skeleton) => { - expect(skeleton.classes()).not.toContain('animate-pulse'); - }); + expect(skeletons).toHaveLength(0); + + expect(wrapper.text()).toContain('Projects will be added soon. Check back later!'); const skeletonGrid = wrapper.find('[data-testid="projects-skeleton-grid"]'); expect(skeletonGrid.classes()).toContain('min-h-[25rem]'); diff --git a/tests/partials/EducationPartial.test.ts b/tests/partials/EducationPartial.test.ts index 171ef95b..530ee349 100644 --- a/tests/partials/EducationPartial.test.ts +++ b/tests/partials/EducationPartial.test.ts @@ -5,8 +5,9 @@ import EducationPartial from '@partials/EducationPartial.vue'; import type { EducationResponse } from '@api/response/index.ts'; const renderMarkdown = vi.hoisted(() => vi.fn(() => '

hi

')); +const initializeHighlighter = vi.hoisted(() => vi.fn(() => Promise.resolve())); -vi.mock('@/support/markdown.ts', () => ({ renderMarkdown })); +vi.mock('@/support/markdown.ts', () => ({ renderMarkdown, initializeHighlighter })); const education: EducationResponse[] = [ { diff --git a/tests/partials/RecommendationPartial.test.ts b/tests/partials/RecommendationPartial.test.ts index a0d5cae9..9bcde1ca 100644 --- a/tests/partials/RecommendationPartial.test.ts +++ b/tests/partials/RecommendationPartial.test.ts @@ -5,8 +5,16 @@ import RecommendationPartial from '@partials/RecommendationPartial.vue'; import type { RecommendationsResponse } from '@api/response/index.ts'; const renderMarkdown = vi.hoisted(() => vi.fn(() => '

great

')); +const initializeHighlighter = vi.hoisted(() => vi.fn(() => Promise.resolve())); -vi.mock('@/support/markdown.ts', () => ({ renderMarkdown })); +vi.mock('@/support/markdown.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + renderMarkdown, + initializeHighlighter, + }; +}); vi.mock('@/public.ts', () => ({ image: (p: string) => `/img/${p}`, date: () => ({ format: () => 'now' }), diff --git a/tests/support/tags.test.ts b/tests/support/tags.test.ts index c789c96b..17394f2c 100644 --- a/tests/support/tags.test.ts +++ b/tests/support/tags.test.ts @@ -12,6 +12,10 @@ describe('Tags.normalizeParam', () => { expect(Tags.normalizeParam(' vue ')).toBe('vue'); }); + it('normalizes tag casing to lowercase', () => { + expect(Tags.normalizeParam(' ReAcT ')).toBe('react'); + }); + it('returns the first trimmed string when provided an array parameter', () => { expect(Tags.normalizeParam([' php ', 'extra'])).toBe('php'); });