Compare commits

...

14 commits

Author SHA1 Message Date
github-actions[bot]
af13518a2b Merge remote-tracking branch 'origin/master' into blog-content 2026-04-13 21:11:10 +00:00
c1db660259 Merge branch 'dev'
Some checks are pending
Sync and Deploy Astro Blog / sync-blog-content (push) Waiting to run
Sync and Deploy Astro Blog / build-and-deploy (push) Blocked by required conditions
Sync and Deploy Astro Blog / manual-deploy (push) Waiting to run
2026-04-14 00:10:56 +03:00
2f279848b9 update comment display 2026-04-14 00:10:41 +03:00
github-actions[bot]
a1099e2243 Merge remote-tracking branch 'origin/master' into blog-content 2026-04-13 20:48:53 +00:00
ClovertaTheTrilobita
e12bcc0b24
Merge pull request #13 from ClovertaTheTrilobita/dev
Refactore Remark42 components & Added animation
2026-04-13 23:48:44 +03:00
8dee31ca16 Added spinner 2026-04-13 23:47:23 +03:00
9c8c0e186f refactor floating actions 2026-04-13 23:24:35 +03:00
74b3b723cf update latest posts num 2026-04-13 23:21:08 +03:00
2b2222c740 update timeline animation 2026-04-13 23:19:40 +03:00
a06f3d524f chore: deleted uneeded files 2026-04-13 23:03:50 +03:00
fdd55fb068 refactored counter 2026-04-13 23:02:21 +03:00
07fbc4b731 feat(latest comments): added reply to 2026-04-13 22:57:37 +03:00
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
20 changed files with 2417 additions and 611 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,224 @@
<div class="floating-actions">
<button
id="theme-toggle-fab"
aria-label="Toggle theme"
title="Toggle theme"
>
<svg
class="theme-toggle-icon"
viewBox="0 0 24 24"
width="20"
height="20"
aria-hidden="true"
>
<path
class="sun"
fill-rule="evenodd"
d="M12 17.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zm0 1.5a7 7 0 1 0 0-14 7 7 0 0 0 0 14zm12-7a.8.8 0 0 1-.8.8h-2.4a.8.8 0 0 1 0-1.6h2.4a.8.8 0 0 1 .8.8zM4 12a.8.8 0 0 1-.8.8H.8a.8.8 0 0 1 0-1.6h2.5a.8.8 0 0 1 .8.8zm16.5-8.5a.8.8 0 0 1 0 1l-1.8 1.8a.8.8 0 0 1-1-1l1.7-1.8a.8.8 0 0 1 1 0zM6.3 17.7a.8.8 0 0 1 0 1l-1.7 1.8a.8.8 0 1 1-1-1l1.7-1.8a.8.8 0 0 1 1 0zM12 0a.8.8 0 0 1 .8.8v2.5a.8.8 0 0 1-1.6 0V.8A.8.8 0 0 1 12 0zm0 20a.8.8 0 0 1 .8.8v2.4a.8.8 0 0 1-1.6 0v-2.4a.8.8 0 0 1 .8-.8zM3.5 3.5a.8.8 0 0 1 1 0l1.8 1.8a.8.8 0 1 1-1 1L3.5 4.6a.8.8 0 0 1 0-1zm14.2 14.2a.8.8 0 0 1 1 0l1.8 1.7a.8.8 0 0 1-1 1l-1.8-1.7a.8.8 0 0 1 0-1z"
></path>
<path
class="moon"
fill-rule="evenodd"
d="M16.5 6A10.5 10.5 0 0 1 4.7 16.4 8.5 8.5 0 1 0 16.4 4.7l.1 1.3zm-1.7-2a9 9 0 0 1 .2 2 9 9 0 0 1-11 8.8 9.4 9.4 0 0 1-.8-.3c-.4 0-.8.3-.7.7a10 10 0 0 0 .3.8 10 10 0 0 0 9.2 6 10 10 0 0 0 4-19.2 9.7 9.7 0 0 0-.9-.3c-.3-.1-.7.3-.6.7a9 9 0 0 1 .3.8z"
></path>
</svg>
</button>
<button id="back-to-top" aria-label="Back to top" title="Back to top">
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M18 15l-6-6-6 6"></path>
</svg>
</button>
</div>
<style>
.floating-actions {
position: fixed;
right: 1.25rem;
bottom: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
z-index: 1000;
}
#theme-toggle-fab,
#back-to-top {
width: 3rem;
height: 3rem;
border: #a7a7a7 1.5px solid;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
color: #222;
/* box-shadow: 0 4px 14px rgba(0, 0, 0, 0.16); */
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(6px);
}
#theme-toggle-fab {
position: relative;
}
#theme-toggle-fab {
position: relative;
}
.theme-toggle-icon {
width: 1.2rem;
height: 1.2rem;
fill: currentColor;
}
.theme-toggle-icon .sun,
.theme-toggle-icon .moon {
transform-origin: center;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.theme-toggle-icon .sun {
opacity: 1;
transform: scale(1) rotate(0deg);
}
.theme-toggle-icon .moon {
opacity: 0;
transform: scale(0.75) rotate(-20deg);
}
:global(.dark) .theme-toggle-icon .sun {
opacity: 0;
transform: scale(0.75) rotate(20deg);
}
:global(.dark) .theme-toggle-icon .moon {
opacity: 1;
transform: scale(1) rotate(0deg);
}
#theme-toggle-fab,
#back-to-top {
/* opacity: 0; */
visibility: hidden;
transform: translateY(150px);
transition:
opacity 0.2s ease,
transform 0.27s ease,
visibility 0.2s ease;
}
#theme-toggle-fab.show,
#back-to-top.show {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
#theme-toggle-fab.show:hover,
#back-to-top.show:hover {
transform: translateY(-2px);
}
:global(.dark) #theme-toggle-fab,
:global(.dark) #back-to-top {
background: rgba(34, 34, 34, 0.92);
color: #f5f5f5;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.32);
border: #515151 2px solid;
}
@media (max-width: 640px) {
.floating-actions {
right: 1rem;
bottom: 1rem;
gap: 0.65rem;
}
#theme-toggle-fab,
#back-to-top {
width: 2.75rem;
height: 2.75rem;
}
}
</style>
<script>
const initFloatingActions = () => {
const backToTop = document.getElementById("back-to-top");
const themeToggle = document.getElementById("theme-toggle-fab");
if (backToTop && backToTop.dataset.bound !== "true") {
const toggleBackToTop = () => {
if (window.scrollY > 300) {
backToTop.classList.add("show");
} else {
backToTop.classList.remove("show");
}
};
backToTop.addEventListener("click", () => {
window.scrollTo({
top: 0,
behavior: "smooth",
});
});
window.addEventListener("scroll", toggleBackToTop, {
passive: true,
});
toggleBackToTop();
backToTop.dataset.bound = "true";
}
if (themeToggle && themeToggle.dataset.bound !== "true") {
const togglethemeToggle = () => {
if (window.scrollY > 300) {
themeToggle.classList.add("show");
} else {
themeToggle.classList.remove("show");
}
};
const root = document.documentElement;
const applyTheme = (theme: string) => {
if (theme === "dark") {
root.classList.add("dark");
localStorage.setItem("color-theme", "dark");
} else {
root.classList.remove("dark");
localStorage.setItem("color-theme", "light");
}
};
themeToggle.addEventListener("click", () => {
const isDark = root.classList.contains("dark");
applyTheme(isDark ? "light" : "dark");
});
window.addEventListener("scroll", togglethemeToggle, {
passive: true,
});
togglethemeToggle();
themeToggle.dataset.bound = "true";
}
};
initFloatingActions();
document.addEventListener("astro:page-load", initFloatingActions);
</script>

View file

@ -0,0 +1,299 @@
---
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 normalizePath(url) {
if (!url) return "";
try {
const u = new URL(url, window.location.origin);
let path = u.pathname;
if (!path.startsWith("/")) path = "/" + path;
if (!path.endsWith("/")) path += "/";
return path;
} catch {
let path = String(url);
path = path.replace(/^https?:\/\/[^/]+/i, "");
if (!path.startsWith("/")) path = "/" + path;
if (!path.endsWith("/")) path += "/";
return path;
}
}
function buildUrlCandidates(rawUrl) {
const path = normalizePath(rawUrl);
if (!path) return [];
const noSlash = path.endsWith("/") ? path.slice(0, -1) : path;
const candidates = [
path,
noSlash,
`/zh${path}`,
`/zh${noSlash}`,
`/en${path}`,
`/en${noSlash}`,
`${window.location.origin}${path}`,
`${window.location.origin}${noSlash}`,
];
return [...new Set(candidates.filter(Boolean))];
}
async function fetchPostComments(host, siteId, rawUrl) {
const candidates = buildUrlCandidates(rawUrl);
for (const candidate of candidates) {
try {
const res = await fetch(
`${host}/api/v1/find?site=${encodeURIComponent(siteId)}&url=${encodeURIComponent(candidate)}&format=plain`,
{ credentials: "omit" },
);
if (!res.ok) continue;
const data = await res.json();
const comments = Array.isArray(data)
? data
: Array.isArray(data?.comments)
? data.comments
: [];
if (comments.length > 0) {
return comments;
}
} catch {}
}
return [];
}
async function enrichReplyTargets(host, siteId, comments) {
const urls = [
...new Set(
comments.map((item) => item?.locator?.url).filter(Boolean),
),
];
const postMap = new Map();
await Promise.all(
urls.map(async (url) => {
const postComments = await fetchPostComments(host, siteId, url);
postMap.set(url, postComments);
}),
);
return comments.map((item) => {
if (!item?.pid) {
return { ...item, replyToName: "" };
}
const postComments = postMap.get(item?.locator?.url) || [];
const parent = postComments.find(
(c) => String(c?.id) === String(item?.pid),
);
return {
...item,
replyToName: parent?.user?.name || "",
};
});
}
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 rawAuthor = getUserName(item, lang);
const author = escapeHtml(rawAuthor);
const fallbackInitial = escapeHtml(
rawAuthor.slice(0, 1).toUpperCase(),
);
const avatar = getAvatar(item);
const safeAvatar = escapeHtml(avatar || "");
const title = escapeHtml(
item?.title ||
(lang === "zh" ? "未命名文章" : "Untitled post"),
);
const href = rewritePostUrl(item?.locator?.url || "", lang);
// Remark42 返回的 text 是处理后的 HTML适合直接渲染
const html = item?.text || "";
const time = formatTime(item?.time, lang);
const replyTo = escapeHtml(item?.replyToName || "");
const authorLine = replyTo
? `<span class="comment-author">@${author}</span><span class="comment-reply-sep">${
lang === "zh" ? "回复" : "replied to"
}</span><span class="comment-reply-to">@${replyTo}</span>`
: `<span class="comment-author">@${author}</span>`;
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">${fallbackInitial}</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">
${authorLine}
${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();
const enriched = await enrichReplyTargets(
host,
siteId,
Array.isArray(data) ? data : [],
);
renderComments(section, enriched, 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

@ -1,6 +1,6 @@
---
import "@/styles/global.css";
import Remark42Count from "@/components/remark42-counter.svelte";
import Remark42Counter from "@/components/Remark42Counter.astro";
import { getLangFromUrl } from "@/i18n";
const lang = getLangFromUrl(Astro.url);
@ -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>
@ -32,7 +33,7 @@ const tags = data.tags;
<span class="post-date">{data.date}</span>
<span class="post-stats">
<span class="post-stat">
💬 <Remark42Count url={data.postPath} client:idle />
💬 <Remark42Counter url={data.postPath} />
</span>
</span>
</div>
@ -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

@ -16,7 +16,7 @@ const filteredPosts = sortedPosts.filter((post: any) => {
return postLang === lang;
});
const latestPosts = filteredPosts.slice(0, 5);
const latestPosts = filteredPosts.slice(0, 7);
---
<ul>
@ -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

@ -51,9 +51,11 @@ const groupedPosts = filteredPosts.reduce((acc: any[], post: any) => {
<a
href={`/${lang}/posts/${slug}/`}
class="timeline-card"
data-astro-reload
>
<h2 class="post-title">
<h2
class="post-title"
transition:name={`post-title-${slug}`}
>
{post.data.title}
</h2>
<p class="post-description">

View file

@ -0,0 +1,66 @@
---
const { url } = Astro.props;
const host = import.meta.env.PUBLIC_REMARK42_HOST;
const siteId = import.meta.env.PUBLIC_REMARK42_SITE_ID;
---
<span
class="remark42__counter"
data-url={url}
data-host={host}
data-site-id={siteId}></span>
<script is:inline data-astro-rerun>
function getTheme() {
return document.documentElement.classList.contains("dark")
? "dark"
: "light";
}
function mountRemark42Counters() {
const counters = Array.from(
document.querySelectorAll(".remark42__counter[data-url]"),
);
if (!counters.length) return;
const first = counters[0];
const host = first.dataset.host;
const siteId = first.dataset.siteId;
if (!host || !siteId) return;
window.remark_config = {
host,
site_id: siteId,
components: ["counter"],
show_rss_subscription: false,
theme: getTheme(),
};
for (const el of counters) {
el.textContent = "";
}
document.getElementById("remark42-counter-loader")?.remove();
const script = document.createElement("script");
script.id = "remark42-counter-loader";
script.defer = true;
if ("noModule" in script) {
script.type = "module";
script.src = `${host}/web/counter.mjs?ts=${Date.now()}`;
} else {
script.async = true;
script.src = `${host}/web/counter.js?ts=${Date.now()}`;
}
document.head.appendChild(script);
}
clearTimeout(window.__remark42CounterMountTimer);
window.__remark42CounterMountTimer = setTimeout(() => {
mountRemark42Counters();
}, 0);
</script>

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>

View file

@ -0,0 +1,95 @@
<div id="page-loading-indicator" aria-hidden="true">
<span class="page-loading-spinner"></span>
</div>
<script is:inline>
(() => {
let timer;
const show = () => {
clearTimeout(timer);
timer = setTimeout(() => {
const el = document.getElementById("page-loading-indicator");
if (el) el.classList.add("is-active");
}, 120);
};
const hide = () => {
clearTimeout(timer);
const el = document.getElementById("page-loading-indicator");
if (el) el.classList.remove("is-active");
};
document.addEventListener("astro:before-preparation", show);
document.addEventListener("astro:page-load", hide);
hide();
})();
</script>
<style is:global>
#page-loading-indicator {
position: fixed;
top: 1rem;
right: 1rem;
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
opacity: 0;
transform: scale(1.3);
pointer-events: none;
/* transition:
opacity 160ms ease,
transform 160ms ease; */
z-index: 9999;
}
#page-loading-indicator.is-active {
opacity: 1;
transform: scale(1);
}
.page-loading-spinner {
width: 1.4rem;
height: 1.4rem;
box-sizing: border-box;
border: 1.5px solid #7da2ff;
border-top-color: white;
border-radius: 50%;
background: transparent;
box-shadow:
0 0 0 1px #606060,
inset 0 0 0 1px #606060;
animation: page-loading-spin-rhythm 2.1s infinite;
transform-origin: center;
}
@keyframes page-loading-spin-rhythm {
0% {
transform: rotate(0deg);
animation-timing-function: cubic-bezier(0.25, 0.75, 0.35, 1);
}
52% {
transform: rotate(720deg); /* 前两圈,稍微慢一点 */
animation-timing-function: cubic-bezier(0.55, 0.08, 0.78, 0.22);
}
100% {
transform: rotate(1080deg); /* 最后一圈更慢 */
}
}
.dark .page-loading-spinner,
html.dark .page-loading-spinner {
border-color: #d8c7a1;
border-top-color: transparent;
}
@keyframes page-loading-spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -34,7 +34,7 @@ const switchHref = "/" + segments.join("/");
</svg>
</button>
<a class="lang-switch" href={switchHref} data-astro-reload>
<a class="lang-switch" href={switchHref}>
{switchLabel}
</a>
</div>

View file

@ -1,38 +0,0 @@
<script>
import { onMount } from "svelte";
export let url;
const host = import.meta.env.PUBLIC_REMARK42_HOST;
const siteId = import.meta.env.PUBLIC_REMARK42_SITE_ID;
onMount(() => {
const remark_config = {
host,
site_id: siteId,
components: ["counter"],
show_rss_subscription: false,
theme: localStorage.getItem("color-theme") ?? "light",
};
window.remark_config = remark_config;
for (const name of remark_config.components || ["embed"]) {
const script = document.createElement("script");
let ext = ".js";
if ("noModule" in script) {
script.type = "module";
ext = ".mjs";
} else {
script.async = true;
}
script.defer = true;
script.src = `${remark_config.host}/web/${name}${ext}`;
document.head.appendChild(script);
}
});
</script>
<span class="remark42__counter" data-url={url}></span>

View file

@ -1,75 +0,0 @@
<script>
import { onMount } from "svelte";
export let slug;
let pagePath = `/posts/${slug}/`;
function getTheme() {
return document.documentElement.classList.contains("dark")
? "dark"
: "light";
}
function applyRemark42Theme() {
if (window.REMARK42 && typeof window.REMARK42.changeTheme === "function") {
window.REMARK42.changeTheme(getTheme());
return true;
}
return false;
}
onMount(() => {
const scriptId = "remark42-script";
window.remark_config = {
host: import.meta.env.PUBLIC_REMARK42_HOST,
site_id: import.meta.env.PUBLIC_REMARK42_SITE_ID,
components: ["embed"],
show_rss_subscription: false,
theme: getTheme(),
url: pagePath,
page_id: pagePath,
};
if (!document.getElementById(scriptId)) {
const script = document.createElement("script");
script.id = scriptId;
script.async = true;
script.innerHTML = `
!function(e,n){
for(var o=0;o<e.length;o++){
var r=n.createElement("script"),c=".js",d=n.head||n.body;
"noModule"in r?(r.type="module",c=".mjs"):r.async=!0;
r.defer=!0;
r.src=window.remark_config.host+"/web/"+e[o]+c;
d.appendChild(r);
}
}(window.remark_config.components||["embed"],document);
`;
document.body.appendChild(script);
}
const applyWhenReady = setInterval(() => {
if (applyRemark42Theme()) {
clearInterval(applyWhenReady);
}
}, 200);
const observer = new MutationObserver(() => {
applyRemark42Theme();
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => {
clearInterval(applyWhenReady);
observer.disconnect();
};
});
</script>
<div id="remark42"></div>

View file

@ -1,252 +0,0 @@
<script>
import { onMount } from "svelte";
export let lang = "zh";
let loaded = false;
function rewriteLinks() {
const links = document.querySelectorAll(
".latest-comments .comment__title-link",
);
links.forEach((link) => {
const href = link.getAttribute("href");
if (!href) return;
if (href.startsWith("/posts/")) {
link.setAttribute("href", `/${lang}${href}`);
}
});
}
onMount(() => {
window.remark_config = {
host: import.meta.env.PUBLIC_REMARK42_HOST,
site_id: import.meta.env.PUBLIC_REMARK42_SITE_ID,
components: ["last-comments"],
theme: localStorage.getItem("color-theme") ?? "light",
max_last_comments: 10,
no_footer: true,
};
const loaderId = "remark42-last-comments-loader";
if (!document.getElementById(loaderId)) {
const init = document.createElement("script");
init.id = loaderId;
init.innerHTML = `
!function(e,n){
for(var o=0;o<e.length;o++){
var r=n.createElement("script"),c=".js",d=n.head||n.body;
"noModule"in r?(r.type="module",c=".mjs"):r.async=!0;
r.defer=!0;
r.src=window.remark_config.host+"/web/"+e[o]+c;
d.appendChild(r);
}
}(window.remark_config.components||["last-comments"],document);
`;
document.body.appendChild(init);
}
const timer = setInterval(() => {
const root = document.querySelector(".remark42__last-comments");
if (root && root.children.length > 0) {
rewriteLinks();
loaded = true;
clearInterval(timer);
}
}, 200);
return () => clearInterval(timer);
});
</script>
<section class="latest-comments">
<h2>最新评论</h2>
{#if !loaded}
<div class="comments-loading" aria-hidden="true">
<div class="loading-card"></div>
<div class="loading-card"></div>
<div class="loading-card"></div>
</div>
{/if}
<div class="remark42__last-comments" data-max="10"></div>
</section>
<style>
.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 {
margin-top: 1.5rem;
}
.latest-comments h2 {
margin-bottom: 0.8rem;
}
.latest-comments :global(.remark42__last-comments) {
display: block;
}
.latest-comments :global(article.comment.list-comments__item) {
display: block;
margin: 0 0 14px 0;
padding: 0.9rem 1rem;
border: 1px dashed #aeb8c2;
background: rgba(160, 175, 190, 0.06);
}
.latest-comments :global(article.comment.list-comments__item:last-child) {
margin-bottom: 0;
}
:global(.dark)
.latest-comments
:global(article.comment.list-comments__item) {
background: #2a3138;
border-color: #7f8c97;
}
.latest-comments :global(.comment__body) {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
grid-template-areas:
"info title"
"text title";
gap: 0.35rem 1rem;
align-items: start;
}
.latest-comments :global(.comment__title) {
grid-area: title;
text-align: right;
min-width: 0;
white-space: normal;
}
.latest-comments :global(.comment__title-link) {
color: #6f8090;
text-decoration: none;
font-size: 0.87rem;
font-weight: 700;
line-height: 1.45;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.latest-comments :global(.comment__title-link:hover) {
text-decoration: underline;
}
.latest-comments :global(.comment__info) {
grid-area: info;
font-weight: 700;
line-height: 1.3;
color: inherit;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
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;
}
.latest-comments :global(.comment__info)::before {
content: "@";
margin-right: 0.08em;
opacity: 0.85;
}
.latest-comments :global(.comment__text) {
grid-area: text;
margin: 0;
color: #555;
line-height: 1.7;
font-size: 1.03rem;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
white-space: pre-wrap;
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;
}
:global(.dark)
.latest-comments
:global(article.comment.list-comments__item) {
border-color: #7f8c97;
background: rgba(180, 190, 200, 0.06);
}
:global(.dark) .latest-comments :global(.comment__title-link) {
color: #c8d2dc;
}
:global(.dark) .latest-comments :global(.comment__text) {
color: #d3d7db;
}
@media (max-width: 640px) {
.latest-comments :global(.comment__body) {
grid-template-columns: 1fr;
grid-template-areas:
"info"
"title"
"text";
}
.latest-comments :global(.comment__title) {
text-align: left;
min-width: 0;
}
.latest-comments :global(.comment__title-link) {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
</style>

View file

@ -2,7 +2,10 @@
import Footer from "@/components/Footer.astro";
import Header from "@/components/Header.astro";
import { ClientRouter } from "astro:transitions";
import { slide, fade } from "astro:transitions";
import SEO from "@/components/SEO.astro";
import FloatingActions from "@/components/FloatingActions.astro";
import Spinner from "@/components/Spinner.astro";
const {
pageTitle,
@ -14,6 +17,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} />
@ -22,51 +26,15 @@ const {
<meta name="generator" content={Astro.generator} />
<title>{pageTitle}</title>
</head>
<div class="floating-actions">
<button
id="theme-toggle-fab"
aria-label="Toggle theme"
title="Toggle theme"
>
<svg
class="theme-toggle-icon"
viewBox="0 0 24 24"
width="20"
height="20"
aria-hidden="true"
>
<path
class="sun"
fill-rule="evenodd"
d="M12 17.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zm0 1.5a7 7 0 1 0 0-14 7 7 0 0 0 0 14zm12-7a.8.8 0 0 1-.8.8h-2.4a.8.8 0 0 1 0-1.6h2.4a.8.8 0 0 1 .8.8zM4 12a.8.8 0 0 1-.8.8H.8a.8.8 0 0 1 0-1.6h2.5a.8.8 0 0 1 .8.8zm16.5-8.5a.8.8 0 0 1 0 1l-1.8 1.8a.8.8 0 0 1-1-1l1.7-1.8a.8.8 0 0 1 1 0zM6.3 17.7a.8.8 0 0 1 0 1l-1.7 1.8a.8.8 0 1 1-1-1l1.7-1.8a.8.8 0 0 1 1 0zM12 0a.8.8 0 0 1 .8.8v2.5a.8.8 0 0 1-1.6 0V.8A.8.8 0 0 1 12 0zm0 20a.8.8 0 0 1 .8.8v2.4a.8.8 0 0 1-1.6 0v-2.4a.8.8 0 0 1 .8-.8zM3.5 3.5a.8.8 0 0 1 1 0l1.8 1.8a.8.8 0 1 1-1 1L3.5 4.6a.8.8 0 0 1 0-1zm14.2 14.2a.8.8 0 0 1 1 0l1.8 1.7a.8.8 0 0 1-1 1l-1.8-1.7a.8.8 0 0 1 0-1z"
></path>
<path
class="moon"
fill-rule="evenodd"
d="M16.5 6A10.5 10.5 0 0 1 4.7 16.4 8.5 8.5 0 1 0 16.4 4.7l.1 1.3zm-1.7-2a9 9 0 0 1 .2 2 9 9 0 0 1-11 8.8 9.4 9.4 0 0 1-.8-.3c-.4 0-.8.3-.7.7a10 10 0 0 0 .3.8 10 10 0 0 0 9.2 6 10 10 0 0 0 4-19.2 9.7 9.7 0 0 0-.9-.3c-.3-.1-.7.3-.6.7a9 9 0 0 1 .3.8z"
></path>
</svg>
</button>
<FloatingActions />
<button id="back-to-top" aria-label="Back to top" title="Back to top">
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M18 15l-6-6-6 6"></path>
</svg>
</button>
</div>
<body>
<Header />
<main class="page-content">
<Spinner />
<main
class="page-content"
transition:animate={fade({ duration: "0.05s" })}
>
<slot />
</main>
<Footer />
@ -80,184 +48,4 @@ const {
width: 100%;
height: 100%;
}
.floating-actions {
position: fixed;
right: 1.25rem;
bottom: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
z-index: 1000;
}
#theme-toggle-fab,
#back-to-top {
width: 3rem;
height: 3rem;
border: #a7a7a7 1.5px solid;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
color: #222;
/* box-shadow: 0 4px 14px rgba(0, 0, 0, 0.16); */
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(6px);
}
#theme-toggle-fab {
position: relative;
}
#theme-toggle-fab {
position: relative;
}
.theme-toggle-icon {
width: 1.2rem;
height: 1.2rem;
fill: currentColor;
}
.theme-toggle-icon .sun,
.theme-toggle-icon .moon {
transform-origin: center;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.theme-toggle-icon .sun {
opacity: 1;
transform: scale(1) rotate(0deg);
}
.theme-toggle-icon .moon {
opacity: 0;
transform: scale(0.75) rotate(-20deg);
}
:global(.dark) .theme-toggle-icon .sun {
opacity: 0;
transform: scale(0.75) rotate(20deg);
}
:global(.dark) .theme-toggle-icon .moon {
opacity: 1;
transform: scale(1) rotate(0deg);
}
#theme-toggle-fab,
#back-to-top {
/* opacity: 0; */
visibility: hidden;
transform: translateY(150px);
transition:
opacity 0.2s ease,
transform 0.27s ease,
visibility 0.2s ease;
}
#theme-toggle-fab.show,
#back-to-top.show {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
#theme-toggle-fab.show:hover,
#back-to-top.show:hover {
transform: translateY(-2px);
}
:global(.dark) #theme-toggle-fab,
:global(.dark) #back-to-top {
background: rgba(34, 34, 34, 0.92);
color: #f5f5f5;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.32);
border: #515151 2px solid;
}
@media (max-width: 640px) {
.floating-actions {
right: 1rem;
bottom: 1rem;
gap: 0.65rem;
}
#theme-toggle-fab,
#back-to-top {
width: 2.75rem;
height: 2.75rem;
}
}
</style>
<script>
const initFloatingActions = () => {
const backToTop = document.getElementById("back-to-top");
const themeToggle = document.getElementById("theme-toggle-fab");
if (backToTop && backToTop.dataset.bound !== "true") {
const toggleBackToTop = () => {
if (window.scrollY > 300) {
backToTop.classList.add("show");
} else {
backToTop.classList.remove("show");
}
};
backToTop.addEventListener("click", () => {
window.scrollTo({
top: 0,
behavior: "smooth",
});
});
window.addEventListener("scroll", toggleBackToTop, {
passive: true,
});
toggleBackToTop();
backToTop.dataset.bound = "true";
}
if (themeToggle && themeToggle.dataset.bound !== "true") {
const togglethemeToggle = () => {
if (window.scrollY > 300) {
themeToggle.classList.add("show");
} else {
themeToggle.classList.remove("show");
}
};
const root = document.documentElement;
const applyTheme = (theme: string) => {
if (theme === "dark") {
root.classList.add("dark");
localStorage.setItem("color-theme", "dark");
} else {
root.classList.remove("dark");
localStorage.setItem("color-theme", "light");
}
};
themeToggle.addEventListener("click", () => {
const isDark = root.classList.contains("dark");
applyTheme(isDark ? "light" : "dark");
});
window.addEventListener("scroll", togglethemeToggle, {
passive: true,
});
togglethemeToggle();
themeToggle.dataset.bound = "true";
}
};
initFloatingActions();
document.addEventListener("astro:page-load", initFloatingActions);
</script>

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,266 @@ 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.7rem;
}
.comment-card-title {
grid-area: title;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
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;
}
.comment-reply-sep {
font-size: 0.8rem;
opacity: 0.7;
}
.latest-comments .comment-reply-to {
font-weight: 400;
font-size: 0.8rem;
color: gray;
opacity: 0.9;
overflow-wrap: anywhere;
word-break: break-word;
}
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:
"title"
"info"
"text";
}
.comment-card-title {
text-align: left;
margin-bottom: 0.2rem;
}
.comment-title-link {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}