feat: footer navigation (#788)

This commit is contained in:
Jason Rasmussen
2026-09-04 18:04:24 -04:00
committed by GitHub
parent e748709956
commit 106ee588df
10 changed files with 119 additions and 3 deletions
@@ -1,17 +1,18 @@
<script lang="ts">
import { asBlogPost, blogMetadata, BlogType } from '$lib';
import { asBlogPost, blogMetadata, BlogType, getPostNeighbors, posts } from '$lib';
import BlogTypeBadge from '$lib/components/BlogTypeBadge.svelte';
import {
Heading,
Icon,
Link,
Markdown,
PageFooterNavigation,
SiteMetadata,
TableOfContents,
Text,
type TableOfContentsItem,
} from '@immich/ui';
import type { SerializedPost } from '$lib/types';
import type { BlogPost, SerializedPost } from '$lib/types';
import { mdiChevronRight } from '@mdi/js';
import { DateTime } from 'luxon';
import type { Snippet } from 'svelte';
@@ -30,6 +31,9 @@
...post.headers,
...(postScript ? [{ id: 'faqs', text: 'FAQs', level: 2 }] : []),
]);
const { previous, next } = $derived(getPostNeighbors(posts, post));
const asNavigationLink = (item?: BlogPost) => (item ? { title: item.title, href: item.url } : undefined);
</script>
<SiteMetadata site={blogMetadata} page={{ title, description }} />
@@ -92,6 +96,8 @@
<Markdown.Heading level={2} id="faqs">FAQs</Markdown.Heading>
{@render postScript?.()}
{/if}
<PageFooterNavigation previous={asNavigationLink(previous)} next={asNavigationLink(next)} />
</article>
{#if headers.length > 1}
+19 -1
View File
@@ -1,4 +1,4 @@
import { BlogType, posts } from '$lib';
import { BlogType, getPostNeighbors, posts } from '$lib';
import { readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, test } from 'vitest';
@@ -50,3 +50,21 @@ describe('posts', () => {
expect(ids.size).toBe(posts.length);
});
});
describe(getPostNeighbors.name, () => {
test('returns the older post as previous and the newer post as next', () => {
expect(getPostNeighbors(posts, posts[1])).toEqual({ previous: posts[2], next: posts[0] });
});
test('omits the neighbor that does not exist', () => {
expect(getPostNeighbors(posts, posts[0]).next).toBeUndefined();
expect(getPostNeighbors(posts, posts.at(-1)!).previous).toBeUndefined();
});
test('returns no neighbors for an unknown post', () => {
expect(getPostNeighbors(posts, { ...posts[0], url: '/blog/does-not-exist' })).toEqual({
previous: undefined,
next: undefined,
});
});
});
+9
View File
@@ -62,3 +62,12 @@ export const posts: BlogPost[] = getDocs<SerializedPost | ClientDoc>()
.filter((doc) => isPost(doc))
.map((doc) => asBlogPost(doc))
.toSorted((a, b) => b.publishedAt.valueOf() - a.publishedAt.valueOf());
export const getPostNeighbors = (allPosts: BlogPost[], post: SerializedPost | BlogPost) => {
const index = allPosts.findIndex((item) => item.url === post.url);
if (index === -1) {
return { previous: undefined, next: undefined };
}
return { previous: allPosts[index + 1], next: allPosts[index - 1] };
};
+3
View File
@@ -67,6 +67,8 @@ import {
mdiMessageAlertOutline,
mdiNumeric,
mdiOpenInNew,
mdiPageNext,
mdiPageNextOutline,
mdiPalette,
mdiPanVertical,
mdiPartyPopper,
@@ -187,6 +189,7 @@ export const components: ComponentItem[] = [
{ name: 'MultiSelect', icon: mdiCheckboxMultipleMarkedOutline, activeIcon: mdiCheckboxMultipleMarked },
{ name: 'Navbar', icon: mdiMenu },
{ name: 'NumberInput', icon: mdiNumeric },
{ name: 'PageFooterNavigation', icon: mdiPageNextOutline, activeIcon: mdiPageNext },
{ name: 'PasswordInput', icon: mdiFormTextboxPassword },
{ name: 'PinInput', icon: mdiLockSmart },
{ name: 'ProgressBar', icon: mdiProgressHelper },
@@ -0,0 +1,10 @@
<script lang="ts">
import ComponentExamples from '$lib/components/ComponentExamples.svelte';
import ComponentPage from '$lib/components/ComponentPage.svelte';
import BasicExample from './BasicExample.svelte';
import basicExample from './BasicExample.svelte?raw';
</script>
<ComponentPage name="PageFooterNavigation" description="Link to the previous and next page in a section">
<ComponentExamples examples={[{ title: 'Basic', code: basicExample, component: BasicExample }]} />
</ComponentPage>
@@ -0,0 +1,12 @@
<script lang="ts">
import { PageFooterNavigation, Text } from '@immich/ui';
</script>
<Text>Both links are optional, so the first and last page of a section only render the direction that exists.</Text>
<PageFooterNavigation
previous={{ title: 'Installation', href: '#installation' }}
next={{ title: 'Configuration', href: '#configuration' }}
/>
<PageFooterNavigation next={{ title: 'Getting started', href: '#getting-started' }} />
@@ -0,0 +1,43 @@
<script lang="ts">
import Icon from '$lib/components/Icon/Icon.svelte';
import Text from '$lib/components/Text/Text.svelte';
import { t } from '$lib/services/translation.svelte.js';
import type { PageFooterNavigationProps } from '$lib/types.js';
import { cleanClass } from '$lib/utilities/internal.js';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
const { previous, next, translations, class: className, ...restProps }: PageFooterNavigationProps = $props();
const label = $derived(t('page_footer_navigation_label', translations));
</script>
{#if previous || next}
<nav class={cleanClass('mt-8 grid gap-4 border-t pt-8 sm:grid-cols-2', className)} aria-label={label} {...restProps}>
{#if previous}
<a
href={previous.href}
class="hover:border-primary hover:text-primary flex items-center gap-2 rounded-xl border p-4 transition-colors"
>
<Icon icon={mdiChevronLeft} size="1.5rem" />
<div class="min-w-0">
<Text color="muted" size="tiny">{t('navigate_previous', translations)}</Text>
<Text fontWeight="semi-bold" class="truncate">{previous.title}</Text>
</div>
</a>
{:else}
<div></div>
{/if}
{#if next}
<a
href={next.href}
class="hover:border-primary hover:text-primary flex items-center justify-end gap-2 rounded-xl border p-4 text-end transition-colors"
>
<div class="min-w-0">
<Text color="muted" size="tiny">{t('navigate_next', translations)}</Text>
<Text fontWeight="semi-bold" class="truncate">{next.title}</Text>
</div>
<Icon icon={mdiChevronRight} size="1.5rem" />
</a>
{/if}
</nav>
{/if}
+1
View File
@@ -76,6 +76,7 @@ export { default as MultiSelect } from '$lib/components/MultiSelect/MultiSelect.
export { default as NavbarGroup } from '$lib/components/Navbar/NavbarGroup.svelte';
export { default as NavbarItem } from '$lib/components/Navbar/NavbarItem.svelte';
export { default as NumberInput } from '$lib/components/NumberInput/NumberInput.svelte';
export { default as PageFooterNavigation } from '$lib/components/PageFooterNavigation/PageFooterNavigation.svelte';
export { default as PasswordInput } from '$lib/components/PasswordInput/PasswordInput.svelte';
export { default as PinInput } from '$lib/components/PinInput/PinInput.svelte';
export { default as ProgressBar } from '$lib/components/ProgressBar/ProgressBar.svelte';
@@ -40,6 +40,9 @@ const defaultTranslations = {
navigate_next: 'Next',
navigate_previous: 'Previous',
// page footer navigation
page_footer_navigation_label: 'Page navigation',
// table of contents
table_of_contents_title: 'On this page',
+11
View File
@@ -418,6 +418,17 @@ export type TableOfContentsProps = {
translations?: TranslationProps<'table_of_contents_title'>;
} & HTMLAttributes<HTMLElement>;
export type PageFooterNavigationLink = {
title: string;
href: string;
};
export type PageFooterNavigationProps = {
previous?: PageFooterNavigationLink;
next?: PageFooterNavigationLink;
translations?: TranslationProps<'page_footer_navigation_label' | 'navigate_previous' | 'navigate_next'>;
} & HTMLAttributes<HTMLElement>;
export type BreadcrumbsProps = {
separator?: IconLike | { text: string };
items: BreadcrumbItem[];