优化互动区样式

This commit is contained in:
2026-08-27 23:39:56 +08:00
parent 504d5052bd
commit c493a0b212
6 changed files with 267 additions and 40 deletions
+2 -1
View File
@@ -6,4 +6,5 @@ dist/
.env.*
!.env.example
pnpm-debug.log*
.obsidian/
.obsidian/
.pnpm-store/
+231
View File
@@ -0,0 +1,231 @@
---
import TwikooComments from '@/components/TwikooComments.astro';
import Webmentions from '@/components/Webmentions.astro';
import { siteConfig } from '@/site.config';
interface Props {
commentsEnabled: boolean;
path: string;
target: string;
}
const { commentsEnabled, path, target } = Astro.props;
const webmentionsEnabled = siteConfig.webmentions.enabled && Boolean(siteConfig.webmentions.endpoint.trim());
const commentsConfigured = siteConfig.comments.provider === 'twikoo';
const showComments = commentsEnabled && commentsConfigured;
---
{(webmentionsEnabled || showComments) && <section
class="engagement-shell"
aria-label="文章互动"
data-engagement-tabs
data-pagefind-ignore="all"
>
<div class="engagement-tablist" role="tablist" aria-label="文章互动方式">
{webmentionsEnabled && <button
class="engagement-tab"
id="engagement-tab-webmentions"
type="button"
role="tab"
aria-controls="engagement-panel-webmentions"
aria-selected="true"
data-engagement-tab="webmentions"
>WebMention</button>}
{showComments && <button
class="engagement-tab"
id="engagement-tab-comments"
type="button"
role="tab"
aria-controls="engagement-panel-comments"
aria-selected={webmentionsEnabled ? 'false' : 'true'}
tabindex={webmentionsEnabled ? -1 : 0}
data-engagement-tab="comments"
>评论</button>}
</div>
{webmentionsEnabled && <div
class="engagement-panel"
id="engagement-panel-webmentions"
role="tabpanel"
aria-labelledby="engagement-tab-webmentions"
data-engagement-panel="webmentions"
>
<Webmentions target={target} />
</div>}
{showComments && <div
class="engagement-panel"
id="engagement-panel-comments"
role="tabpanel"
aria-labelledby="engagement-tab-comments"
data-engagement-panel="comments"
hidden={webmentionsEnabled}
>
<TwikooComments enabled={commentsEnabled} path={path} />
</div>}
</section>}
<style>
.engagement-shell {
width: min(100%, var(--article-content-width));
margin: 4.375rem auto 0;
border-top: 1px solid var(--color-border);
color: var(--color-text);
font-family: var(--reading-font-family);
font-size: .94rem;
}
.engagement-tablist {
display: flex;
align-items: center;
gap: 1.5rem;
min-height: 3.25rem;
border-bottom: 1px solid var(--color-border);
}
.engagement-tab {
position: relative;
align-self: stretch;
padding: 0 .125rem;
border: 0;
background: transparent;
color: var(--color-muted);
cursor: pointer;
font-family: inherit;
font-size: .9375rem;
font-weight: 500;
}
.engagement-tab::after {
position: absolute;
right: 0;
bottom: -1px;
left: 0;
height: 2px;
background: var(--color-accent);
content: '';
opacity: 0;
transform: scaleX(.5);
transition: opacity 160ms ease, transform 160ms ease;
}
.engagement-tab:is(:hover, [aria-selected='true']) {
color: var(--color-accent-strong);
}
.engagement-tab[aria-selected='true']::after {
opacity: 1;
transform: scaleX(1);
}
.engagement-tab:focus-visible {
border-radius: var(--radius-sm);
outline: 1px solid var(--color-accent);
outline-offset: 3px;
}
.engagement-panel {
padding-top: 1.5rem;
}
.engagement-panel[hidden] {
display: none;
}
@media (max-width: 37.5rem) {
.engagement-shell {
width: 100%;
margin-top: 3.25rem;
font-size: .9rem;
}
.engagement-tablist {
min-height: 3rem;
}
.engagement-panel {
padding-top: 1.25rem;
}
}
</style>
<script>
type EngagementTabName = 'webmentions' | 'comments';
declare global {
interface Window {
__eidolonEngagementTabsBound?: boolean;
}
}
const tabNameFromHash = (): EngagementTabName | null => {
if (window.location.hash === '#comments') return 'comments';
if (window.location.hash === '#webmentions') return 'webmentions';
return null;
};
const activateEngagementTab = (
root: HTMLElement,
name: EngagementTabName,
focus = false
) => {
const tabs = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-engagement-tab]'));
const panels = Array.from(root.querySelectorAll<HTMLElement>('[data-engagement-panel]'));
const activeTab = tabs.find((tab) => tab.dataset.engagementTab === name);
if (!activeTab) return;
for (const tab of tabs) {
const active = tab === activeTab;
tab.setAttribute('aria-selected', String(active));
tab.tabIndex = active ? 0 : -1;
}
for (const panel of panels) {
panel.hidden = panel.dataset.engagementPanel !== name;
}
if (focus) activeTab.focus();
root.dispatchEvent(new CustomEvent('engagement:tab-change', {
bubbles: true,
detail: { tab: name }
}));
};
const bindEngagementTabs = () => {
const root = document.querySelector<HTMLElement>('[data-engagement-tabs]');
if (!root || root.dataset.engagementTabsBound) return;
root.dataset.engagementTabsBound = 'true';
const tabs = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-engagement-tab]'));
const initialTab = tabNameFromHash() ?? (tabs[0]?.dataset.engagementTab as EngagementTabName | undefined);
if (initialTab) activateEngagementTab(root, initialTab);
for (const tab of tabs) {
tab.addEventListener('click', () => {
activateEngagementTab(root, tab.dataset.engagementTab as EngagementTabName);
});
tab.addEventListener('keydown', (event) => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const currentIndex = tabs.indexOf(tab);
const nextIndex = event.key === 'Home'
? 0
: event.key === 'End'
? tabs.length - 1
: (currentIndex + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
const nextName = tabs[nextIndex]?.dataset.engagementTab as EngagementTabName | undefined;
if (nextName) activateEngagementTab(root, nextName, true);
});
}
};
if (!window.__eidolonEngagementTabsBound) {
window.__eidolonEngagementTabsBound = true;
document.addEventListener('astro:page-load', bindEngagementTabs);
window.addEventListener('hashchange', () => {
const root = document.querySelector<HTMLElement>('[data-engagement-tabs]');
const tab = tabNameFromHash();
if (root && tab) activateEngagementTab(root, tab);
});
}
bindEngagementTabs();
</script>
+3
View File
@@ -39,6 +39,8 @@
const initCurrentComments = () => {
const root = document.querySelector('#twikoo-comments');
if (!(root instanceof HTMLElement) || root.dataset.twikooBound) return;
const panel = root.closest('[data-engagement-panel]');
if (panel instanceof HTMLElement && panel.hidden) return;
let config;
try {
config = JSON.parse(root.dataset.twikooConfig ?? '{}');
@@ -62,6 +64,7 @@
initCurrentComments();
document.addEventListener('astro:page-load', initCurrentComments);
document.addEventListener('engagement:tab-change', initCurrentComments);
document.addEventListener('astro:before-swap', () => {
document.querySelector('#twikoo-comments')?.replaceChildren();
});
+6 -7
View File
@@ -12,12 +12,12 @@ const config = comments.provider === 'twikoo' && comments.envId.trim() ? {
} : null;
---
{enabled && config && <section class="comments-shell" id="comments" aria-labelledby="comments-title">
<h2 id="comments-title">评论</h2>
<h2 class="sr-only" id="comments-title">评论</h2>
<div id="twikoo-comments" data-twikoo-config={JSON.stringify(config)} data-twikoo-state="idle"></div>
</section>}
{enabled && !config && <section class="comments-shell comments-preview" id="comments" aria-labelledby="comments-title" data-pagefind-ignore="all">
<h2 id="comments-title">评论</h2>
<h2 class="sr-only" id="comments-title">评论</h2>
<div class="twikoo-preview" aria-label="Twikoo 评论预览">
<p class="twikoo-preview-status">Twikoo 尚未配置</p>
<div class="twikoo-preview-fields" aria-hidden="true">
@@ -30,9 +30,8 @@ const config = comments.provider === 'twikoo' && comments.envId.trim() ? {
</section>}
<style is:global>
#comments { width: min(100%, var(--article-content-width)); margin: 4.375rem auto 0; }
#comments > h2 { margin: 0 0 1.5rem; padding-top: 1.375rem; border-top: 1px solid var(--color-border); color: var(--color-accent-strong); font-family: var(--reading-font-family); font-size: 1.5625rem; font-weight: 500; }
#twikoo-comments, #comments > #twikoo { color: var(--color-text); font-size: .94rem; }
#comments { width: 100%; margin: 0; font-family: var(--reading-font-family); }
#twikoo-comments, #comments > #twikoo { color: var(--color-text); font-family: var(--reading-font-family); font-size: .94rem; }
#twikoo-comments .tk-comments-title, #comments > #twikoo .tk-comments-title { margin: 0 0 18px; color: var(--color-text); font-family: var(--font-serif); font-size: 1.1rem; font-weight: 600; }
#twikoo-comments .tk-comment, #comments > #twikoo .tk-comment { padding: 1.375rem 0; border-bottom: 1px solid var(--color-border); }
#twikoo-comments .tk-content, #twikoo-comments .tk-comment-content, #comments > #twikoo .tk-content, #comments > #twikoo .tk-comment-content { margin-top: .5rem; color: var(--color-text); line-height: 1.75; overflow-wrap: anywhere; }
@@ -94,7 +93,7 @@ const config = comments.provider === 'twikoo' && comments.envId.trim() ? {
#comments > #twikoo .tk-submit-action-icon { color: var(--color-muted) !important; }
#comments > #twikoo .OwO-logo { color: var(--color-muted) !important; }
#comments > #twikoo .OwO-body { border: 1px solid var(--color-border) !important; border-radius: var(--radius-sm) !important; background: var(--color-surface) !important; color: var(--color-text) !important; box-shadow: var(--shadow-soft) !important; }
#comments > #twikoo .tk-send { min-width: 7rem !important; height: 2.25rem !important; border: 0 !important; border-radius: var(--radius-sm) !important; background: var(--color-accent) !important; color: var(--color-accent-contrast) !important; font-weight: 500 !important; }
#comments > #twikoo .tk-send { min-width: 7rem !important; height: 2.25rem !important; border: 0 !important; border-radius: var(--radius-sm) !important; background: var(--color-accent) !important; color: var(--color-accent-contrast) !important; font-family: var(--reading-font-family) !important; font-size: .875rem !important; font-weight: 500 !important; }
#comments > #twikoo .tk-send.is-disabled { opacity: .55 !important; }
#comments > #twikoo .tk-preview { border: 1px solid var(--color-border) !important; border-radius: var(--radius-sm) !important; background: transparent !important; color: var(--color-muted) !important; }
#comments > #twikoo .tk-comments-container { margin-top: 3rem !important; }
@@ -169,5 +168,5 @@ const config = comments.provider === 'twikoo' && comments.envId.trim() ? {
#comments > #twikoo .tk-replies .tk-comment > .tk-avatar { flex-basis: 1.75rem !important; width: 1.75rem !important; height: 1.75rem !important; margin-right: .5rem !important; }
#comments > #twikoo .OwO .OwO-body { right: 0 !important; left: auto !important; width: min(18rem, 100vw - 2rem) !important; }
}
@media (max-width: 37.5rem) { #comments { margin-top: 3.25rem; } #twikoo-comments .tk-avatar, #twikoo-comments .tk-comment-avatar { width: 1.875rem; height: 1.875rem; } }
@media (max-width: 37.5rem) { #twikoo-comments .tk-avatar, #twikoo-comments .tk-comment-avatar { width: 1.875rem; height: 1.875rem; } }
</style>
+19 -28
View File
@@ -1,5 +1,4 @@
---
import { Send } from '@lucide/astro';
import { getWebmentionEndpoint } from '@/lib/webmentions';
import { siteConfig } from '@/site.config';
@@ -25,7 +24,7 @@ const getEndpoint = endpoint ? getWebmentionEndpoint(endpoint, 'get') : '';
data-site-origin={new URL(siteConfig.site.url).origin}
data-pagefind-ignore="all"
>
<h2 id="webmentions-title">WebMention</h2>
<h2 class="sr-only" id="webmentions-title">WebMention</h2>
<div class="webmentions-loading" data-webmentions-loading role="status" aria-label="正在读取 WebMention">
<svg class="webmentions-loading-spinner" viewBox="25 25 50 50" aria-hidden="true">
@@ -56,10 +55,7 @@ const getEndpoint = endpoint ? getWebmentionEndpoint(endpoint, 'get') : '';
<div class="webmention-form-row">
<input id="webmention-source" name="source" type="url" inputmode="url" autocomplete="url" placeholder="https://example.com/your-post" required />
<input name="target" type="hidden" value={target} />
<button type="submit">
<Send size={16} stroke-width={1.8} aria-hidden="true" />
<span>发送</span>
</button>
<button type="submit">发送</button>
</div>
<p class="webmention-form-status" data-webmention-form-status role="status" aria-live="polite"></p>
</form>}
@@ -67,18 +63,8 @@ const getEndpoint = endpoint ? getWebmentionEndpoint(endpoint, 'get') : '';
<style>
.webmentions-shell {
width: min(100%, var(--article-content-width));
margin: 4.375rem auto 0;
}
.webmentions-shell > h2 {
margin: 0 0 1.5rem;
padding-top: 1.375rem;
border-top: 1px solid var(--color-border);
color: var(--color-accent-strong);
font-family: var(--reading-font-family);
font-size: 1.5625rem;
font-weight: 500;
font-size: .94rem;
}
.webmentions-status,
@@ -163,7 +149,7 @@ const getEndpoint = endpoint ? getWebmentionEndpoint(endpoint, 'get') : '';
:global(.webmention-reaction) {
display: grid;
width: 2.25rem;
height: 2.25rem;
height: 2.125rem;
place-items: center;
overflow: hidden;
border: 1px solid var(--color-border);
@@ -254,7 +240,7 @@ const getEndpoint = endpoint ? getWebmentionEndpoint(endpoint, 'get') : '';
:global(.webmention-author) {
color: var(--color-text);
font-size: .875rem;
font-size: .8125rem;
font-weight: 500;
overflow-wrap: anywhere;
}
@@ -308,12 +294,15 @@ const getEndpoint = endpoint ? getWebmentionEndpoint(endpoint, 'get') : '';
.webmention-form input[type='url'] {
min-width: 0;
flex: 1 1 auto;
height: 2.5rem;
padding: .5rem .75rem;
height: 2.125rem;
padding: .438rem .525rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font-family: inherit;
font-size: .8125rem;
box-shadow: none;
}
.webmention-form input[type='url']:focus {
@@ -326,15 +315,17 @@ const getEndpoint = endpoint ? getWebmentionEndpoint(endpoint, 'get') : '';
flex: 0 0 auto;
align-items: center;
justify-content: center;
gap: .4rem;
min-height: 2.5rem;
padding: .45rem .85rem;
border: 1px solid var(--color-accent-strong);
min-width: 7rem;
height: 2.25rem;
padding: 0 .875rem;
border: 0;
border-radius: var(--radius-sm);
background: var(--color-accent);
color: var(--color-accent-contrast);
cursor: pointer;
font-weight: 600;
font-family: inherit;
font-size: .875rem;
font-weight: 500;
}
.webmention-form button:disabled {
@@ -349,8 +340,7 @@ const getEndpoint = endpoint ? getWebmentionEndpoint(endpoint, 'get') : '';
@media (max-width: 37.5rem) {
.webmentions-shell {
width: 100%;
margin-top: 3.25rem;
font-size: .9rem;
}
.webmention-form-row {
@@ -360,6 +350,7 @@ const getEndpoint = endpoint ? getWebmentionEndpoint(endpoint, 'get') : '';
.webmention-form button {
align-self: flex-end;
min-width: 5.5rem;
}
}
</style>
+6 -4
View File
@@ -4,11 +4,10 @@ import BaseLayout from '@/layouts/BaseLayout.astro';
import Masthead from '@/components/Masthead.astro';
import { getContentCacheKey, getExcerpt, getPostCardImage, getPostPath, getPostTaxonomy, getPublicPosts, slugify } from '@/lib/content';
import { siteConfig } from '@/site.config';
import TwikooComments from '@/components/TwikooComments.astro';
import EngagementTabs from '@/components/EngagementTabs.astro';
import TableOfContents from '@/components/TableOfContents.astro';
import Mermaid from '@/components/Mermaid.astro';
import ImageLightbox from '@/components/ImageLightbox.astro';
import Webmentions from '@/components/Webmentions.astro';
export async function getStaticPaths() {
const posts = await getPublicPosts();
return posts.map((post, index) => ({
@@ -49,8 +48,11 @@ const dateText = `${date.year} 年 ${date.month} 月 ${date.day} 日`;
{older ? <a class="grid min-h-24 content-center p-4 no-underline sm:border-r sm:border-line" href={getPostPath(older)}><span class="text-xs text-subtle">上一篇</span><strong class="mt-1 font-sans text-base font-normal">{older.data.title}</strong></a> : <span class="grid min-h-24 content-center p-4 sm:border-r sm:border-line"><span class="text-xs text-subtle">上一篇</span><strong class="mt-1 font-sans text-base font-normal">没有更多了</strong></span>}
{newer ? <a class="grid min-h-24 content-center border-t border-line p-4 no-underline sm:border-t-0 sm:text-right" href={getPostPath(newer)}><span class="text-xs text-subtle">下一篇</span><strong class="mt-1 font-sans text-base font-normal">{newer.data.title}</strong></a> : <span class="grid min-h-24 content-center border-t border-line p-4 sm:border-t-0 sm:text-right"><span class="text-xs text-subtle">下一篇</span><strong class="mt-1 font-sans text-base font-normal">没有更多了</strong></span>}
</nav>
<Webmentions target={new URL(Astro.url.pathname, siteConfig.site.url).href} />
<TwikooComments enabled={post.data.comments} path={Astro.url.pathname} />
<EngagementTabs
commentsEnabled={post.data.comments}
path={Astro.url.pathname}
target={new URL(Astro.url.pathname, siteConfig.site.url).href}
/>
</article>
<Mermaid enabled={post.data.mermaid} />
</BaseLayout>