feat: ship blog platform admin and deploy stack
This commit is contained in:
@@ -3,16 +3,17 @@ import { createMarkdownProcessor } from '@astrojs/markdown-remark';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||
import ResponsiveImage from '../../components/ui/ResponsiveImage.astro';
|
||||
import TableOfContents from '../../components/TableOfContents.astro';
|
||||
import RelatedPosts from '../../components/RelatedPosts.astro';
|
||||
import ReadingProgress from '../../components/ReadingProgress.astro';
|
||||
import BackToTop from '../../components/BackToTop.astro';
|
||||
import Lightbox from '../../components/Lightbox.astro';
|
||||
import CodeCopyButton from '../../components/CodeCopyButton.astro';
|
||||
import Comments from '../../components/Comments.astro';
|
||||
import ParagraphComments from '../../components/ParagraphComments.astro';
|
||||
import { apiClient, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||
import { formatReadTime, getI18n } from '../../lib/i18n';
|
||||
import type { PopularPostHighlight } from '../../lib/types';
|
||||
import {
|
||||
getAccentVars,
|
||||
getCategoryTheme,
|
||||
@@ -28,10 +29,12 @@ const { slug } = Astro.params;
|
||||
|
||||
let post = null;
|
||||
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||
let homeData: Awaited<ReturnType<typeof apiClient.getHomePageData>> | null = null;
|
||||
|
||||
const [postResult, siteSettingsResult] = await Promise.allSettled([
|
||||
const [postResult, siteSettingsResult, homeDataResult] = await Promise.allSettled([
|
||||
apiClient.getPostBySlug(slug ?? ''),
|
||||
apiClient.getSiteSettings(),
|
||||
apiClient.getHomePageData(),
|
||||
]);
|
||||
|
||||
if (postResult.status === 'fulfilled') {
|
||||
@@ -46,10 +49,23 @@ if (siteSettingsResult.status === 'fulfilled') {
|
||||
console.error('Site settings API Error:', siteSettingsResult.reason);
|
||||
}
|
||||
|
||||
if (homeDataResult.status === 'fulfilled') {
|
||||
homeData = homeDataResult.value;
|
||||
if (siteSettingsResult.status !== 'fulfilled') {
|
||||
siteSettings = homeData.siteSettings;
|
||||
}
|
||||
} else {
|
||||
console.error('Home data API Error:', homeDataResult.reason);
|
||||
}
|
||||
|
||||
if (!post) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
if (slug && post.slug !== slug) {
|
||||
return Astro.redirect(`/articles/${post.slug}`, 301);
|
||||
}
|
||||
|
||||
const typeColor = getPostTypeColor(post.type || 'article');
|
||||
const typeTheme = getPostTypeTheme(post.type || 'article');
|
||||
const categoryTheme = getCategoryTheme(post.category);
|
||||
@@ -62,11 +78,109 @@ const paragraphCommentsEnabled = siteSettings.comments.paragraphsEnabled;
|
||||
|
||||
const markdownProcessor = await createMarkdownProcessor();
|
||||
const renderedContent = await markdownProcessor.render(articleMarkdown);
|
||||
const siteBaseUrl = siteSettings.siteUrl || new URL(Astro.request.url).origin;
|
||||
const canonicalUrl = post.canonicalUrl || new URL(`/articles/${post.slug}`, siteBaseUrl).toString();
|
||||
const ogImage = post.ogImage || `/og/${post.slug}.svg`;
|
||||
const noindex = Boolean(post.noindex || post.visibility === 'unlisted');
|
||||
const publishedAt = post.publishAt || `${post.date}T00:00:00Z`;
|
||||
const hotRelatedPosts = (homeData?.popularPosts ?? [])
|
||||
.filter((item): item is PopularPostHighlight & { post: NonNullable<PopularPostHighlight['post']> } => Boolean(item.post))
|
||||
.filter((item) => item.slug !== post.slug)
|
||||
.map((item) => {
|
||||
const sharedTags = item.post.tags.filter((tag) => post.tags.includes(tag));
|
||||
const sameCategory = item.post.category === post.category;
|
||||
const sameType = item.post.type === post.type;
|
||||
const relevance = (sameCategory ? 4 : 0) + sharedTags.length * 3 + (sameType ? 1 : 0);
|
||||
const popularityScore =
|
||||
item.pageViews + item.readCompletes * 2 + item.avgProgressPercent / 20;
|
||||
|
||||
return {
|
||||
...item,
|
||||
sharedTags,
|
||||
sameCategory,
|
||||
relevance,
|
||||
popularityScore,
|
||||
finalScore: relevance * 100 + popularityScore,
|
||||
};
|
||||
})
|
||||
.filter((item) => item.relevance > 0)
|
||||
.sort((left, right) => {
|
||||
return (
|
||||
right.finalScore - left.finalScore ||
|
||||
right.pageViews - left.pageViews ||
|
||||
right.readCompletes - left.readCompletes
|
||||
);
|
||||
})
|
||||
.slice(0, 3);
|
||||
const articleJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: post.title,
|
||||
description: post.description,
|
||||
image: [new URL(ogImage, siteBaseUrl).toString()],
|
||||
mainEntityOfPage: canonicalUrl,
|
||||
url: canonicalUrl,
|
||||
datePublished: publishedAt,
|
||||
dateModified: publishedAt,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: siteSettings.ownerName,
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: siteSettings.siteName,
|
||||
logo: siteSettings.ownerAvatarUrl
|
||||
? {
|
||||
'@type': 'ImageObject',
|
||||
url: siteSettings.ownerAvatarUrl,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
articleSection: post.category,
|
||||
keywords: post.tags,
|
||||
inLanguage: locale,
|
||||
isAccessibleForFree: true,
|
||||
wordCount,
|
||||
timeRequired: `PT${Math.max(readTimeMinutes, 1)}M`,
|
||||
};
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: siteSettings.siteName,
|
||||
item: siteBaseUrl,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: 'Articles',
|
||||
item: new URL('/articles', siteBaseUrl).toString(),
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: post.title,
|
||||
item: canonicalUrl,
|
||||
},
|
||||
],
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout title={post.title} description={post.description} siteSettings={siteSettings}>
|
||||
<BaseLayout
|
||||
title={post.title}
|
||||
description={post.description}
|
||||
siteSettings={siteSettings}
|
||||
canonical={canonicalUrl}
|
||||
noindex={noindex}
|
||||
ogImage={ogImage}
|
||||
ogType="article"
|
||||
twitterCard="summary_large_image"
|
||||
jsonLd={[articleJsonLd, breadcrumbJsonLd]}
|
||||
>
|
||||
<ReadingProgress />
|
||||
<BackToTop />
|
||||
<Lightbox />
|
||||
<CodeCopyButton />
|
||||
|
||||
@@ -143,11 +257,16 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
|
||||
<div class="ml-4 mt-4 space-y-6">
|
||||
{post.image && (
|
||||
<div class="terminal-panel-muted overflow-hidden">
|
||||
<img
|
||||
<ResponsiveImage
|
||||
src={resolveFileRef(post.image)}
|
||||
alt={post.title}
|
||||
data-lightbox-image="true"
|
||||
class="w-full h-auto rounded-xl border border-[var(--border-color)] cursor-zoom-in"
|
||||
pictureClass="block"
|
||||
imgClass="w-full h-auto rounded-xl border border-[var(--border-color)] cursor-zoom-in"
|
||||
widths={[640, 960, 1280, 1600, 1920]}
|
||||
sizes="(min-width: 1280px) 60rem, 100vw"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
lightbox={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -156,11 +275,14 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{post.images.map((image, index) => (
|
||||
<div class="terminal-panel-muted overflow-hidden">
|
||||
<img
|
||||
<ResponsiveImage
|
||||
src={resolveFileRef(image)}
|
||||
alt={`${post.title} 图片 ${index + 1}`}
|
||||
data-lightbox-image="true"
|
||||
class="h-full w-full rounded-xl border border-[var(--border-color)] object-cover cursor-zoom-in"
|
||||
pictureClass="block h-full w-full"
|
||||
imgClass="h-full w-full rounded-xl border border-[var(--border-color)] object-cover cursor-zoom-in"
|
||||
widths={[480, 720, 960, 1280]}
|
||||
sizes="(min-width: 1280px) 30vw, (min-width: 640px) 45vw, 100vw"
|
||||
lightbox={true}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -201,6 +323,103 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
|
||||
currentTags={post.tags}
|
||||
/>
|
||||
|
||||
{hotRelatedPosts.length > 0 && (
|
||||
<section class="terminal-panel mt-8">
|
||||
<div class="space-y-5">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="space-y-3">
|
||||
<span class="terminal-kicker">
|
||||
<i class="fas fa-fire"></i>
|
||||
{t('relatedPosts.hotKicker')}
|
||||
</span>
|
||||
<div class="terminal-section-title">
|
||||
<span class="terminal-section-icon">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-[var(--title-color)]">{t('relatedPosts.hotTitle')}</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
{t('relatedPosts.hotDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-signal text-[var(--primary)]"></i>
|
||||
{t('relatedPosts.linked', { count: hotRelatedPosts.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
{hotRelatedPosts.map((item) => (
|
||||
<a
|
||||
href={`/articles/${item.slug}`}
|
||||
class="terminal-panel-muted terminal-panel-accent terminal-interactive-card group flex h-full flex-col gap-4 p-4"
|
||||
style={getAccentVars(getPostTypeTheme(item.post.type))}
|
||||
>
|
||||
{item.post.image ? (
|
||||
<div class="overflow-hidden rounded-xl border border-[var(--border-color)]">
|
||||
<ResponsiveImage
|
||||
src={resolveFileRef(item.post.image)}
|
||||
alt={item.post.title}
|
||||
pictureClass="block"
|
||||
imgClass="aspect-[16/9] w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
|
||||
widths={[320, 480, 640, 960]}
|
||||
sizes="(min-width: 1280px) 18rem, (min-width: 768px) 30vw, 100vw"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="terminal-chip terminal-chip--accent px-2.5 py-1 text-xs" style={getAccentVars(getPostTypeTheme(item.post.type))}>
|
||||
{item.post.type === 'article' ? t('common.article') : t('common.tweet')}
|
||||
</span>
|
||||
<span class="terminal-chip terminal-chip--accent px-2.5 py-1 text-xs" style={getAccentVars(getCategoryTheme(item.post.category))}>
|
||||
<i class="fas fa-folder-tree text-[11px]"></i>
|
||||
{item.post.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 class="text-base font-semibold text-[var(--title-color)] group-hover:text-[var(--primary)]">
|
||||
{item.post.title}
|
||||
</h4>
|
||||
<p class="text-sm leading-7 text-[var(--text-secondary)]">{item.post.description}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto flex flex-wrap gap-2 pt-1">
|
||||
<span class="terminal-stat-pill px-2.5 py-1 text-xs">
|
||||
<i class="fas fa-eye text-[var(--primary)]"></i>
|
||||
{t('home.views')}: {item.pageViews}
|
||||
</span>
|
||||
<span class="terminal-stat-pill px-2.5 py-1 text-xs">
|
||||
<i class="fas fa-check-double text-[var(--primary)]"></i>
|
||||
{t('home.completes')}: {item.readCompletes}
|
||||
</span>
|
||||
<span class="terminal-stat-pill px-2.5 py-1 text-xs">
|
||||
<i class="fas fa-chart-line text-[var(--primary)]"></i>
|
||||
{t('home.avgProgress')}: {Math.round(item.avgProgressPercent)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{item.sharedTags.length > 0 && (
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{item.sharedTags.slice(0, 3).map((tag) => (
|
||||
<span class="terminal-chip terminal-chip--accent px-2.5 py-1 text-xs" style={getAccentVars(getTagTheme(tag))}>
|
||||
<i class="fas fa-hashtag text-[11px]"></i>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section class="mt-8">
|
||||
<Comments postSlug={post.slug} class="terminal-panel" />
|
||||
</section>
|
||||
@@ -210,3 +429,101 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<script is:inline define:vars={{ postSlug: post.slug }}>
|
||||
(() => {
|
||||
const endpoint = '/api/analytics/content';
|
||||
const sessionStorageKey = `termi:content-session:${postSlug}`;
|
||||
const startedAt = Date.now();
|
||||
let sentPageView = false;
|
||||
let lastReportedProgress = 0;
|
||||
|
||||
function ensureSessionId() {
|
||||
try {
|
||||
const existing = window.sessionStorage.getItem(sessionStorageKey);
|
||||
if (existing) return existing;
|
||||
const nextId = crypto.randomUUID();
|
||||
window.sessionStorage.setItem(sessionStorageKey, nextId);
|
||||
return nextId;
|
||||
} catch {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function getProgressPercent() {
|
||||
const doc = document.documentElement;
|
||||
const scrollTop = window.scrollY || doc.scrollTop || 0;
|
||||
const scrollHeight = Math.max(doc.scrollHeight - window.innerHeight, 1);
|
||||
return Math.max(0, Math.min(100, Math.round((scrollTop / scrollHeight) * 100)));
|
||||
}
|
||||
|
||||
function sendEvent(eventType, extras = {}, useBeacon = false) {
|
||||
const payload = JSON.stringify({
|
||||
event_type: eventType,
|
||||
path: window.location.pathname,
|
||||
post_slug: postSlug,
|
||||
session_id: ensureSessionId(),
|
||||
referrer: document.referrer || undefined,
|
||||
...extras,
|
||||
});
|
||||
|
||||
if (useBeacon && navigator.sendBeacon) {
|
||||
navigator.sendBeacon(endpoint, new Blob([payload], { type: 'application/json' }));
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload,
|
||||
keepalive: true,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
function reportProgress(forceComplete = false) {
|
||||
const progress = forceComplete ? 100 : getProgressPercent();
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const eventType = forceComplete || progress >= 95 ? 'read_complete' : 'read_progress';
|
||||
|
||||
if (!forceComplete && progress <= lastReportedProgress + 9) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastReportedProgress = Math.max(lastReportedProgress, progress);
|
||||
sendEvent(
|
||||
eventType,
|
||||
{
|
||||
progress_percent: progress,
|
||||
duration_ms: durationMs,
|
||||
metadata: {
|
||||
viewportHeight: window.innerHeight,
|
||||
},
|
||||
},
|
||||
eventType === 'read_complete',
|
||||
);
|
||||
}
|
||||
|
||||
if (!sentPageView) {
|
||||
sentPageView = true;
|
||||
sendEvent('page_view', {
|
||||
metadata: {
|
||||
title: document.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
reportProgress(false);
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
reportProgress(getProgressPercent() >= 95);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('pagehide', () => {
|
||||
reportProgress(getProgressPercent() >= 95);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user