mirror of
https://github.com/ClovertaTheTrilobita/SanYeCao-blog.git
synced 2026-07-03 15:41:26 +00:00
update animation
refactor remark42 components
This commit is contained in:
parent
703a63fffc
commit
018ad4ab1e
10 changed files with 546 additions and 10 deletions
175
src/components/LatestComments.astro
Normal file
175
src/components/LatestComments.astro
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const latestPosts = filteredPosts.slice(0, 5);
|
|||
date={formattedDate}
|
||||
img={post.data.image.url}
|
||||
tags={post.data.tags}
|
||||
slug={slug}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
|
|
|||
82
src/components/Remark42Embed.astro
Normal file
82
src/components/Remark42Embed.astro
Normal 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
18
src/global.d.ts
vendored
Normal 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 { };
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue