@windcoder/astro-theme-orchid 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +311 -2
- package/package.json +68 -3
- package/src/components/AlbumCard.astro +73 -0
- package/src/components/Analytics.astro +22 -0
- package/src/components/BaseLayout.astro +188 -0
- package/src/components/Calendar.astro +379 -0
- package/src/components/CategoryBar.astro +74 -0
- package/src/components/Comment.astro +48 -0
- package/src/components/EncryptedContent.astro +42 -0
- package/src/components/Footer.astro +31 -0
- package/src/components/Header.astro +153 -0
- package/src/components/License.astro +29 -0
- package/src/components/Live2DWidget.astro +262 -0
- package/src/components/MusicPlayer.astro +940 -0
- package/src/components/OutdatedCard.astro +53 -0
- package/src/components/Pagination.astro +94 -0
- package/src/components/PhotoCard.astro +22 -0
- package/src/components/PostCard.astro +89 -0
- package/src/components/PostNav.astro +40 -0
- package/src/components/RecommendedPost.astro +166 -0
- package/src/components/SakuraEffect.astro +152 -0
- package/src/components/Sidebar.astro +334 -0
- package/src/components/Toc.astro +27 -0
- package/src/components/analytics/GoogleAnalytics.astro +22 -0
- package/src/components/analytics/La51Analytics.astro +52 -0
- package/src/components/analytics/MicrosoftClarity.astro +18 -0
- package/src/components/analytics/UmamiAnalytics.astro +86 -0
- package/src/components/comments/Artalk.astro +51 -0
- package/src/components/comments/Disqus.astro +43 -0
- package/src/components/comments/Giscus.astro +50 -0
- package/src/components/comments/Twikoo.astro +65 -0
- package/src/components/comments/Waline.astro +36 -0
- package/src/i18n/en.ts +164 -0
- package/src/i18n/index.ts +18 -0
- package/src/i18n/resolve.ts +49 -0
- package/src/i18n/zh-CN.ts +184 -0
- package/src/icons.ts +71 -0
- package/src/index.ts +406 -0
- package/src/plugins/plantuml-encoder.ts +95 -0
- package/src/plugins/rehype-diagram.ts +218 -0
- package/src/plugins/rehype-email.ts +75 -0
- package/src/plugins/rehype-github-card.ts +114 -0
- package/src/plugins/rehype.ts +284 -0
- package/src/plugins/remark-diagram.ts +78 -0
- package/src/plugins/remark-directives.ts +100 -0
- package/src/plugins/remark-excerpt.ts +29 -0
- package/src/plugins/remark-image-grid.ts +97 -0
- package/src/plugins/remark-reading-time.ts +38 -0
- package/src/plugins/scripts/diagram-panzoom.js +388 -0
- package/src/plugins/scripts/plantuml-theme-switch.js +90 -0
- package/src/routes/404.astro +14 -0
- package/src/routes/_hydrate.ts +34 -0
- package/src/routes/about.astro +25 -0
- package/src/routes/api/allPostMeta.json.ts +26 -0
- package/src/routes/archive.astro +81 -0
- package/src/routes/categories/[category].astro +37 -0
- package/src/routes/categories/index.astro +29 -0
- package/src/routes/friends.astro +83 -0
- package/src/routes/gallery/[album].astro +97 -0
- package/src/routes/gallery/index.astro +100 -0
- package/src/routes/guestbook.astro +37 -0
- package/src/routes/index.astro +47 -0
- package/src/routes/og/[...slug].ts +281 -0
- package/src/routes/page/[page].astro +53 -0
- package/src/routes/posts/[...slug].astro +337 -0
- package/src/routes/robots.txt.ts +18 -0
- package/src/routes/rss.xml.ts +37 -0
- package/src/routes/series/[name].astro +63 -0
- package/src/routes/series/index.astro +64 -0
- package/src/routes/sponsor.astro +84 -0
- package/src/routes/tags/[tag].astro +40 -0
- package/src/routes/tags/index.astro +29 -0
- package/src/schema.ts +1212 -0
- package/src/scripts/app.ts +1041 -0
- package/src/scripts/share-poster.ts +468 -0
- package/src/styles/global.css +4830 -0
- package/src/utils/crypto.ts +46 -0
- package/src/utils/gallery.ts +76 -0
- package/src/utils/lqip.ts +18 -0
- package/src/utils/related.ts +101 -0
- package/src/utils.ts +181 -0
- package/src/vite.ts +105 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* 文章过时提醒卡片(orchid-blog showLastModified 卡片移植)。
|
|
4
|
+
* 构建期输出 hidden 卡片 + data-*(日期/阈值/天数文案模板);
|
|
5
|
+
* 天数由客户端计算(静态站构建期计算会随访问时间失效),超过阈值才显示。
|
|
6
|
+
* enabled 单点裁决(与 License.astro 模式一致)。
|
|
7
|
+
*/
|
|
8
|
+
import config from "virtual:theme/config";
|
|
9
|
+
import { t } from "../i18n/index.js";
|
|
10
|
+
import { formatDate } from "../utils.js";
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
/** 最后更新时间(调用方传 updated ?? published) */
|
|
14
|
+
updated: Date;
|
|
15
|
+
published: Date;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { updated, published } = Astro.props;
|
|
19
|
+
const o = config.outdated;
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
o.enabled && (
|
|
24
|
+
<div class="outdated-card" id="outdated-card" hidden data-date={updated.toISOString()} data-threshold={o.threshold}>
|
|
25
|
+
<div class="outdated-icon" aria-hidden="true">
|
|
26
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M12 7v5l4 2"></path></svg>
|
|
27
|
+
</div>
|
|
28
|
+
<div class="outdated-text">
|
|
29
|
+
<p class="outdated-line">
|
|
30
|
+
{t('lastModifiedPrefix')}{formatDate(updated)}
|
|
31
|
+
<span class="outdated-days" data-template={t('lastModifiedDaysAgo')}></span>
|
|
32
|
+
</p>
|
|
33
|
+
<p class="outdated-warning" hidden>{t('lastModifiedOutdated')}</p>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
<script is:inline data-astro-rerun>
|
|
40
|
+
(function () {
|
|
41
|
+
const card = document.getElementById("outdated-card");
|
|
42
|
+
if (!card) return;
|
|
43
|
+
const days = Math.floor((Date.now() - new Date(card.dataset.date).getTime()) / 86400000);
|
|
44
|
+
if (days < Number(card.dataset.threshold)) return;
|
|
45
|
+
card.hidden = false;
|
|
46
|
+
if (days >= 1) {
|
|
47
|
+
const el = card.querySelector(".outdated-days");
|
|
48
|
+
if (el) el.textContent = (el.dataset.template || "").replace("{days}", String(days));
|
|
49
|
+
}
|
|
50
|
+
const warn = card.querySelector(".outdated-warning");
|
|
51
|
+
if (warn) warn.hidden = false;
|
|
52
|
+
})();
|
|
53
|
+
</script>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { t } from "../i18n/index.js";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
/** 当前页(从 1 开始) */
|
|
6
|
+
page: number;
|
|
7
|
+
/** 总页数 */
|
|
8
|
+
total: number;
|
|
9
|
+
/** 列表基路径,如 '/'、'/tags/xx/'(带尾斜杠) */
|
|
10
|
+
basePath: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { page, total, basePath } = Astro.props;
|
|
14
|
+
const hrefFor = (p: number) =>
|
|
15
|
+
p === 1 ? basePath : `${basePath.replace(/\/$/, "")}/page/${p}/`;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 页码序列(§4.53 PC 页码翻页,§4.54 加大窗口):恒含首页/末页/当前页及前后各两页,
|
|
19
|
+
* 间隙以省略号占位——如 total=12、page=6 → 1 … 4 5 6 7 8 … 12
|
|
20
|
+
*/
|
|
21
|
+
const pageNums = (): (number | "ellipsis")[] => {
|
|
22
|
+
const kept = new Set(
|
|
23
|
+
[1, total, page - 2, page - 1, page, page + 1, page + 2].filter(
|
|
24
|
+
(p) => p >= 1 && p <= total,
|
|
25
|
+
),
|
|
26
|
+
);
|
|
27
|
+
const sorted = [...kept].sort((a, b) => a - b);
|
|
28
|
+
const out: (number | "ellipsis")[] = [];
|
|
29
|
+
let prev = 0;
|
|
30
|
+
for (const p of sorted) {
|
|
31
|
+
if (p - prev > 1) out.push("ellipsis");
|
|
32
|
+
out.push(p);
|
|
33
|
+
prev = p;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
};
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
<nav class="pagination reveal" aria-label={t('pagination')}>
|
|
40
|
+
{/* PC(≥768px):页码条,两端箭头翻页 */}
|
|
41
|
+
<div class="pg-nums">
|
|
42
|
+
{
|
|
43
|
+
page > 1 ? (
|
|
44
|
+
<a class="page-item" href={hrefFor(page - 1)} aria-label={t('prevPage')}>
|
|
45
|
+
‹
|
|
46
|
+
</a>
|
|
47
|
+
) : (
|
|
48
|
+
<span class="page-item disabled">‹</span>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
{
|
|
52
|
+
pageNums().map((n) =>
|
|
53
|
+
n === "ellipsis" ? (
|
|
54
|
+
<span class="pg-gap">…</span>
|
|
55
|
+
) : n === page ? (
|
|
56
|
+
<span class="page-item current" aria-current="page">{n}</span>
|
|
57
|
+
) : (
|
|
58
|
+
<a class="page-item" href={hrefFor(n)}>{n}</a>
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
{
|
|
63
|
+
page < total ? (
|
|
64
|
+
<a class="page-item" href={hrefFor(page + 1)} aria-label={t('nextPage')}>
|
|
65
|
+
›
|
|
66
|
+
</a>
|
|
67
|
+
) : (
|
|
68
|
+
<span class="page-item disabled">›</span>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
</div>
|
|
72
|
+
{/* 移动端(<768px):上一页 / 第 x / N 页 / 下一页 */}
|
|
73
|
+
<div class="pg-adj">
|
|
74
|
+
{
|
|
75
|
+
page > 1 ? (
|
|
76
|
+
<a class="page-item" href={hrefFor(page - 1)}>
|
|
77
|
+
{t('prevPage')}
|
|
78
|
+
</a>
|
|
79
|
+
) : (
|
|
80
|
+
<span class="page-item disabled">{t('prevPage')}</span>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
<span>{t('pageOf', { current: page, total })}</span>
|
|
84
|
+
{
|
|
85
|
+
page < total ? (
|
|
86
|
+
<a class="page-item" href={hrefFor(page + 1)}>
|
|
87
|
+
{t('nextPage')}
|
|
88
|
+
</a>
|
|
89
|
+
) : (
|
|
90
|
+
<span class="page-item disabled">{t('nextPage')}</span>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
</div>
|
|
94
|
+
</nav>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* 照片卡(M3b-b8):详情页瀑布流单元。
|
|
4
|
+
* data-lightbox-item + 外层 data-lightbox-group 容器(路由页提供)
|
|
5
|
+
* 供灯箱(PhotoSwipe,§4.69)点击组浏览(app.ts 绑定委托,加密解密注入后经
|
|
6
|
+
* showDecrypted → initLightbox 重预置生效)。
|
|
7
|
+
*/
|
|
8
|
+
import { getLqipStyle } from "../utils/lqip.js";
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
src: string;
|
|
12
|
+
alt?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { src, alt = "" } = Astro.props;
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
<figure class="gallery-photo" data-lightbox-item>
|
|
19
|
+
{/* LQIP 占位渐变(b9.5):src 支持外链,getLqipStyle 查不到时降级纯色 */}
|
|
20
|
+
<div class="qy-lqip" style={getLqipStyle(src)} aria-hidden="true"></div>
|
|
21
|
+
<img src={src} alt={alt} loading="lazy" decoding="async" data-lqip />
|
|
22
|
+
</figure>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
import config from "virtual:theme/config";
|
|
3
|
+
import { t } from "../i18n/index.js";
|
|
4
|
+
import { getLqipStyle } from "../utils/lqip.js";
|
|
5
|
+
import { formatDate, type PostLike, resolveCover } from "../utils.js";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
post: PostLike;
|
|
9
|
+
/** 置顶/特写形态(大图卡 + 置顶徽标) */
|
|
10
|
+
featured?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { post, featured = false } = Astro.props;
|
|
14
|
+
const d = post.data;
|
|
15
|
+
|
|
16
|
+
/* 形态判定:frontmatter.cardStyle 按篇覆盖 > 全局 postList.style;无图可降级为纯文字 */
|
|
17
|
+
const style = d.cardStyle ?? config.postList.style;
|
|
18
|
+
/* 封面(§4.33):image:"api" 时经图源解析出确定性随机图,并携带客户端重试列表;
|
|
19
|
+
§4.55 showCover=false 时整体关闭列表封面(classes/模板自动降级纯文字形态) */
|
|
20
|
+
const { cover: rawCover, retries } = resolveCover(
|
|
21
|
+
d.image,
|
|
22
|
+
d.firstImage,
|
|
23
|
+
config.postList.fallbackCover,
|
|
24
|
+
config.postList.randomCover.apis,
|
|
25
|
+
post.id,
|
|
26
|
+
);
|
|
27
|
+
const cover = config.postList.showCover ? rawCover : null;
|
|
28
|
+
const classes = [
|
|
29
|
+
"post-card",
|
|
30
|
+
featured ? "featured" : "",
|
|
31
|
+
cover ? "has-cover" : "no-cover",
|
|
32
|
+
style === "cover" ? "" : "compact",
|
|
33
|
+
style !== "cover" && !cover ? "text-only" : "",
|
|
34
|
+
]
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.join(" ");
|
|
37
|
+
|
|
38
|
+
const href = `/posts/${post.id}/`;
|
|
39
|
+
const excerpt = d.excerpt || d.description;
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
<article class={classes}>
|
|
43
|
+
{
|
|
44
|
+
cover && (
|
|
45
|
+
<a class="post-cover" href={href} aria-label={d.title}>
|
|
46
|
+
{featured && d.pinned && <span class="pin-badge">{t('pinned')}</span>}
|
|
47
|
+
{/* LQIP 占位渐变(b9.5):绝对定位盖在 img 上,加载完成后由 app.ts 淡出 */}
|
|
48
|
+
<div class="qy-lqip" style={getLqipStyle(cover)} aria-hidden="true"></div>
|
|
49
|
+
<img
|
|
50
|
+
src={cover}
|
|
51
|
+
alt={d.title}
|
|
52
|
+
loading="lazy"
|
|
53
|
+
data-lqip
|
|
54
|
+
data-api-retries={retries ? JSON.stringify(retries) : undefined}
|
|
55
|
+
/>
|
|
56
|
+
</a>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
<div class="post-body">
|
|
60
|
+
<h2 class="post-title"><a href={href}>{d.title}</a></h2>
|
|
61
|
+
{excerpt && <p class="post-excerpt">{excerpt}</p>}
|
|
62
|
+
<div class="post-meta">
|
|
63
|
+
<span>{formatDate(d.published)}</span>
|
|
64
|
+
{d.category && <span class="dot">{d.category}</span>}
|
|
65
|
+
{d.password && <span class="dot enc-flag">{t('postEncrypted')}</span>}
|
|
66
|
+
{d.words ? <span class="dot">{t('wordsCount', { n: d.words })}</span> : null}
|
|
67
|
+
{d.minutes ? <span class="dot">{t('minutesCount', { n: d.minutes })}</span> : null}
|
|
68
|
+
</div>
|
|
69
|
+
{
|
|
70
|
+
d.tags.length > 0 && (
|
|
71
|
+
<div class="post-tags">
|
|
72
|
+
{d.tags.map((tag) => (
|
|
73
|
+
<a class="chip" href={`/tags/${encodeURIComponent(tag)}/`}>
|
|
74
|
+
{tag}
|
|
75
|
+
</a>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
</div>
|
|
81
|
+
{
|
|
82
|
+
/* 无封面卡(§4.34 迭代):右侧进入按钮竖条,为正文右侧留白,避免文字铺满/挤压 */
|
|
83
|
+
!cover && (
|
|
84
|
+
<a class="post-enter" href={href} aria-label={d.title}>
|
|
85
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
|
|
86
|
+
</a>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
</article>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { t } from "../i18n/index.js";
|
|
3
|
+
import type { PostLike } from "../utils.js";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
prev?: PostLike;
|
|
7
|
+
next?: PostLike;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { prev, next } = Astro.props;
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
<nav class="post-nav" aria-label={t('prevNext')}>
|
|
14
|
+
{
|
|
15
|
+
prev ? (
|
|
16
|
+
<a href={`/posts/${prev.id}/`}>
|
|
17
|
+
<div class="pn-label">{t('prevPost')}</div>
|
|
18
|
+
<div class="pn-title">{prev.data.title}</div>
|
|
19
|
+
</a>
|
|
20
|
+
) : (
|
|
21
|
+
<div class="pn-disabled">
|
|
22
|
+
<div class="pn-label">{t('prevPost')}</div>
|
|
23
|
+
<div class="pn-title">{t('firstPost')}</div>
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
{
|
|
28
|
+
next ? (
|
|
29
|
+
<a class="next" href={`/posts/${next.id}/`}>
|
|
30
|
+
<div class="pn-label">{t('nextPost')}</div>
|
|
31
|
+
<div class="pn-title">{next.data.title}</div>
|
|
32
|
+
</a>
|
|
33
|
+
) : (
|
|
34
|
+
<div class="pn-disabled">
|
|
35
|
+
<div class="pn-label">{t('nextPost')}</div>
|
|
36
|
+
<div class="pn-title">{t('lastPost')}</div>
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
</nav>
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { t } from "../i18n/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* 相关/随机文章推荐(P1-12,移植自 orchid-blog RecommendedPost)。
|
|
5
|
+
* 左栏:构建期静态渲染的相关文章(getRelatedPosts 评分);
|
|
6
|
+
* 右栏:客户端 fetch /api/allPostMeta.json 随机抽取(Fisher-Yates),
|
|
7
|
+
* 加密文章已在 json 侧排除;<random-posts> 为 VT 安全 custom element
|
|
8
|
+
* (is:inline + customElements.get 防重复 define,VT 换页自动 re-connect)。
|
|
9
|
+
*/
|
|
10
|
+
import type { PostLike } from "../utils.js";
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
related: PostLike[];
|
|
14
|
+
currentId: string;
|
|
15
|
+
randomCount?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { related, currentId, randomCount = 5 } = Astro.props;
|
|
19
|
+
const excludeIds = JSON.stringify([currentId, ...related.map((p) => p.id)]);
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
<div class="related-grid">
|
|
23
|
+
<div class="related-card">
|
|
24
|
+
<div class="related-head">
|
|
25
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="m21.66 9.65-6.87-6.87a1.03 1.03 0 0 0-1.59.16l-2.88 4.5-4.5 2.88a1.03 1.03 0 0 0-.16 1.59l6.87 6.87c.4.4 1.07.4 1.47 0l7.66-7.66c.4-.4.4-1.07 0-1.47M8.6 9.31 4.19 6.4l2.24-2.24 2.93 4.43zm6.09 6.09 2.24-2.24-4.43-2.93-2.91 4.41z"></path></svg>
|
|
26
|
+
<span>{t('relatedPosts')}</span>
|
|
27
|
+
<span class="related-badge">{t('smartRecommend')}</span>
|
|
28
|
+
</div>
|
|
29
|
+
{related.length > 0 ? (
|
|
30
|
+
<div class="related-list">
|
|
31
|
+
{related.map((p, idx) => (
|
|
32
|
+
<a class="related-item" href={`/posts/${p.id}/`}>
|
|
33
|
+
<span class="related-num">{idx + 1}</span>
|
|
34
|
+
<span class="related-body">
|
|
35
|
+
<span class="related-title">{p.data.title}</span>
|
|
36
|
+
<span class="related-meta">
|
|
37
|
+
{p.data.category && <span class="related-cat">{p.data.category}</span>}
|
|
38
|
+
<span class="related-desc">{p.data.description || p.data.published.toISOString().substring(0, 10)}</span>
|
|
39
|
+
</span>
|
|
40
|
+
</span>
|
|
41
|
+
<svg class="related-arrow" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M9.29 6.71a1 1 0 0 0 0 1.41L13.17 12l-3.88 3.88a1 1 0 1 0 1.41 1.41l4.59-4.59a1 1 0 0 0 0-1.41L10.7 6.7a1 1 0 0 0-1.41.01z"></path></svg>
|
|
42
|
+
</a>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
) : (
|
|
46
|
+
<div class="related-empty">
|
|
47
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M19 5v14H5V5zm0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2m-5 4H7v2h7zm2 4H7v2h9z"></path></svg>
|
|
48
|
+
<span>{t('noRelatedPosts')}</span>
|
|
49
|
+
</div>
|
|
50
|
+
)}
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div class="related-card">
|
|
54
|
+
<div class="related-head">
|
|
55
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18 7h1.5a2.5 2.5 0 0 1 0 5H18v1h2v1h-2v2h-1V7h3v1h-2zm-7 1.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5S12.5 10.83 12.5 10s-.67-1.5-1.5-1.5M18 2H6a2 2 0 0 0-2 2v16c0 1.1.9 2 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2m0 18H6V4h12z" transform="scale(.9) translate(1.2 1.2)"></path><path d="M12.01 12.46c-.89 0-1.61.72-1.61 1.61s.72 1.61 1.61 1.61 1.61-.72 1.61-1.61-.72-1.61-1.61-1.61M6 2h12a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2m1 4v2h10V6zm1.6 8.07c0 .89.72 1.61 1.61 1.61s1.61-.72 1.61-1.61-.72-1.61-1.61-1.61-1.61.72-1.61 1.61"></path></svg>
|
|
56
|
+
<span>{t('randomPosts')}</span>
|
|
57
|
+
<span class="related-badge">{t('randomRecommend')}</span>
|
|
58
|
+
</div>
|
|
59
|
+
<random-posts
|
|
60
|
+
class="related-list"
|
|
61
|
+
data-api-url="/api/allPostMeta.json"
|
|
62
|
+
data-current-id={currentId}
|
|
63
|
+
data-exclude-ids={excludeIds}
|
|
64
|
+
data-count={randomCount}
|
|
65
|
+
data-empty-html={t('noRandomPosts')}
|
|
66
|
+
>
|
|
67
|
+
<div class="related-empty">
|
|
68
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M19 5v14H5V5zm0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2m-5 4H7v2h7zm2 4H7v2h9z"></path></svg>
|
|
69
|
+
<span>{t('noRandomPosts')}</span>
|
|
70
|
+
</div>
|
|
71
|
+
</random-posts>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<script is:inline>
|
|
76
|
+
if (!customElements.get('random-posts')) {
|
|
77
|
+
customElements.define(
|
|
78
|
+
'random-posts',
|
|
79
|
+
class extends HTMLElement {
|
|
80
|
+
connectedCallback() {
|
|
81
|
+
// 随机抽取每次连接重跑:VT 往返后重新洗牌是期望行为
|
|
82
|
+
const apiUrl = this.dataset.apiUrl;
|
|
83
|
+
const excludeIds = JSON.parse(this.dataset.excludeIds || '[]');
|
|
84
|
+
const count = parseInt(this.dataset.count || '5', 10);
|
|
85
|
+
const emptyHtml = this.dataset.emptyHtml || '';
|
|
86
|
+
|
|
87
|
+
const render = (allPosts) => {
|
|
88
|
+
const candidates = allPosts.filter((p) => !excludeIds.includes(p.id));
|
|
89
|
+
// Fisher-Yates 洗牌后取前 count 个
|
|
90
|
+
for (let i = candidates.length - 1; i > 0; i--) {
|
|
91
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
92
|
+
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
|
|
93
|
+
}
|
|
94
|
+
const picked = candidates.slice(0, count);
|
|
95
|
+
this.innerHTML = '';
|
|
96
|
+
if (picked.length === 0) {
|
|
97
|
+
const empty = document.createElement('div');
|
|
98
|
+
empty.className = 'related-empty';
|
|
99
|
+
empty.innerHTML =
|
|
100
|
+
'<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M19 5v14H5V5zm0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2m-5 4H7v2h7zm2 4H7v2h9z"></path></svg><span></span>';
|
|
101
|
+
empty.querySelector('span').textContent = emptyHtml;
|
|
102
|
+
this.appendChild(empty);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const frag = document.createDocumentFragment();
|
|
106
|
+
picked.forEach((post, idx) => {
|
|
107
|
+
const a = document.createElement('a');
|
|
108
|
+
a.className = 'related-item';
|
|
109
|
+
a.href = '/posts/' + post.id + '/';
|
|
110
|
+
const num = document.createElement('span');
|
|
111
|
+
num.className = 'related-num';
|
|
112
|
+
num.textContent = String(idx + 1);
|
|
113
|
+
const body = document.createElement('span');
|
|
114
|
+
body.className = 'related-body';
|
|
115
|
+
const title = document.createElement('span');
|
|
116
|
+
title.className = 'related-title';
|
|
117
|
+
title.textContent = post.title;
|
|
118
|
+
const meta = document.createElement('span');
|
|
119
|
+
meta.className = 'related-meta';
|
|
120
|
+
if (post.category) {
|
|
121
|
+
const cat = document.createElement('span');
|
|
122
|
+
cat.className = 'related-cat';
|
|
123
|
+
cat.textContent = post.category;
|
|
124
|
+
meta.appendChild(cat);
|
|
125
|
+
}
|
|
126
|
+
const desc = document.createElement('span');
|
|
127
|
+
desc.className = 'related-desc';
|
|
128
|
+
desc.textContent = post.description || new Date(post.published).toISOString().substring(0, 10);
|
|
129
|
+
meta.appendChild(desc);
|
|
130
|
+
body.appendChild(title);
|
|
131
|
+
body.appendChild(meta);
|
|
132
|
+
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
133
|
+
arrow.setAttribute('class', 'related-arrow');
|
|
134
|
+
arrow.setAttribute('viewBox', '0 0 24 24');
|
|
135
|
+
arrow.setAttribute('fill', 'currentColor');
|
|
136
|
+
arrow.setAttribute('aria-hidden', 'true');
|
|
137
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
138
|
+
path.setAttribute('d', 'M9.29 6.71a1 1 0 0 0 0 1.41L13.17 12l-3.88 3.88a1 1 0 1 0 1.41 1.41l4.59-4.59a1 1 0 0 0 0-1.41L10.7 6.7a1 1 0 0 0-1.41.01z');
|
|
139
|
+
arrow.appendChild(path);
|
|
140
|
+
a.appendChild(num);
|
|
141
|
+
a.appendChild(body);
|
|
142
|
+
a.appendChild(arrow);
|
|
143
|
+
frag.appendChild(a);
|
|
144
|
+
});
|
|
145
|
+
this.appendChild(frag);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// 缓存复用:VT 导航往返不重复请求
|
|
149
|
+
if (window.__qyAllPostMetaCache) {
|
|
150
|
+
render(window.__qyAllPostMetaCache);
|
|
151
|
+
} else {
|
|
152
|
+
fetch(apiUrl)
|
|
153
|
+
.then((r) => r.json())
|
|
154
|
+
.then((data) => {
|
|
155
|
+
window.__qyAllPostMetaCache = data;
|
|
156
|
+
render(data);
|
|
157
|
+
})
|
|
158
|
+
.catch(() => {
|
|
159
|
+
/* 索引缺失等场景静默保持占位 */
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
</script>
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* 樱花飘落特效(§4.30):全站 canvas 粒子层,程序化贝塞尔花瓣(零贴图资产)。
|
|
4
|
+
* - prefers-reduced-motion 自动停用
|
|
5
|
+
* - 页签隐藏暂停、可见时恢复
|
|
6
|
+
* - VT swap 后 body 重建,经 astro:page-load 重新初始化(模块级单例清理旧动画)
|
|
7
|
+
*/
|
|
8
|
+
interface Props {
|
|
9
|
+
/** 同屏花瓣数(schema sakura.density) */
|
|
10
|
+
density?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { density = 24 } = Astro.props;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<canvas id="sakura-canvas" data-density={density} aria-hidden="true"></canvas>
|
|
17
|
+
|
|
18
|
+
<style>
|
|
19
|
+
#sakura-canvas {
|
|
20
|
+
position: fixed;
|
|
21
|
+
inset: 0;
|
|
22
|
+
width: 100%;
|
|
23
|
+
height: 100%;
|
|
24
|
+
pointer-events: none;
|
|
25
|
+
z-index: 90;
|
|
26
|
+
}
|
|
27
|
+
</style>
|
|
28
|
+
|
|
29
|
+
<script>
|
|
30
|
+
let raf = 0;
|
|
31
|
+
let cleanup: (() => void) | null = null;
|
|
32
|
+
|
|
33
|
+
function startSakura() {
|
|
34
|
+
// 清理旧实例(VT swap 前的动画循环与监听)
|
|
35
|
+
cleanup?.();
|
|
36
|
+
|
|
37
|
+
const canvas = document.getElementById(
|
|
38
|
+
"sakura-canvas",
|
|
39
|
+
) as HTMLCanvasElement | null;
|
|
40
|
+
if (!canvas) return;
|
|
41
|
+
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
42
|
+
|
|
43
|
+
const ctx = canvas.getContext("2d");
|
|
44
|
+
if (!ctx) return;
|
|
45
|
+
|
|
46
|
+
const count = Number(canvas.dataset.density) || 24;
|
|
47
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
48
|
+
|
|
49
|
+
let w = 0;
|
|
50
|
+
let h = 0;
|
|
51
|
+
const resize = () => {
|
|
52
|
+
w = window.innerWidth;
|
|
53
|
+
h = window.innerHeight;
|
|
54
|
+
canvas.width = w * dpr;
|
|
55
|
+
canvas.height = h * dpr;
|
|
56
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
57
|
+
};
|
|
58
|
+
resize();
|
|
59
|
+
window.addEventListener("resize", resize);
|
|
60
|
+
|
|
61
|
+
// 3 档粉调色板(对齐参考站观感)
|
|
62
|
+
const colors = ["#ffd7e2", "#ffc0d0", "#fdaec5"];
|
|
63
|
+
|
|
64
|
+
interface Petal {
|
|
65
|
+
x: number;
|
|
66
|
+
y: number;
|
|
67
|
+
size: number;
|
|
68
|
+
speedY: number;
|
|
69
|
+
swayAmp: number;
|
|
70
|
+
swayFreq: number;
|
|
71
|
+
phase: number;
|
|
72
|
+
rot: number;
|
|
73
|
+
rotSpeed: number;
|
|
74
|
+
color: string;
|
|
75
|
+
opacity: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const spawn = (randomY = false): Petal => {
|
|
79
|
+
const i = Math.floor(Math.random() * colors.length);
|
|
80
|
+
return {
|
|
81
|
+
x: Math.random() * w,
|
|
82
|
+
y: randomY ? Math.random() * h : -20,
|
|
83
|
+
size: 6 + Math.random() * 8,
|
|
84
|
+
speedY: 0.6 + Math.random() * 1.2,
|
|
85
|
+
swayAmp: 20 + Math.random() * 40,
|
|
86
|
+
swayFreq: 0.005 + Math.random() * 0.01,
|
|
87
|
+
phase: Math.random() * Math.PI * 2,
|
|
88
|
+
rot: Math.random() * Math.PI * 2,
|
|
89
|
+
rotSpeed: (Math.random() - 0.5) * 0.03,
|
|
90
|
+
color: colors[i] ?? colors[0]!,
|
|
91
|
+
opacity: 0.5 + Math.random() * 0.4,
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const petals: Petal[] = Array.from({ length: count }, () => spawn(true));
|
|
96
|
+
|
|
97
|
+
// 程序化花瓣:两段贝塞尔拼出泪滴形
|
|
98
|
+
const drawPetal = (p: Petal) => {
|
|
99
|
+
const s = p.size;
|
|
100
|
+
ctx.save();
|
|
101
|
+
ctx.translate(p.x, p.y);
|
|
102
|
+
ctx.rotate(p.rot);
|
|
103
|
+
ctx.globalAlpha = p.opacity;
|
|
104
|
+
ctx.fillStyle = p.color;
|
|
105
|
+
ctx.beginPath();
|
|
106
|
+
ctx.moveTo(0, -s);
|
|
107
|
+
ctx.bezierCurveTo(s * 0.9, -s * 0.6, s * 0.7, s * 0.6, 0, s);
|
|
108
|
+
ctx.bezierCurveTo(-s * 0.7, s * 0.6, -s * 0.9, -s * 0.6, 0, -s);
|
|
109
|
+
ctx.fill();
|
|
110
|
+
ctx.restore();
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
let paused = false;
|
|
114
|
+
let frame = 0;
|
|
115
|
+
const tick = () => {
|
|
116
|
+
if (paused) return;
|
|
117
|
+
frame++;
|
|
118
|
+
ctx.clearRect(0, 0, w, h);
|
|
119
|
+
for (const p of petals) {
|
|
120
|
+
p.y += p.speedY;
|
|
121
|
+
p.x += Math.sin(frame * p.swayFreq + p.phase) * (p.swayAmp / 60);
|
|
122
|
+
p.rot += p.rotSpeed;
|
|
123
|
+
if (p.y > h + 30) Object.assign(p, spawn());
|
|
124
|
+
drawPetal(p);
|
|
125
|
+
}
|
|
126
|
+
raf = requestAnimationFrame(tick);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const onVisibility = () => {
|
|
130
|
+
if (document.hidden) {
|
|
131
|
+
paused = true;
|
|
132
|
+
cancelAnimationFrame(raf);
|
|
133
|
+
} else if (paused) {
|
|
134
|
+
paused = false;
|
|
135
|
+
raf = requestAnimationFrame(tick);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
139
|
+
|
|
140
|
+
raf = requestAnimationFrame(tick);
|
|
141
|
+
|
|
142
|
+
cleanup = () => {
|
|
143
|
+
cancelAnimationFrame(raf);
|
|
144
|
+
window.removeEventListener("resize", resize);
|
|
145
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
146
|
+
cleanup = null;
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ClientRouter 下 astro:page-load 首载与每次导航都会触发
|
|
151
|
+
document.addEventListener("astro:page-load", startSakura);
|
|
152
|
+
</script>
|