mirror of
https://github.com/immich-app/static-pages.git
synced 2026-09-30 13:23:05 +08:00
feat: footer navigation (#788)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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] };
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
+12
@@ -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}
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user