Compare commits

...

2 commits

Author SHA1 Message Date
018ad4ab1e update animation
refactor remark42 components
2026-04-13 22:35:33 +03:00
703a63fffc Added mermaid support 2026-04-13 20:59:08 +03:00
13 changed files with 1893 additions and 18 deletions

View file

@ -2,6 +2,7 @@
import { defineConfig } from 'astro/config';
import sitemap from "@astrojs/sitemap";
import svelte from "@astrojs/svelte";
import rehypeMermaid from "rehype-mermaid";
// https://astro.build/config
import { fileURLToPath } from 'node:url'
@ -18,4 +19,7 @@ export default defineConfig({
"/": "/zh",
},
integrations: [sitemap(), svelte()],
markdown: {
rehypePlugins: [rehypeMermaid],
},
})

1349
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,8 @@
"@astrojs/sitemap": "^3.7.1",
"@astrojs/svelte": "^8.0.4",
"astro": "^6.0.8",
"playwright": "^1.59.1",
"rehype-mermaid": "^3.0.0",
"url": "^0.11.4"
},
"devDependencies": {

View file

@ -0,0 +1,175 @@
---
const { lang = "zh" } = Astro.props;
const host = import.meta.env.PUBLIC_REMARK42_HOST;
const siteId = import.meta.env.PUBLIC_REMARK42_SITE_ID;
const max = 10;
const heading = lang === "zh" ? "最新评论" : "Latest comments";
---
<section
class="latest-comments"
data-lang={lang}
data-host={host}
data-site-id={siteId}
data-max={max}
>
<h2>{heading}</h2>
<div class="comments-loading" aria-hidden="true">
<div class="loading-card"></div>
<div class="loading-card"></div>
<div class="loading-card"></div>
</div>
<div class="latest-comments-list" aria-live="polite"></div>
</section>
<script is:inline data-astro-rerun>
function escapeHtml(str) {
return String(str ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function formatTime(input, lang) {
try {
const date = new Date(input);
return new Intl.DateTimeFormat(lang === "zh" ? "zh-CN" : "en", {
year: "numeric",
month: "short",
day: "numeric",
}).format(date);
} catch {
return "";
}
}
function rewritePostUrl(url, lang) {
if (!url) return "#";
if (url.startsWith("/posts/")) {
return `/${lang}${url}`;
}
try {
const u = new URL(url, window.location.origin);
if (u.pathname.startsWith("/posts/")) {
return `/${lang}${u.pathname}${u.search}${u.hash}`;
}
return u.pathname + u.search + u.hash;
} catch {
return url;
}
}
function getAvatar(item) {
return (
item?.user?.picture || item?.user?.photo || item?.user?.avatar || ""
);
}
function getUserName(item, lang) {
return item?.user?.name || (lang === "zh" ? "匿名用户" : "Anonymous");
}
function renderComments(root, comments, lang) {
const list = root.querySelector(".latest-comments-list");
const loading = root.querySelector(".comments-loading");
if (!list || !loading) return;
if (!Array.isArray(comments) || comments.length === 0) {
loading.style.display = "none";
list.innerHTML = `<p class="latest-comments-empty">${
lang === "zh" ? "还没有评论" : "No comments yet"
}</p>`;
return;
}
list.innerHTML = comments
.map((item) => {
const author = escapeHtml(getUserName(item, lang));
const avatar = getAvatar(item);
const safeAvatar = escapeHtml(avatar || "");
const title = escapeHtml(
item?.title ||
(lang === "zh" ? "未命名文章" : "Untitled post"),
);
const href = rewritePostUrl(item?.locator?.url || "", lang);
const html = item?.text || "";
const time = formatTime(item?.time, lang);
const avatarHtml = avatar
? `<img class="comment-avatar-img" src="${safeAvatar}" alt="${author}" loading="lazy" referrerpolicy="no-referrer" />`
: `<div class="comment-avatar-fallback" aria-hidden="true">${author.slice(0, 1).toUpperCase()}</div>`;
return `
<article class="comment-card">
<div class="comment-card-body">
<div class="comment-card-info">
<div class="comment-avatar">
${avatarHtml}
</div>
<div class="comment-meta">
<div class="comment-author-row">
<span class="comment-author">@${author}</span>
${time ? `<span class="comment-time">${escapeHtml(time)}</span>` : ""}
</div>
</div>
</div>
<div class="comment-card-title">
<a class="comment-title-link" href="${href}">${title}</a>
</div>
<div class="comment-card-text">${html}</div>
</div>
</article>
`;
})
.join("");
loading.style.display = "none";
}
async function loadLatestComments(section) {
if (!section) return;
const host = section.dataset.host;
const siteId = section.dataset.siteId;
const max = section.dataset.max || "10";
const lang = section.dataset.lang || "zh";
const list = section.querySelector(".latest-comments-list");
const loading = section.querySelector(".comments-loading");
if (!host || !siteId || !list || !loading) return;
loading.style.display = "";
list.innerHTML = "";
const url = `${host}/api/v1/last/${encodeURIComponent(max)}?site=${encodeURIComponent(siteId)}`;
try {
const res = await fetch(url, { credentials: "omit" });
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
renderComments(section, data, lang);
} catch (err) {
loading.style.display = "none";
list.innerHTML = `<p class="latest-comments-empty">${
lang === "zh"
? "最新评论加载失败"
: "Failed to load latest comments"
}</p>`;
console.error("latest comments load failed:", err);
}
}
document.querySelectorAll(".latest-comments").forEach(loadLatestComments);
</script>

View file

@ -7,7 +7,7 @@ const t = getTranslations(lang);
<nav class="site-nav" aria-label="Site navigation">
<div class="site-nav-desktop">
<a href={`/${lang}/`} data-astro-reload>{t.nav.home}</a>
<a href={`/${lang}/`}>{t.nav.home}</a>
<a href={`/${lang}/about/`}>{t.nav.about}</a>
<a href={`/${lang}/tags/`}>{t.nav.tags}</a>
<a href={`/${lang}/timeline/`}>{t.nav.timeline}</a>
@ -17,7 +17,7 @@ const t = getTranslations(lang);
<details class="site-nav-mobile">
<summary>☰ {lang === "zh" ? "导航栏" : "Menu"}</summary>
<div class="dropdown-menu">
<a href={`/${lang}/`} data-astro-reload>{t.nav.home}</a>
<a href={`/${lang}/`}>{t.nav.home}</a>
<a href={`/${lang}/about/`}>{t.nav.about}</a>
<a href={`/${lang}/tags/`}>{t.nav.tags}</a>
<a href={`/${lang}/timeline/`}>{t.nav.timeline}</a>

View file

@ -11,10 +11,11 @@ const tags = data.tags;
<li class="post-card">
<div class="post-link">
<div class="post-text">
<a href={data.url} class="post-title">
<a href={data.url} class="post-title-link">
<h2 class="post-title" transition:name={`post-title-${data.slug}`}>
{data.title}
</h2>
</a>
<a href={data.url} class="post-description">
{data.description}
</a>
@ -133,7 +134,13 @@ const tags = data.tags;
min-height: calc(1.6em * 4);
}
.post-title-link {
text-decoration: none;
display: block;
}
.post-title {
margin: 0;
font-family:
system-ui,
-apple-system,
@ -231,8 +238,11 @@ const tags = data.tags;
min-height: auto;
}
.post-title {
.post-title-link {
grid-area: title;
}
.post-title {
-webkit-line-clamp: 2;
}

View file

@ -38,6 +38,7 @@ const latestPosts = filteredPosts.slice(0, 5);
date={formattedDate}
img={post.data.image.url}
tags={post.data.tags}
slug={slug}
/>
);
})

View file

@ -0,0 +1,82 @@
---
const { slug } = Astro.props;
const pagePath = `/posts/${slug}/`;
const host = import.meta.env.PUBLIC_REMARK42_HOST;
const siteId = import.meta.env.PUBLIC_REMARK42_SITE_ID;
---
<div id="remark42"></div>
<script define:vars={{ pagePath, host, siteId }} is:inline data-astro-rerun>
function getTheme() {
return document.documentElement.classList.contains("dark")
? "dark"
: "light";
}
function setRemarkConfig() {
window.remark_config = {
host,
site_id: siteId,
url: pagePath,
page_id: pagePath,
theme: getTheme(),
components: ["embed"],
show_rss_subscription: false,
};
}
function ensureScript() {
return new Promise((resolve, reject) => {
const existing = document.getElementById("remark42-loader");
if (existing) {
if (window.REMARK42) {
resolve(true);
return;
}
existing.addEventListener("load", () => resolve(true), {
once: true,
});
existing.addEventListener("error", reject, { once: true });
return;
}
const script = document.createElement("script");
script.id = "remark42-loader";
script.defer = true;
if ("noModule" in script) {
script.type = "module";
script.src = `${host}/web/embed.mjs`;
} else {
script.async = true;
script.src = `${host}/web/embed.js`;
}
script.onload = () => resolve(true);
script.onerror = reject;
document.head.appendChild(script);
});
}
async function mountRemark42() {
const node = document.getElementById("remark42");
if (!node) return;
setRemarkConfig();
await ensureScript();
if (window.REMARK42) {
if (typeof window.REMARK42.destroy === "function") {
window.REMARK42.destroy();
}
if (typeof window.REMARK42.createInstance === "function") {
node.innerHTML = "";
window.REMARK42.createInstance(window.remark_config);
}
}
}
mountRemark42();
</script>

18
src/global.d.ts vendored Normal file
View file

@ -0,0 +1,18 @@
declare global {
interface Window {
REMARK42?: {
changeTheme?: (theme: string) => void;
};
remark_config?: {
host: string;
site_id: string;
components?: string[];
show_rss_subscription?: boolean;
theme?: string;
url?: string;
page_id?: string;
};
}
}
export { };

View file

@ -14,6 +14,7 @@ const {
<!doctype html>
<html lang="en">
<head>
<ClientRouter />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<SEO title={pageTitle} description={description} image={image} />

View file

@ -1,6 +1,6 @@
---
import BaseLayout from "./BaseLayout.astro";
import Remark42Embed from "@/components/remark42-embed.svelte";
import Remark42Embed from "@/components/Remark42Embed.astro";
import { getLangFromUrl, getTranslations } from "@/i18n";
import "@/styles/global.css";
@ -39,7 +39,7 @@ const t = getTranslations(lang);
<article class="post-article">
<div class="post-header">
<div class="post-meta">
<h1 class="post-title">{frontmatter.title}</h1>
<h1 class="post-title" transition:name={`post-title-${postId}`} >{frontmatter.title}</h1>
<p class="description"><em>{frontmatter.description}</em></p>
<p class="meta-line">
{t.post.publishedOn}: {frontmatter.pubDate.toLocaleDateString()}
@ -71,7 +71,7 @@ const t = getTranslations(lang);
</div>
<h2>{comments}</h2>
<Remark42Embed slug={postId} client:load />
<Remark42Embed slug={postId} />
</article>
</BaseLayout>

View file

@ -2,6 +2,7 @@
import BaseLayout from "@/layouts/BaseLayout.astro";
import PostList from "@/components/Posts/PostList.astro";
import Remark42LatestComments from "@/components/remark42-latest-comments.svelte";
import LatestComments from "@/components/LatestComments.astro";
import { getLangFromUrl, getTranslations } from "@/i18n";
import "@/styles/global.css";
@ -50,7 +51,8 @@ const pageTitle = t.home.title;
<div class="section-divider"></div>
<Remark42LatestComments lang={lang} client:load />
<!-- <Remark42LatestComments lang={lang} client:load /> -->
<LatestComments />
</BaseLayout>
<style>

View file

@ -164,3 +164,250 @@ img {
height: auto;
display: block;
}
.latest-comments {
margin-top: 1.5rem;
}
.latest-comments h2 {
margin-bottom: 0.8rem;
}
.loading-card {
height: 92px;
margin: 0 0 14px 0;
padding: 0.9rem 1rem;
border: 1px dashed #aeb8c2;
background: linear-gradient(90deg,
rgba(160, 175, 190, 0.06) 25%,
rgba(160, 175, 190, 0.16) 50%,
rgba(160, 175, 190, 0.06) 75%);
background-size: 200% 100%;
animation: shimmer 1.2s infinite linear;
}
@keyframes shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
.latest-comments-list {
display: block;
}
.comment-card {
display: block;
margin: 0 0 14px 0;
padding: 0.6rem 1rem;
border: 1.5px dashed #aeb8c2;
background-color: #f3f5f7;
}
.comment-card:last-child {
margin-bottom: 0;
}
.comment-card-body {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
grid-template-areas:
"info title"
"text title";
gap: 0.2rem 1rem;
align-items: start;
}
.comment-card-info {
grid-area: info;
display: flex;
align-items: center;
gap: 0.7rem;
min-width: 0;
}
.comment-avatar {
width: 35px;
height: 35px;
flex-shrink: 0;
}
.comment-avatar-img,
.comment-avatar-fallback {
width: 35px;
height: 35px;
display: block;
border-radius: 50%;
object-fit: cover;
}
.comment-avatar-img {
border: 1px dashed #aeb8c2;
background: #f3f5f7;
}
.comment-avatar-fallback {
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #aeb8c2;
background: rgba(160, 175, 190, 0.12);
color: #5e6b77;
font-weight: 700;
font-size: 1rem;
user-select: none;
}
.comment-meta {
min-width: 0;
flex: 1;
}
.comment-author-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.6rem;
min-width: 0;
line-height: 1.3;
font-style: italic;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
"Noto Sans CJK SC",
"Source Han Sans SC",
sans-serif;
}
.comment-author {
font-weight: 400;
font-size: 0.8rem;
color: gray;
overflow-wrap: anywhere;
word-break: break-word;
}
.comment-time {
color: #7a7a7a;
font-size: 0.9rem;
}
.comment-card-title {
grid-area: title;
text-align: right;
min-width: 0;
}
.comment-title-link {
color: #6f8090;
text-decoration: none;
font-size: 1.1rem;
font-weight: 700;
line-height: 1.45;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.comment-title-link:hover {
text-decoration: underline;
}
.comment-card-text {
grid-area: text;
margin: 0;
color: #555;
line-height: 1.5;
font-size: 0.93rem;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
"Noto Sans CJK SC",
"Source Han Sans SC",
sans-serif;
}
.comment-card-text :global(p),
.comment-card-text p {
margin: 0;
}
.comment-card-text :global(img),
.comment-card-text img {
max-width: 100%;
height: auto;
}
.latest-comments-empty {
color: #777;
font-style: italic;
}
html.dark .latest-comments .comment-card,
.dark .latest-comments .comment-card {
border-color: #7f8c97;
background-color: #252525;
}
html.dark .comment-title-link {
color: #c8d2dc;
}
html.dark .comment-card-text {
color: #d3d7db;
}
html.dark .comment-time {
color: #a8b0b7;
}
html.dark .latest-comments .comment-avatar-fallback,
.dark .latest-comments .comment-avatar-fallback {
border-color: #7f8c97;
background-color: #3a444d;
color: #dbe3ea;
}
:global(.dark) .comment-avatar-fallback {
background: rgba(180, 190, 200, 0.12);
color: #dbe3ea;
}
@media (max-width: 640px) {
.comment-card-body {
grid-template-columns: 1fr;
grid-template-areas:
"info"
"title"
"text";
}
.comment-card-title {
text-align: left;
}
.comment-title-link {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}