mirror of
https://github.com/ClovertaTheTrilobita/SanYeCao-blog.git
synced 2026-04-02 01:54:50 +00:00
added slow loading to comments
This commit is contained in:
parent
d4bebfc9d8
commit
be54fad794
5 changed files with 447 additions and 303 deletions
|
|
@ -1,301 +1,162 @@
|
||||||
---
|
---
|
||||||
import { getCollection } from "astro:content";
|
|
||||||
import { getLangFromUrl } from "@/i18n";
|
import { getLangFromUrl } from "@/i18n";
|
||||||
|
|
||||||
const token = import.meta.env.GITHUB_TOKEN;
|
|
||||||
const owner = import.meta.env.GISCUS_REPO_OWNER ?? "ClovertaTheTrilobita";
|
|
||||||
const name = import.meta.env.GISCUS_REPO_NAME ?? "SanYeCao-blog";
|
|
||||||
const categoryId = import.meta.env.GISCUS_CATEGORY_ID ?? "DIC_kwDORvuVpM4C5MDE";
|
|
||||||
const limit = Number(Astro.props.limit ?? 5);
|
const limit = Number(Astro.props.limit ?? 5);
|
||||||
|
|
||||||
const lang = getLangFromUrl(Astro.url);
|
const lang = getLangFromUrl(Astro.url);
|
||||||
const allPosts = await getCollection("blog");
|
|
||||||
|
|
||||||
type CommentAuthor = {
|
|
||||||
login?: string;
|
|
||||||
avatarUrl?: string;
|
|
||||||
url?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RawReply = {
|
|
||||||
id: string;
|
|
||||||
body?: string;
|
|
||||||
updatedAt: string;
|
|
||||||
author?: CommentAuthor | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RawComment = {
|
|
||||||
id: string;
|
|
||||||
body?: string;
|
|
||||||
updatedAt: string;
|
|
||||||
author?: CommentAuthor | null;
|
|
||||||
replies?: {
|
|
||||||
nodes?: RawReply[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type DiscussionNode = {
|
|
||||||
title: string;
|
|
||||||
comments?: {
|
|
||||||
nodes?: RawComment[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type LatestCommentItem = {
|
|
||||||
id: string;
|
|
||||||
body?: string;
|
|
||||||
updatedAt: string;
|
|
||||||
author?: CommentAuthor | null;
|
|
||||||
postId: string;
|
|
||||||
postTitle: string;
|
|
||||||
localUrl: string;
|
|
||||||
isReply: boolean;
|
|
||||||
replyTo?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizePath(path: string) {
|
|
||||||
return path.replace(/^\/+|\/+$/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function excerpt(text = "", max = 110) {
|
|
||||||
const plain = text.replace(/\s+/g, " ").trim();
|
|
||||||
if (plain.length <= max) return plain;
|
|
||||||
return plain.slice(0, max) + "…";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string) {
|
function formatDate(iso: string) {
|
||||||
return new Date(iso).toISOString().slice(0, 10);
|
return new Date(iso).toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLatestComments(): Promise<LatestCommentItem[]> {
|
|
||||||
if (!token) return [];
|
|
||||||
|
|
||||||
const query = `
|
|
||||||
query($owner: String!, $name: String!, $categoryId: ID!) {
|
|
||||||
repository(owner: $owner, name: $name) {
|
|
||||||
discussions(
|
|
||||||
first: 20
|
|
||||||
categoryId: $categoryId
|
|
||||||
orderBy: { field: UPDATED_AT, direction: DESC }
|
|
||||||
) {
|
|
||||||
nodes {
|
|
||||||
title
|
|
||||||
comments(last: 10) {
|
|
||||||
nodes {
|
|
||||||
id
|
|
||||||
body
|
|
||||||
updatedAt
|
|
||||||
author {
|
|
||||||
... on User {
|
|
||||||
login
|
|
||||||
avatarUrl
|
|
||||||
url
|
|
||||||
}
|
|
||||||
... on Organization {
|
|
||||||
login
|
|
||||||
avatarUrl
|
|
||||||
url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
replies(first: 10) {
|
|
||||||
nodes {
|
|
||||||
id
|
|
||||||
body
|
|
||||||
updatedAt
|
|
||||||
author {
|
|
||||||
... on User {
|
|
||||||
login
|
|
||||||
avatarUrl
|
|
||||||
url
|
|
||||||
}
|
|
||||||
... on Organization {
|
|
||||||
login
|
|
||||||
avatarUrl
|
|
||||||
url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const res = await fetch("https://api.github.com/graphql", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
query,
|
|
||||||
variables: { owner, name, categoryId },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const json = await res.json();
|
|
||||||
const discussions = (json?.data?.repository?.discussions?.nodes ??
|
|
||||||
[]) as DiscussionNode[];
|
|
||||||
|
|
||||||
// 用 post.id 建本地文章索引
|
|
||||||
const postMap = new Map(
|
|
||||||
allPosts.map((post: any) => [
|
|
||||||
normalizePath(post.id),
|
|
||||||
{
|
|
||||||
title: post.data.title,
|
|
||||||
url: `/${lang}/posts/${post.id}/#comments`,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const comments: LatestCommentItem[] = [];
|
|
||||||
|
|
||||||
for (const discussion of discussions) {
|
|
||||||
const discussionKey = normalizePath(discussion.title);
|
|
||||||
|
|
||||||
let matchedPostId: string | undefined;
|
|
||||||
|
|
||||||
if (postMap.has(discussionKey)) {
|
|
||||||
matchedPostId = discussionKey;
|
|
||||||
} else {
|
|
||||||
matchedPostId = [...postMap.keys()].find(
|
|
||||||
(postId) =>
|
|
||||||
discussionKey === postId ||
|
|
||||||
discussionKey.includes(postId) ||
|
|
||||||
postId.includes(discussionKey),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!matchedPostId) continue;
|
|
||||||
|
|
||||||
const postInfo = postMap.get(matchedPostId);
|
|
||||||
if (!postInfo) continue;
|
|
||||||
|
|
||||||
for (const comment of discussion.comments?.nodes ?? []) {
|
|
||||||
comments.push({
|
|
||||||
id: comment.id,
|
|
||||||
body: comment.body,
|
|
||||||
updatedAt: comment.updatedAt,
|
|
||||||
author: comment.author,
|
|
||||||
postId: matchedPostId,
|
|
||||||
postTitle: postInfo.title,
|
|
||||||
localUrl: postInfo.url,
|
|
||||||
isReply: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const reply of comment.replies?.nodes ?? []) {
|
|
||||||
comments.push({
|
|
||||||
id: reply.id,
|
|
||||||
body: reply.body,
|
|
||||||
updatedAt: reply.updatedAt,
|
|
||||||
author: reply.author,
|
|
||||||
postId: matchedPostId,
|
|
||||||
postTitle: postInfo.title,
|
|
||||||
localUrl: postInfo.url,
|
|
||||||
isReply: true,
|
|
||||||
replyTo: comment.author?.login ?? "Unknown",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return comments
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.updatedAt).getTime() -
|
|
||||||
new Date(a.updatedAt).getTime(),
|
|
||||||
)
|
|
||||||
.slice(0, limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
const comments = await fetchLatestComments();
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<section class="latest-comments">
|
<section
|
||||||
|
id="latest-comments"
|
||||||
|
class="latest-comments"
|
||||||
|
data-lang={lang}
|
||||||
|
data-limit={limit}
|
||||||
|
>
|
||||||
<h2>{lang === "zh" ? "最新评论" : "Latest Comments"}</h2>
|
<h2>{lang === "zh" ? "最新评论" : "Latest Comments"}</h2>
|
||||||
|
|
||||||
{
|
<div class="comments-loading" id="comments-loading">
|
||||||
comments.length === 0 ? (
|
<div class="loading-card"></div>
|
||||||
<p class="empty">
|
<div class="loading-card"></div>
|
||||||
{lang === "zh" ? "还没有评论。" : "No comments yet."}
|
<div class="loading-card"></div>
|
||||||
</p>
|
</div>
|
||||||
) : (
|
|
||||||
<ul class="comment-list">
|
|
||||||
{comments.map((comment) => (
|
|
||||||
<li
|
|
||||||
class={`comment-card ${comment.isReply ? "is-reply" : ""}`}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={comment.localUrl}
|
|
||||||
class="comment-link"
|
|
||||||
data-astro-reload
|
|
||||||
>
|
|
||||||
<div class="comment-top">
|
|
||||||
{comment.author?.avatarUrl && (
|
|
||||||
<img
|
|
||||||
src={comment.author.avatarUrl}
|
|
||||||
alt={comment.author?.login ?? "author"}
|
|
||||||
class="comment-avatar"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div class="comment-meta">
|
<ul class="comment-list" id="comment-list" style="display: none;"></ul>
|
||||||
<span class="comment-author">
|
|
||||||
{comment.author?.login ?? "Unknown"}
|
|
||||||
</span>
|
|
||||||
<span class="comment-date">
|
|
||||||
{formatDate(comment.updatedAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span
|
<p class="empty" id="comments-empty" style="display: none;">
|
||||||
class="comment-post-title"
|
{lang === "zh" ? "还没有评论。" : "No comments yet."}
|
||||||
title={comment.postTitle}
|
</p>
|
||||||
>
|
|
||||||
{comment.postTitle}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{comment.isReply && (
|
<p class="empty" id="comments-error" style="display: none;">
|
||||||
<p class="comment-kind">
|
{lang === "zh" ? "评论加载失败。" : "Failed to load comments."}
|
||||||
{lang === "zh"
|
</p>
|
||||||
? `回复 @${comment.replyTo ?? "Unknown"}`
|
|
||||||
: `Reply to @${comment.replyTo ?? "Unknown"}`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p class="comment-body">{excerpt(comment.body)}</p>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<script>
|
||||||
.comment-kind {
|
type CommentAuthor = {
|
||||||
margin: 0 0 0.35rem 0;
|
login?: string;
|
||||||
font-size: 0.82rem;
|
avatarUrl?: string;
|
||||||
color: #8c8c8c;
|
url?: string;
|
||||||
font-style: italic;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
.comment-card.is-reply .comment-link {
|
type LatestCommentItem = {
|
||||||
border-style: dashed;
|
id: string;
|
||||||
background: rgba(160, 175, 190, 0.1);
|
body?: string;
|
||||||
}
|
updatedAt: string;
|
||||||
|
author?: CommentAuthor | null;
|
||||||
|
postId: string;
|
||||||
|
postTitle: string;
|
||||||
|
localUrl: string;
|
||||||
|
isReply: boolean;
|
||||||
|
replyTo?: string;
|
||||||
|
};
|
||||||
|
|
||||||
:global(.dark) .comment-kind {
|
type CommentsResponse = {
|
||||||
color: #b8b8b8;
|
comments?: LatestCommentItem[];
|
||||||
}
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
:global(.dark) .comment-card.is-reply .comment-link {
|
const root = document.getElementById(
|
||||||
background: rgba(180, 190, 200, 0.1);
|
"latest-comments",
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (root) {
|
||||||
|
const lang = root.dataset.lang || "zh";
|
||||||
|
const limit = root.dataset.limit || "5";
|
||||||
|
|
||||||
|
const loadingEl = root.querySelector("#comments-loading");
|
||||||
|
const listEl = root.querySelector("#comment-list");
|
||||||
|
const emptyEl = root.querySelector("#comments-empty");
|
||||||
|
const errorEl = root.querySelector("#comments-error");
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(loadingEl instanceof HTMLElement) ||
|
||||||
|
!(listEl instanceof HTMLElement) ||
|
||||||
|
!(emptyEl instanceof HTMLElement) ||
|
||||||
|
!(errorEl instanceof HTMLElement)
|
||||||
|
) {
|
||||||
|
throw new Error("Comments elements not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (iso: string) =>
|
||||||
|
new Date(iso).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const renderComment = (comment: LatestCommentItem) => {
|
||||||
|
console.log("Start render");
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = `comment-card ${comment.isReply ? "is-reply" : ""}`;
|
||||||
|
|
||||||
|
li.innerHTML = `
|
||||||
|
<a href="${comment.localUrl}" class="comment-link" data-astro-reload>
|
||||||
|
<div class="comment-top">
|
||||||
|
${
|
||||||
|
comment.author?.avatarUrl
|
||||||
|
? `<img
|
||||||
|
src="${comment.author.avatarUrl}"
|
||||||
|
alt="${comment.author?.login ?? "author"}"
|
||||||
|
class="comment-avatar"
|
||||||
|
/>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="comment-meta">
|
||||||
|
<span class="comment-author">${comment.author?.login ?? "Unknown"}</span>
|
||||||
|
<span class="comment-date">${formatDate(comment.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="comment-post-title" title="${comment.postTitle}">
|
||||||
|
${comment.postTitle}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${
|
||||||
|
comment.isReply
|
||||||
|
? `<p class="comment-kind">${
|
||||||
|
lang === "zh"
|
||||||
|
? `回复 @${comment.replyTo ?? "Unknown"}`
|
||||||
|
: `Reply to @${comment.replyTo ?? "Unknown"}`
|
||||||
|
}</p>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
<p class="comment-body">${comment.body ?? ""}</p>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return li;
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch(`/api/comments.json?lang=${lang}&limit=${limit}`)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error("Request failed");
|
||||||
|
return res.json() as Promise<CommentsResponse>;
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
loadingEl.style.display = "none";
|
||||||
|
|
||||||
|
const comments = data.comments ?? [];
|
||||||
|
|
||||||
|
if (!comments.length) {
|
||||||
|
emptyEl.style.display = "block";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.style.display = "grid";
|
||||||
|
|
||||||
|
comments.forEach((comment) => {
|
||||||
|
listEl.appendChild(renderComment(comment));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Comments fetch failed:", err);
|
||||||
|
loadingEl.style.display = "none";
|
||||||
|
errorEl.style.display = "block";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style is:global>
|
||||||
.latest-comments {
|
.latest-comments {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
@ -325,6 +186,11 @@ const comments = await fetchLatestComments();
|
||||||
background: rgba(160, 175, 190, 0.06);
|
background: rgba(160, 175, 190, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comment-card.is-reply .comment-link {
|
||||||
|
border-style: dashed;
|
||||||
|
background: rgba(160, 175, 190, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.comment-top {
|
.comment-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -349,24 +215,6 @@ const comments = await fetchLatestComments();
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .comment-post-title {
|
|
||||||
color: #c8d2dc;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.comment-top {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-post-title {
|
|
||||||
margin-left: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-avatar {
|
.comment-avatar {
|
||||||
width: 42px;
|
width: 42px;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
|
|
@ -384,6 +232,13 @@ const comments = await fetchLatestComments();
|
||||||
color: #6f8090;
|
color: #6f8090;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comment-kind {
|
||||||
|
margin: 0 0 0.35rem 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #8c8c8c;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.comment-body {
|
.comment-body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #555;
|
color: #555;
|
||||||
|
|
@ -395,17 +250,81 @@ const comments = await fetchLatestComments();
|
||||||
color: #6f8090;
|
color: #6f8090;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comments-loading {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-card {
|
||||||
|
height: 92px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
:global(.dark) .comment-link {
|
:global(.dark) .comment-link {
|
||||||
border-color: #7f8c97;
|
border-color: #7f8c97;
|
||||||
background: rgba(180, 190, 200, 0.06);
|
background: rgba(180, 190, 200, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.dark) .comment-card.is-reply .comment-link {
|
||||||
|
background: rgba(180, 190, 200, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .comment-post-title {
|
||||||
|
color: #c8d2dc;
|
||||||
|
}
|
||||||
|
|
||||||
:global(.dark) .comment-date,
|
:global(.dark) .comment-date,
|
||||||
:global(.dark) .empty {
|
:global(.dark) .empty {
|
||||||
color: #aab7c4;
|
color: #aab7c4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.dark) .comment-kind {
|
||||||
|
color: #b8b8b8;
|
||||||
|
}
|
||||||
|
|
||||||
:global(.dark) .comment-body {
|
:global(.dark) .comment-body {
|
||||||
color: #d3d7db;
|
color: #d3d7db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.dark) .loading-card {
|
||||||
|
border-color: #7f8c97;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(180, 190, 200, 0.06) 25%,
|
||||||
|
rgba(180, 190, 200, 0.16) 50%,
|
||||||
|
rgba(180, 190, 200, 0.06) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.comment-top {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-post-title {
|
||||||
|
margin-left: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ const t = getTranslations(lang);
|
||||||
---
|
---
|
||||||
|
|
||||||
<nav class="site-nav">
|
<nav class="site-nav">
|
||||||
<a href={`/${lang}`}>{t.nav.home}</a>
|
<a href={`/${lang}`} data-astro-reload>{t.nav.home}</a>
|
||||||
<a href={`/${lang}/about`}>{t.nav.about}</a>
|
<a href={`/${lang}/about`}>{t.nav.about}</a>
|
||||||
<a href={`/${lang}/tags`}>{t.nav.tags}</a>
|
<a href={`/${lang}/tags`}>{t.nav.tags}</a>
|
||||||
<a href={`/${lang}/timeline`}>{t.nav.timeline}</a>
|
<a href={`/${lang}/timeline`}>{t.nav.timeline}</a>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: 'CLovertaTheTrilobita'
|
name: 'CLovertaTheTrilobita'
|
||||||
description: "This is Cloverta's blog"
|
description: "This is Cloverta's blog"
|
||||||
url: "https://cloverta.top"
|
url: "https://blog.cloverta.top"
|
||||||
avatar: 'https://docs.astro.build/assets/rose.webp'
|
avatar: '/images/marisa.png'
|
||||||
---
|
---
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
---
|
|
||||||
name: 'CLovertaTheTrilobita'
|
|
||||||
description: "This is Cloverta's blog"
|
|
||||||
url: 'https://docs.astro.build/assets/rose.webp'
|
|
||||||
avatar: 'https://docs.astro.build/assets/rose.webp'
|
|
||||||
---
|
|
||||||
231
src/pages/api/comments.json.ts
Normal file
231
src/pages/api/comments.json.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
|
||||||
|
const token = import.meta.env.GITHUB_TOKEN;
|
||||||
|
const owner = import.meta.env.GISCUS_REPO_OWNER ?? "ClovertaTheTrilobita";
|
||||||
|
const name = import.meta.env.GISCUS_REPO_NAME ?? "SanYeCao-blog";
|
||||||
|
const categoryId = import.meta.env.GISCUS_CATEGORY_ID ?? "DIC_kwDORvuVpM4C5MDE";
|
||||||
|
|
||||||
|
export function getStaticPaths() {
|
||||||
|
return [{ params: { lang: "zh" } }, { params: { lang: "en" } }];
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommentAuthor = {
|
||||||
|
login?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RawReply = {
|
||||||
|
id: string;
|
||||||
|
body?: string;
|
||||||
|
updatedAt: string;
|
||||||
|
author?: CommentAuthor | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RawComment = {
|
||||||
|
id: string;
|
||||||
|
body?: string;
|
||||||
|
updatedAt: string;
|
||||||
|
author?: CommentAuthor | null;
|
||||||
|
replies?: {
|
||||||
|
nodes?: RawReply[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiscussionNode = {
|
||||||
|
title: string;
|
||||||
|
comments?: {
|
||||||
|
nodes?: RawComment[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type LatestCommentItem = {
|
||||||
|
id: string;
|
||||||
|
body?: string;
|
||||||
|
updatedAt: string;
|
||||||
|
author?: CommentAuthor | null;
|
||||||
|
postId: string;
|
||||||
|
postTitle: string;
|
||||||
|
localUrl: string;
|
||||||
|
isReply: boolean;
|
||||||
|
replyTo?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizePath(path: string) {
|
||||||
|
return path.replace(/^\/+|\/+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function excerpt(text = "", max = 110) {
|
||||||
|
const plain = text.replace(/\s+/g, " ").trim();
|
||||||
|
if (plain.length <= max) return plain;
|
||||||
|
return plain.slice(0, max) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLatestComments(lang: string, limit: number): Promise<LatestCommentItem[]> {
|
||||||
|
if (!token) return [];
|
||||||
|
|
||||||
|
const allPosts = await getCollection("blog");
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query($owner: String!, $name: String!, $categoryId: ID!) {
|
||||||
|
repository(owner: $owner, name: $name) {
|
||||||
|
discussions(
|
||||||
|
first: 20
|
||||||
|
categoryId: $categoryId
|
||||||
|
orderBy: { field: UPDATED_AT, direction: DESC }
|
||||||
|
) {
|
||||||
|
nodes {
|
||||||
|
title
|
||||||
|
comments(last: 10) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
body
|
||||||
|
updatedAt
|
||||||
|
author {
|
||||||
|
... on User {
|
||||||
|
login
|
||||||
|
avatarUrl
|
||||||
|
url
|
||||||
|
}
|
||||||
|
... on Organization {
|
||||||
|
login
|
||||||
|
avatarUrl
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
replies(first: 10) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
body
|
||||||
|
updatedAt
|
||||||
|
author {
|
||||||
|
... on User {
|
||||||
|
login
|
||||||
|
avatarUrl
|
||||||
|
url
|
||||||
|
}
|
||||||
|
... on Organization {
|
||||||
|
login
|
||||||
|
avatarUrl
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const res = await fetch("https://api.github.com/graphql", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query,
|
||||||
|
variables: { owner, name, categoryId },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
const discussions = (json?.data?.repository?.discussions?.nodes ?? []) as DiscussionNode[];
|
||||||
|
|
||||||
|
const postMap = new Map(
|
||||||
|
allPosts.map((post: any) => [
|
||||||
|
normalizePath(post.id),
|
||||||
|
{
|
||||||
|
title: post.data.title,
|
||||||
|
url: `/${lang}/posts/${post.id}/#comments`,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const comments: LatestCommentItem[] = [];
|
||||||
|
|
||||||
|
for (const discussion of discussions) {
|
||||||
|
const discussionKey = normalizePath(discussion.title);
|
||||||
|
|
||||||
|
let matchedPostId: string | undefined;
|
||||||
|
|
||||||
|
if (postMap.has(discussionKey)) {
|
||||||
|
matchedPostId = discussionKey;
|
||||||
|
} else {
|
||||||
|
matchedPostId = [...postMap.keys()].find(
|
||||||
|
(postId) =>
|
||||||
|
discussionKey === postId ||
|
||||||
|
discussionKey.includes(postId) ||
|
||||||
|
postId.includes(discussionKey),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchedPostId) continue;
|
||||||
|
|
||||||
|
const postInfo = postMap.get(matchedPostId);
|
||||||
|
if (!postInfo) continue;
|
||||||
|
|
||||||
|
for (const comment of discussion.comments?.nodes ?? []) {
|
||||||
|
comments.push({
|
||||||
|
id: comment.id,
|
||||||
|
body: excerpt(comment.body),
|
||||||
|
updatedAt: comment.updatedAt,
|
||||||
|
author: comment.author,
|
||||||
|
postId: matchedPostId,
|
||||||
|
postTitle: postInfo.title,
|
||||||
|
localUrl: postInfo.url,
|
||||||
|
isReply: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const reply of comment.replies?.nodes ?? []) {
|
||||||
|
comments.push({
|
||||||
|
id: reply.id,
|
||||||
|
body: excerpt(reply.body),
|
||||||
|
updatedAt: reply.updatedAt,
|
||||||
|
author: reply.author,
|
||||||
|
postId: matchedPostId,
|
||||||
|
postTitle: postInfo.title,
|
||||||
|
localUrl: postInfo.url,
|
||||||
|
isReply: true,
|
||||||
|
replyTo: comment.author?.login ?? "Unknown",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return comments
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.updatedAt).getTime() -
|
||||||
|
new Date(a.updatedAt).getTime(),
|
||||||
|
)
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ params, url }) => {
|
||||||
|
try {
|
||||||
|
const lang = params.lang === "en" ? "en" : "zh";
|
||||||
|
const limit = Number(url.searchParams.get("limit") ?? 5);
|
||||||
|
|
||||||
|
const comments = await fetchLatestComments(lang, limit);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ comments }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Cache-Control": "public, max-age=300",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(JSON.stringify({ comments: [], error: "Failed to load comments" }), {
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue