mirror of
https://github.com/sendevia/website.git
synced 2026-03-06 07:40:50 +08:00
feat: add PrevNext component for blog post navigation
This commit is contained in:
177
.vitepress/theme/components/PrevNext.vue
Normal file
177
.vitepress/theme/components/PrevNext.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useGlobalData } from "../composables/useGlobalData";
|
||||
import { useAllPosts } from "../composables/useAllPosts";
|
||||
|
||||
const { page } = useGlobalData();
|
||||
|
||||
const postsRef = useAllPosts(true);
|
||||
|
||||
function normalize(u: string | undefined | null) {
|
||||
if (!u) return "";
|
||||
|
||||
try {
|
||||
const url = String(u);
|
||||
const withoutOrigin = url.replace(/^https?:\/\/[^/]+/, "");
|
||||
return withoutOrigin.replace(/(?:\.html)?\/?$/, "");
|
||||
} catch (e) {
|
||||
return String(u);
|
||||
}
|
||||
}
|
||||
|
||||
const currentCandidates = computed(() => {
|
||||
const p = page.value as any;
|
||||
const cand: string[] = [];
|
||||
|
||||
["path", "regularPath", "url", "relativePath", "filePath", "_file"].forEach((k) => {
|
||||
if (p && p[k]) cand.push(String(p[k]));
|
||||
});
|
||||
|
||||
if (p && p.frontmatter) {
|
||||
if (p.frontmatter.permalink) cand.push(String(p.frontmatter.permalink));
|
||||
if (p.frontmatter.slug) cand.push(String(p.frontmatter.slug));
|
||||
}
|
||||
|
||||
const filePath = p && (p.filePath || p._file || p.relativePath || "");
|
||||
if (filePath && typeof filePath === "string") {
|
||||
const m = filePath.match(/posts\/(.+?)\.mdx?$/) || filePath.match(/posts\/(.+?)\.md$/);
|
||||
if (m && m[1]) {
|
||||
const name = m[1];
|
||||
cand.push(`/posts/${encodeURIComponent(name)}`);
|
||||
cand.push(`/posts/${encodeURIComponent(name)}.html`);
|
||||
}
|
||||
}
|
||||
|
||||
if (p && p.title) cand.push(String(p.title));
|
||||
|
||||
return Array.from(new Set(cand.map((c) => normalize(c))));
|
||||
});
|
||||
|
||||
const currentIndex = computed(() => {
|
||||
const posts = postsRef.value || [];
|
||||
|
||||
for (let i = 0; i < posts.length; i++) {
|
||||
const post = posts[i];
|
||||
const postNorm = normalize(post.url);
|
||||
|
||||
for (const c of currentCandidates.value) {
|
||||
if (!c) continue;
|
||||
if (postNorm === c) return i;
|
||||
if (postNorm === c + "") return i;
|
||||
}
|
||||
|
||||
const pTitle = (page.value as any)?.title;
|
||||
if (pTitle && post.title && String(post.title) === String(pTitle)) return i;
|
||||
}
|
||||
|
||||
return -1;
|
||||
});
|
||||
|
||||
const prev = computed(() => {
|
||||
const posts = postsRef.value || [];
|
||||
const idx = currentIndex.value;
|
||||
if (idx > 0) return posts[idx - 1];
|
||||
return null;
|
||||
});
|
||||
|
||||
const next = computed(() => {
|
||||
const posts = postsRef.value || [];
|
||||
const idx = currentIndex.value;
|
||||
if (idx >= 0 && idx < posts.length - 1) return posts[idx + 1];
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prev-next">
|
||||
<div class="prev" v-if="prev">
|
||||
<a :href="prev.url">
|
||||
<span class="label">上一篇</span>
|
||||
<span class="title">{{ prev.title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="next" v-if="next">
|
||||
<a :href="next.url">
|
||||
<span class="label">下一篇</span>
|
||||
<span class="title">{{ next.title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "../styles/mixin";
|
||||
|
||||
.prev-next {
|
||||
display: grid;
|
||||
grid-template-columns: 50% 50%;
|
||||
|
||||
margin-block-start: 24px;
|
||||
|
||||
.prev {
|
||||
grid-column: 1 / 2;
|
||||
|
||||
margin-inline-end: 6px;
|
||||
|
||||
text-align: start;
|
||||
|
||||
a {
|
||||
padding-inline-start: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.next {
|
||||
grid-column: 2 / 3;
|
||||
|
||||
margin-inline-start: 6px;
|
||||
|
||||
text-align: end;
|
||||
|
||||
a {
|
||||
padding-inline-end: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.prev,
|
||||
.next {
|
||||
border-radius: var(--md-sys-shape-corner-medium);
|
||||
|
||||
background-color: var(--md-sys-color-surface-container-low);
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
width: 100%;
|
||||
|
||||
padding-block: 12px;
|
||||
|
||||
color: var(--md-sys-color-on-surface);
|
||||
text-decoration: none !important;
|
||||
|
||||
border-radius: var(--md-sys-shape-corner-medium);
|
||||
|
||||
.label {
|
||||
@include mixin.typescale-style("label-medium");
|
||||
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
.title {
|
||||
@include mixin.typescale-style("title-large");
|
||||
|
||||
font-variation-settings: "wght" 600;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@include mixin.focus-ring();
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--md-sys-color-surface-dim);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import Header from "../components/Header.vue";
|
||||
import PageIndicator from "../components/PageIndicator.vue";
|
||||
import PrevNext from "../components/PrevNext.vue";
|
||||
import { onMounted } from "vue";
|
||||
|
||||
function copyAnchorLink(this: HTMLElement) {
|
||||
@@ -9,8 +10,8 @@ function copyAnchorLink(this: HTMLElement) {
|
||||
const fullUrl = `${window.location.origin}${window.location.pathname}${href}`;
|
||||
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(fullUrl);
|
||||
}
|
||||
navigator.clipboard.writeText(fullUrl);
|
||||
}
|
||||
|
||||
const hiddenSpan = anchor.querySelector<HTMLSpanElement>("span.visually-hidden");
|
||||
if (hiddenSpan) {
|
||||
@@ -60,13 +61,13 @@ if (typeof window !== "undefined") {
|
||||
});
|
||||
|
||||
ulCustomBullets();
|
||||
olCountAttributes();
|
||||
olCountAttributes();
|
||||
|
||||
window.addEventListener("resize", ulCustomBullets);
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
ulCustomBullets();
|
||||
olCountAttributes();
|
||||
olCountAttributes();
|
||||
});
|
||||
|
||||
const contentElement = document.querySelector("#article-content");
|
||||
@@ -85,6 +86,7 @@ olCountAttributes();
|
||||
<Header />
|
||||
<section id="article-content">
|
||||
<Content />
|
||||
<PrevNext />
|
||||
</section>
|
||||
<section id="article-indicator">
|
||||
<PageIndicator />
|
||||
@@ -97,6 +99,7 @@ olCountAttributes();
|
||||
section {
|
||||
&#article-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-column: 1 / 10;
|
||||
|
||||
& > div {
|
||||
@@ -106,9 +109,9 @@ section {
|
||||
*[class^="language-"] {
|
||||
position: relative;
|
||||
|
||||
margin-block-end: 12px;
|
||||
margin-inline: 24px;
|
||||
|
||||
margin-block-end: 12px;
|
||||
margin-inline-start: 24px;
|
||||
|
||||
border-radius: var(--md-sys-shape-corner-large-increased);
|
||||
|
||||
overflow: hidden;
|
||||
@@ -143,9 +146,9 @@ margin-block-end: 12px;
|
||||
color: var(--md-ref-palette-neutral90);
|
||||
}
|
||||
|
||||
&:active {
|
||||
&:active {
|
||||
border-radius: var(--md-sys-shape-corner-large);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@include mixin.focus-ring($size: 2, $z-index: 3);
|
||||
@@ -162,26 +165,26 @@ margin-block-end: 12px;
|
||||
margin-block-start: 6px;
|
||||
margin-inline-end: 9px;
|
||||
|
||||
color: var(--md-ref-palette-neutral90);
|
||||
color: var(--md-ref-palette-neutral90);
|
||||
text-transform: uppercase;
|
||||
|
||||
transition: color var(--md-sys-motion-spring-default-effect-duration) var(--md-sys-motion-spring-default-effect);
|
||||
transition: color var(--md-sys-motion-spring-default-effect-duration) var(--md-sys-motion-spring-default-effect);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
pre.shiki {
|
||||
grid-column: 2/3;
|
||||
grid-column: 2/3;
|
||||
grid-row: 1;
|
||||
|
||||
margin: 0px;
|
||||
padding-block: 15px;
|
||||
padding-block: 15px;
|
||||
|
||||
overflow: overlay;
|
||||
z-index: 0;
|
||||
|
||||
code {
|
||||
padding: 0px;
|
||||
}
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
div.line-numbers-wrapper {
|
||||
@@ -216,7 +219,7 @@ padding-block: 15px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
span.lang {
|
||||
span.lang {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
@@ -244,7 +247,7 @@ span.lang {
|
||||
position: relative;
|
||||
|
||||
margin-block-end: 12px;
|
||||
margin-inline: 24px;
|
||||
margin-inline-start: 24px;
|
||||
padding-block: 12px;
|
||||
padding-inline: 12px;
|
||||
|
||||
@@ -410,7 +413,7 @@ span.lang {
|
||||
left: -54px;
|
||||
top: 0px;
|
||||
|
||||
height: 54px;
|
||||
height: 54px;
|
||||
width: 54px;
|
||||
|
||||
color: var(--md-sys-color-on-surface);
|
||||
@@ -506,13 +509,13 @@ height: 54px;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: underline solid;
|
||||
text-decoration: underline solid;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin-inline: 24px;
|
||||
margin-inline-start: 24px;
|
||||
padding-block-start: 12px;
|
||||
padding-inline-start: 24px;
|
||||
padding-inline-start: 24px;
|
||||
|
||||
color: var(--md-sys-color-on-tertiary-container);
|
||||
|
||||
@@ -546,11 +549,11 @@ padding-inline-start: 24px;
|
||||
|
||||
display: inline-block;
|
||||
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
|
||||
padding-block: 24px;
|
||||
|
||||
text-indent: initial;
|
||||
text-indent: initial;
|
||||
vertical-align: baseline;
|
||||
word-break: break-word;
|
||||
|
||||
@@ -587,13 +590,13 @@ padding-inline-start: 24px;
|
||||
|
||||
hr {
|
||||
margin-block: 24px;
|
||||
margin-inline-end: 24px;
|
||||
margin-inline-start: 24px;
|
||||
}
|
||||
|
||||
p {
|
||||
position: relative;
|
||||
|
||||
margin-inline: 24px;
|
||||
margin-inline-start: 24px;
|
||||
padding-block-end: 12px;
|
||||
|
||||
code {
|
||||
@@ -631,7 +634,7 @@ padding-inline-start: 24px;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin-inline: 24px;
|
||||
margin-inline-start: 24px;
|
||||
padding-block-end: 12px;
|
||||
|
||||
&.shiki {
|
||||
@@ -656,7 +659,7 @@ padding-inline-start: 24px;
|
||||
|
||||
table {
|
||||
margin-block-end: 12px;
|
||||
margin-inline: 24px;
|
||||
margin-inline-start: 24px;
|
||||
padding: 0px;
|
||||
|
||||
width: calc(100% - 48px);
|
||||
@@ -664,7 +667,7 @@ padding-inline-start: 24px;
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin-inline: 24px;
|
||||
margin-inline-start: 24px;
|
||||
padding-block-end: 12px;
|
||||
|
||||
li {
|
||||
@@ -689,14 +692,14 @@ padding-inline-start: 24px;
|
||||
}
|
||||
|
||||
ol {
|
||||
li {
|
||||
&::marker {
|
||||
li {
|
||||
&::marker {
|
||||
font-variation-settings: "wght" 700;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-inline: 0px;
|
||||
padding-block: 0px;
|
||||
padding-block: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -740,7 +743,7 @@ padding-block: 0px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1600px) {
|
||||
section {
|
||||
section {
|
||||
&#article-content {
|
||||
grid-column: 1 / 10;
|
||||
}
|
||||
@@ -764,7 +767,7 @@ section {
|
||||
}
|
||||
|
||||
@media screen and (max-width: 840px) {
|
||||
section {
|
||||
section {
|
||||
&#article-content {
|
||||
grid-column: 1 / 7;
|
||||
}
|
||||
@@ -776,7 +779,7 @@ section {
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
section {
|
||||
section {
|
||||
&#article-content {
|
||||
grid-column: 1 / 5;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user