@mintfolio/theme-verdant 0.2.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/LICENSE +674 -0
- package/README.md +66 -0
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/assets/favicon.ico +0 -0
- package/assets/favicon.svg +9 -0
- package/assets/fonts/Inter-300.woff2 +0 -0
- package/assets/fonts/Inter-400.woff2 +0 -0
- package/assets/fonts/Inter-500.woff2 +0 -0
- package/assets/fonts/Inter-600.woff2 +0 -0
- package/assets/fonts/Inter-700.woff2 +0 -0
- package/assets/fonts/JetBrainsMono-400.woff2 +0 -0
- package/assets/fonts/JetBrainsMono-500.woff2 +0 -0
- package/assets/fonts/PlayfairDisplay-400.woff2 +0 -0
- package/assets/fonts/PlayfairDisplay-500.woff2 +0 -0
- package/assets/fonts/PlayfairDisplay-600.woff2 +0 -0
- package/assets/fonts/PlayfairDisplay-700.woff2 +0 -0
- package/components/base/Contact.astro +173 -0
- package/components/base/Hero.astro +228 -0
- package/components/base/Navigation.astro +288 -0
- package/components/base/Projects.astro +207 -0
- package/components/base/Skills.astro +158 -0
- package/components/blog/ArticleProse.astro +217 -0
- package/components/blog/CodeBlockEnhancer.astro +310 -0
- package/components/blog/PostCard.astro +98 -0
- package/components/blog/ProtectedArticle.astro +89 -0
- package/config/theme-verdant.config.mjs +74 -0
- package/layouts/Base.astro +460 -0
- package/lib/palettes.ts +10 -0
- package/licenses/Inter-OFL.txt +92 -0
- package/licenses/JetBrainsMono-OFL.txt +93 -0
- package/licenses/PlayfairDisplay-OFL.txt +191 -0
- package/package.json +68 -0
- package/pages/archive.astro +384 -0
- package/pages/home.astro +2049 -0
- package/pages/page.astro +434 -0
- package/pages/post.astro +1029 -0
- package/scripts/article.ts +42 -0
- package/scripts/articleOrigin.ts +31 -0
- package/scripts/articleTables.ts +15 -0
- package/scripts/blog.ts +26 -0
- package/scripts/codeBlocks.ts +172 -0
- package/scripts/hero.ts +173 -0
- package/scripts/home.ts +12 -0
- package/scripts/homePanels.ts +100 -0
- package/scripts/hotContent.ts +16 -0
- package/scripts/lifecycle.ts +3 -0
- package/scripts/lightbox.ts +14 -0
- package/scripts/postFilters.ts +101 -0
- package/scripts/postList.ts +132 -0
- package/scripts/progress.ts +29 -0
- package/scripts/protectedArticle.ts +49 -0
- package/scripts/protectedToc.ts +29 -0
- package/scripts/site.ts +145 -0
- package/scripts/tagFold.ts +61 -0
- package/scripts/theme.ts +102 -0
- package/scripts/toc.ts +149 -0
- package/settings.ts +21 -0
- package/styles/fonts.css +120 -0
- package/styles/global.css +146 -0
- package/styles/themes/happyhues/effects.css +62 -0
- package/styles/themes/happyhues/index.css +2 -0
- package/styles/themes/happyhues/theme.css +225 -0
- package/theme.mjs +60 -0
- package/utils/blogNavigation.ts +3 -0
- package/utils/homeReturnHint.ts +34 -0
- package/utils/storage.ts +16 -0
- package/utils/types.ts +8 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { navigate } from 'astro:transitions/client';
|
|
2
|
+
import { ARTICLE_BACK_NAV_STORAGE_KEY, resolveStoredArticleBackHref } from '../utils/blogNavigation';
|
|
3
|
+
import { readStorage } from '../utils/storage';
|
|
4
|
+
import { onPage } from './lifecycle';
|
|
5
|
+
import { initTocDrawer } from './toc';
|
|
6
|
+
import { initLightbox } from './lightbox';
|
|
7
|
+
import { readArticleOrigin, persistArticleOrigin } from './articleOrigin';
|
|
8
|
+
import { initArticleTables } from './articleTables';
|
|
9
|
+
|
|
10
|
+
onPage('.blog-post', (_root, scope) => {
|
|
11
|
+
document.documentElement.classList.add('blog-article-page');
|
|
12
|
+
scope.add(() => document.documentElement.classList.remove('blog-article-page'));
|
|
13
|
+
const origin = readArticleOrigin();
|
|
14
|
+
// Keep the return target when an article heading creates a new history entry.
|
|
15
|
+
window.addEventListener('hashchange', () => {
|
|
16
|
+
if (origin) persistArticleOrigin(origin);
|
|
17
|
+
}, { signal: scope.signal });
|
|
18
|
+
document.getElementById('back-btn')?.addEventListener(
|
|
19
|
+
'click',
|
|
20
|
+
() => {
|
|
21
|
+
const currentIndex: unknown = history.state?.index;
|
|
22
|
+
if (origin && typeof currentIndex === 'number' && origin.index < currentIndex) {
|
|
23
|
+
history.go(origin.index - currentIndex);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const href = resolveStoredArticleBackHref({
|
|
27
|
+
storedHref: origin?.href ?? readStorage('session', ARTICLE_BACK_NAV_STORAGE_KEY),
|
|
28
|
+
currentPath: location.pathname,
|
|
29
|
+
currentOrigin: location.origin,
|
|
30
|
+
fallbackHref: document.body.dataset.archiveHref ?? location.href,
|
|
31
|
+
});
|
|
32
|
+
void navigate(href);
|
|
33
|
+
},
|
|
34
|
+
{ signal: scope.signal },
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
onPage('.article-prose', (root, scope) => {
|
|
39
|
+
initTocDrawer(scope);
|
|
40
|
+
initLightbox(root, scope);
|
|
41
|
+
initArticleTables(root, scope);
|
|
42
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
interface ArticleOrigin {
|
|
2
|
+
href: string;
|
|
3
|
+
index: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const stateKey = 'personalSiteArticleOrigin';
|
|
7
|
+
let pending: { destination: string; origin: ArticleOrigin } | null = null;
|
|
8
|
+
|
|
9
|
+
/** Capture the actual history entry so article anchors can be skipped on return. */
|
|
10
|
+
export function rememberArticleOrigin(destination: string, href: string): void {
|
|
11
|
+
const index: unknown = history.state?.index;
|
|
12
|
+
pending = typeof index === 'number' && Number.isInteger(index)
|
|
13
|
+
? { destination, origin: { href, index } }
|
|
14
|
+
: null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function persistArticleOrigin(origin: ArticleOrigin): void {
|
|
18
|
+
history.replaceState({ ...history.state, [stateKey]: origin }, '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readArticleOrigin(): ArticleOrigin | null {
|
|
22
|
+
const candidate: Partial<ArticleOrigin> | undefined = pending?.destination === location.pathname
|
|
23
|
+
? pending.origin
|
|
24
|
+
: history.state?.[stateKey];
|
|
25
|
+
pending = null;
|
|
26
|
+
if (typeof candidate?.href !== 'string' || typeof candidate.index !== 'number' || !Number.isInteger(candidate.index)) return null;
|
|
27
|
+
if (new URL(candidate.href, location.origin).origin !== location.origin) return null;
|
|
28
|
+
const origin = { href: candidate.href, index: candidate.index };
|
|
29
|
+
persistArticleOrigin(origin);
|
|
30
|
+
return origin;
|
|
31
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PageScope } from './lifecycle';
|
|
2
|
+
|
|
3
|
+
export function initArticleTables(root: HTMLElement, scope: PageScope): void {
|
|
4
|
+
for (const table of root.querySelectorAll('table')) {
|
|
5
|
+
if (table.parentElement?.classList.contains('table-scroll-wrap')) continue;
|
|
6
|
+
const wrapper = document.createElement('div');
|
|
7
|
+
wrapper.className = 'table-scroll-wrap';
|
|
8
|
+
table.parentNode?.insertBefore(wrapper, table);
|
|
9
|
+
wrapper.appendChild(table);
|
|
10
|
+
scope.add(() => {
|
|
11
|
+
wrapper.parentNode?.insertBefore(table, wrapper);
|
|
12
|
+
wrapper.remove();
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
}
|
package/scripts/blog.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { onPage } from './lifecycle';
|
|
2
|
+
import { createPostList } from './postList';
|
|
3
|
+
import { createPostFilters, readFiltersFromUrl, writeFiltersToUrl } from './postFilters';
|
|
4
|
+
import { createTagFold } from './tagFold';
|
|
5
|
+
|
|
6
|
+
onPage('.blog-page', (_root, scope) => {
|
|
7
|
+
const list = createPostList(scope);
|
|
8
|
+
const fold = createTagFold(
|
|
9
|
+
document.getElementById('tags-filter'),
|
|
10
|
+
document.getElementById('blog-tags-filter-toggle'),
|
|
11
|
+
scope,
|
|
12
|
+
);
|
|
13
|
+
const input = document.querySelector<HTMLInputElement>('#search-input');
|
|
14
|
+
createPostFilters(
|
|
15
|
+
list.items,
|
|
16
|
+
input,
|
|
17
|
+
scope,
|
|
18
|
+
(filters) => {
|
|
19
|
+
const url = writeFiltersToUrl(filters);
|
|
20
|
+
if (url.href !== window.location.href) window.history.replaceState(window.history.state, '', url);
|
|
21
|
+
list.filter(filters);
|
|
22
|
+
fold.refresh();
|
|
23
|
+
},
|
|
24
|
+
readFiltersFromUrl(),
|
|
25
|
+
);
|
|
26
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { copyText, resolveCodeLanguage, extractCodeText } from '@mintfolio/core/client';
|
|
2
|
+
import { onPage } from './lifecycle';
|
|
3
|
+
|
|
4
|
+
function createIcon(kind: 'clipboard' | 'check' | 'error'): SVGSVGElement {
|
|
5
|
+
const namespace = 'http://www.w3.org/2000/svg';
|
|
6
|
+
const svg = document.createElementNS(namespace, 'svg');
|
|
7
|
+
const attributes = { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true' };
|
|
8
|
+
for (const [name, value] of Object.entries(attributes)) svg.setAttribute(name, value);
|
|
9
|
+
const append = (name: 'path' | 'rect' | 'circle', attributes: Record<string, string>): void => {
|
|
10
|
+
const element = document.createElementNS(namespace, name);
|
|
11
|
+
for (const [key, value] of Object.entries(attributes)) element.setAttribute(key, value);
|
|
12
|
+
svg.appendChild(element);
|
|
13
|
+
};
|
|
14
|
+
if (kind === 'clipboard') {
|
|
15
|
+
append('rect', { x: '9', y: '9', width: '13', height: '13', rx: '2', ry: '2' });
|
|
16
|
+
append('path', { d: 'M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1' });
|
|
17
|
+
} else if (kind === 'check') {
|
|
18
|
+
append('path', { d: 'M20 6 9 17l-5-5' });
|
|
19
|
+
} else {
|
|
20
|
+
append('circle', { cx: '12', cy: '12', r: '9' });
|
|
21
|
+
append('path', { d: 'M12 8v5 M12 16h.01' });
|
|
22
|
+
}
|
|
23
|
+
return svg;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getRenderedLines(code: HTMLElement, rawText: string): HTMLElement[] {
|
|
27
|
+
const renderedLines = Array.from(code.children).filter(
|
|
28
|
+
(child): child is HTMLElement => child instanceof HTMLElement && child.classList.contains('line'),
|
|
29
|
+
);
|
|
30
|
+
if (renderedLines.length > 0) {
|
|
31
|
+
return renderedLines;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const fragment = document.createDocumentFragment();
|
|
35
|
+
const nextLines = rawText.split('\n').map((lineText) => {
|
|
36
|
+
const line = document.createElement('span');
|
|
37
|
+
line.className = 'line';
|
|
38
|
+
|
|
39
|
+
if (lineText.length > 0) {
|
|
40
|
+
line.textContent = lineText;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fragment.appendChild(line);
|
|
44
|
+
return line;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
code.textContent = '';
|
|
48
|
+
code.appendChild(fragment);
|
|
49
|
+
return nextLines;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createCodeGutter(code: HTMLElement, rawText: string): HTMLDivElement {
|
|
53
|
+
const lines = getRenderedLines(code, rawText);
|
|
54
|
+
const gutter = document.createElement('div');
|
|
55
|
+
gutter.className = 'code-block-gutter';
|
|
56
|
+
gutter.setAttribute('aria-hidden', 'true');
|
|
57
|
+
// Reuse every Shiki token node, removing only the inter-row formatting newlines.
|
|
58
|
+
code.replaceChildren(...lines);
|
|
59
|
+
lines.forEach((line, index) => {
|
|
60
|
+
line.classList.add('code-block-row');
|
|
61
|
+
const lineNumber = document.createElement('span');
|
|
62
|
+
lineNumber.className = 'code-block-line-number';
|
|
63
|
+
lineNumber.textContent = String(index + 1);
|
|
64
|
+
gutter.appendChild(lineNumber);
|
|
65
|
+
});
|
|
66
|
+
return gutter;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createToolbar(languageLabel: string): HTMLDivElement {
|
|
70
|
+
const toolbar = document.createElement('div');
|
|
71
|
+
toolbar.className = 'code-block-toolbar';
|
|
72
|
+
const controls = document.createElement('div');
|
|
73
|
+
controls.className = 'code-block-window-controls';
|
|
74
|
+
controls.setAttribute('aria-hidden', 'true');
|
|
75
|
+
for (const color of ['red', 'yellow', 'green']) {
|
|
76
|
+
const dot = document.createElement('span');
|
|
77
|
+
dot.className = `code-block-window-dot is-${color}`;
|
|
78
|
+
controls.appendChild(dot);
|
|
79
|
+
}
|
|
80
|
+
const language = document.createElement('span');
|
|
81
|
+
language.className = 'code-block-language';
|
|
82
|
+
language.textContent = languageLabel;
|
|
83
|
+
const button = document.createElement('button');
|
|
84
|
+
button.type = 'button';
|
|
85
|
+
button.className = 'code-copy-btn';
|
|
86
|
+
button.setAttribute('aria-label', '复制代码');
|
|
87
|
+
button.title = '复制代码';
|
|
88
|
+
button.appendChild(createIcon('clipboard'));
|
|
89
|
+
toolbar.append(controls, language, button);
|
|
90
|
+
|
|
91
|
+
return toolbar;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function enhancePreBlock(pre: HTMLPreElement, code: HTMLElement, rawText: string): (() => void) | null {
|
|
95
|
+
let toolbar = pre.closest('.code-block-shell')?.querySelector<HTMLDivElement>('.code-block-toolbar');
|
|
96
|
+
if (!toolbar) {
|
|
97
|
+
const languageLabel = resolveCodeLanguage(pre, code);
|
|
98
|
+
const gutter = createCodeGutter(code, rawText);
|
|
99
|
+
|
|
100
|
+
const wrapper = document.createElement('div');
|
|
101
|
+
wrapper.className = 'code-block-shell';
|
|
102
|
+
wrapper.style.setProperty('--code-block-line-digits', String(Math.max(2, String(gutter.childElementCount).length)));
|
|
103
|
+
|
|
104
|
+
toolbar = createToolbar(languageLabel);
|
|
105
|
+
const scroll = document.createElement('div');
|
|
106
|
+
scroll.className = 'code-block-scroll';
|
|
107
|
+
|
|
108
|
+
pre.parentNode?.insertBefore(wrapper, pre);
|
|
109
|
+
wrapper.append(toolbar, scroll);
|
|
110
|
+
scroll.append(gutter, pre);
|
|
111
|
+
pre.tabIndex = 0;
|
|
112
|
+
pre.setAttribute('aria-label', `${languageLabel} 代码`);
|
|
113
|
+
|
|
114
|
+
pre.dataset.codeBlockEnhanced = '1';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const copyButton = toolbar.querySelector('.code-copy-btn');
|
|
118
|
+
if (!copyButton) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let resetTimer: number | null = null;
|
|
123
|
+
let disposed = false;
|
|
124
|
+
|
|
125
|
+
const resetCopyState = () => {
|
|
126
|
+
copyButton.replaceChildren(createIcon('clipboard'));
|
|
127
|
+
copyButton.classList.remove('is-copied');
|
|
128
|
+
copyButton.classList.remove('is-copy-failed');
|
|
129
|
+
copyButton.setAttribute('aria-label', '复制代码');
|
|
130
|
+
copyButton.setAttribute('title', '复制代码');
|
|
131
|
+
resetTimer = null;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const onCopy = async () => {
|
|
135
|
+
const copied = await copyText(rawText);
|
|
136
|
+
if (disposed) return;
|
|
137
|
+
copyButton.replaceChildren(createIcon(copied ? 'check' : 'error'));
|
|
138
|
+
copyButton.classList.toggle('is-copied', copied);
|
|
139
|
+
copyButton.classList.toggle('is-copy-failed', !copied);
|
|
140
|
+
copyButton.setAttribute('aria-label', copied ? '复制成功' : '复制失败');
|
|
141
|
+
copyButton.setAttribute('title', copied ? '复制成功' : '复制失败');
|
|
142
|
+
|
|
143
|
+
if (resetTimer) {
|
|
144
|
+
window.clearTimeout(resetTimer);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
resetTimer = window.setTimeout(resetCopyState, copied ? 1800 : 1500);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
copyButton.addEventListener('click', onCopy);
|
|
151
|
+
|
|
152
|
+
return () => {
|
|
153
|
+
disposed = true;
|
|
154
|
+
if (resetTimer) {
|
|
155
|
+
window.clearTimeout(resetTimer);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
copyButton.removeEventListener('click', onCopy);
|
|
159
|
+
resetCopyState();
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
onPage('.article-prose', (postContent, scope) => {
|
|
164
|
+
for (const pre of postContent.querySelectorAll('pre')) {
|
|
165
|
+
const code = pre.querySelector('code');
|
|
166
|
+
if (!code) continue;
|
|
167
|
+
const rawText = extractCodeText(code, pre);
|
|
168
|
+
if (!rawText.trim()) continue;
|
|
169
|
+
const dispose = enhancePreBlock(pre, code, rawText);
|
|
170
|
+
if (dispose) scope.add(dispose);
|
|
171
|
+
}
|
|
172
|
+
});
|
package/scripts/hero.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { PageScope } from './lifecycle';
|
|
2
|
+
|
|
3
|
+
export function initHero(hero: HTMLElement, scope: PageScope): void {
|
|
4
|
+
const content = document.querySelector('.content-wrapper');
|
|
5
|
+
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
6
|
+
const options = { signal: scope.signal };
|
|
7
|
+
let collapsed = false;
|
|
8
|
+
let hintRequested = false;
|
|
9
|
+
const setCollapsed = (value: boolean): void => {
|
|
10
|
+
collapsed = value;
|
|
11
|
+
hero.classList.toggle('collapsed', value);
|
|
12
|
+
content?.classList.toggle('hero-collapsed', value);
|
|
13
|
+
hero.inert = value;
|
|
14
|
+
if (value && !hintRequested) {
|
|
15
|
+
hintRequested = true;
|
|
16
|
+
window.dispatchEvent(new CustomEvent('home:show-return-hint'));
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const enterContent = (): void => {
|
|
20
|
+
history.replaceState(history.state, '', `${location.pathname}${location.search}#content`);
|
|
21
|
+
setCollapsed(true);
|
|
22
|
+
scope.frame(() => window.scrollTo({ top: 0, behavior: 'instant' }));
|
|
23
|
+
};
|
|
24
|
+
hero.querySelector('a[href="#content"]')?.addEventListener(
|
|
25
|
+
'click',
|
|
26
|
+
(event) => {
|
|
27
|
+
event.preventDefault();
|
|
28
|
+
enterContent();
|
|
29
|
+
},
|
|
30
|
+
options,
|
|
31
|
+
);
|
|
32
|
+
const indicator = hero.querySelector('.scroll-indicator');
|
|
33
|
+
indicator?.addEventListener('click', enterContent, options);
|
|
34
|
+
indicator?.addEventListener(
|
|
35
|
+
'keydown',
|
|
36
|
+
(event) => {
|
|
37
|
+
if (event instanceof KeyboardEvent && ['Enter', ' '].includes(event.key)) {
|
|
38
|
+
event.preventDefault();
|
|
39
|
+
enterContent();
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
options,
|
|
43
|
+
);
|
|
44
|
+
hero.addEventListener(
|
|
45
|
+
'wheel',
|
|
46
|
+
(event) => {
|
|
47
|
+
if (!collapsed && event.deltaY > 0) {
|
|
48
|
+
event.preventDefault();
|
|
49
|
+
enterContent();
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
{ ...options, passive: false },
|
|
53
|
+
);
|
|
54
|
+
let touchY: number | null = null;
|
|
55
|
+
hero.addEventListener(
|
|
56
|
+
'touchstart',
|
|
57
|
+
(event) => {
|
|
58
|
+
touchY = event.touches[0]?.clientY ?? null;
|
|
59
|
+
},
|
|
60
|
+
{ ...options, passive: true },
|
|
61
|
+
);
|
|
62
|
+
hero.addEventListener(
|
|
63
|
+
'touchmove',
|
|
64
|
+
(event) => {
|
|
65
|
+
if (!collapsed && touchY !== null && touchY - (event.touches[0]?.clientY ?? touchY) > 28) {
|
|
66
|
+
touchY = null;
|
|
67
|
+
enterContent();
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
{ ...options, passive: true },
|
|
71
|
+
);
|
|
72
|
+
const restore = (): void => {
|
|
73
|
+
// Hydrating a history entry must not overwrite the router's restored scroll position.
|
|
74
|
+
if (window.location.hash === '#content') setCollapsed(true);
|
|
75
|
+
else if (!collapsed && window.scrollY > 8) setCollapsed(true);
|
|
76
|
+
};
|
|
77
|
+
window.addEventListener('hashchange', restore, options);
|
|
78
|
+
window.addEventListener(
|
|
79
|
+
'scroll',
|
|
80
|
+
() => {
|
|
81
|
+
if (!collapsed && window.scrollY > 8) {
|
|
82
|
+
history.replaceState(history.state, '', `${location.pathname}${location.search}#content`);
|
|
83
|
+
setCollapsed(true);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
{ ...options, passive: true },
|
|
87
|
+
);
|
|
88
|
+
window.addEventListener(
|
|
89
|
+
'home:return-to-hero',
|
|
90
|
+
() => {
|
|
91
|
+
setCollapsed(false);
|
|
92
|
+
history.replaceState(history.state, '', `${location.pathname}${location.search}`);
|
|
93
|
+
window.scrollTo({ top: 0, behavior: 'instant' });
|
|
94
|
+
},
|
|
95
|
+
options,
|
|
96
|
+
);
|
|
97
|
+
document.addEventListener('astro:page-load', restore, options);
|
|
98
|
+
restore();
|
|
99
|
+
const pause = (): void => {
|
|
100
|
+
hero.classList.toggle('is-paused', document.hidden);
|
|
101
|
+
};
|
|
102
|
+
document.addEventListener('visibilitychange', pause, options);
|
|
103
|
+
pause();
|
|
104
|
+
const observer = new IntersectionObserver(
|
|
105
|
+
([entry]) => {
|
|
106
|
+
if (!entry?.isIntersecting) return;
|
|
107
|
+
observer.disconnect();
|
|
108
|
+
for (const node of hero.querySelectorAll<HTMLElement>('[data-countup]')) {
|
|
109
|
+
const target = Number(node.dataset.countup);
|
|
110
|
+
if (!Number.isFinite(target) || reducedMotion.matches) continue;
|
|
111
|
+
const start = performance.now();
|
|
112
|
+
const tick = (now: number): void => {
|
|
113
|
+
const progress = Math.min((now - start) / 900, 1);
|
|
114
|
+
node.textContent = String(Math.round(target * (1 - (1 - progress) ** 3)));
|
|
115
|
+
if (progress < 1 && !collapsed) scope.frame(tick);
|
|
116
|
+
else node.textContent = String(target);
|
|
117
|
+
};
|
|
118
|
+
scope.frame(tick);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
{ threshold: 0.35 },
|
|
122
|
+
);
|
|
123
|
+
observer.observe(hero);
|
|
124
|
+
scope.add(() => observer.disconnect());
|
|
125
|
+
const gradient = hero.querySelector<HTMLElement>('.hero-gradient');
|
|
126
|
+
const pattern = hero.querySelector<HTMLElement>('.hero-pattern');
|
|
127
|
+
if (
|
|
128
|
+
!gradient ||
|
|
129
|
+
!pattern ||
|
|
130
|
+
reducedMotion.matches ||
|
|
131
|
+
navigator.maxTouchPoints > 0 ||
|
|
132
|
+
matchMedia('(pointer: coarse)').matches
|
|
133
|
+
)
|
|
134
|
+
return;
|
|
135
|
+
let targetX = 0;
|
|
136
|
+
let targetY = 0;
|
|
137
|
+
let x = 0;
|
|
138
|
+
let y = 0;
|
|
139
|
+
let pending = false;
|
|
140
|
+
const animate = (): void => {
|
|
141
|
+
pending = false;
|
|
142
|
+
if (collapsed || document.hidden) return;
|
|
143
|
+
x += (targetX - x) * 0.12;
|
|
144
|
+
y += (targetY - y) * 0.12;
|
|
145
|
+
gradient.style.transform = `translate3d(${x * 36}px, ${y * 36}px, 0)`;
|
|
146
|
+
pattern.style.transform = `translate3d(${x * -24}px, ${y * -24}px, 0)`;
|
|
147
|
+
if (Math.abs(targetX - x) + Math.abs(targetY - y) > 0.001) requestFrame();
|
|
148
|
+
};
|
|
149
|
+
const requestFrame = (): void => {
|
|
150
|
+
if (!pending) {
|
|
151
|
+
pending = true;
|
|
152
|
+
scope.frame(animate);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
hero.addEventListener(
|
|
156
|
+
'mousemove',
|
|
157
|
+
(event) => {
|
|
158
|
+
const rect = hero.getBoundingClientRect();
|
|
159
|
+
targetX = (event.clientX - rect.left) / rect.width - 0.5;
|
|
160
|
+
targetY = (event.clientY - rect.top) / rect.height - 0.5;
|
|
161
|
+
requestFrame();
|
|
162
|
+
},
|
|
163
|
+
options,
|
|
164
|
+
);
|
|
165
|
+
hero.addEventListener(
|
|
166
|
+
'mouseleave',
|
|
167
|
+
() => {
|
|
168
|
+
targetX = targetY = 0;
|
|
169
|
+
requestFrame();
|
|
170
|
+
},
|
|
171
|
+
options,
|
|
172
|
+
);
|
|
173
|
+
}
|
package/scripts/home.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { onPage } from './lifecycle';
|
|
2
|
+
import { createPostList } from './postList';
|
|
3
|
+
import { initHero } from './hero';
|
|
4
|
+
import { initHomePanels } from './homePanels';
|
|
5
|
+
import { initHotContent } from './hotContent';
|
|
6
|
+
|
|
7
|
+
onPage('#hero', (hero, scope) => {
|
|
8
|
+
const list = createPostList(scope);
|
|
9
|
+
initHero(hero, scope);
|
|
10
|
+
initHomePanels(hero, list.items, scope);
|
|
11
|
+
initHotContent(scope);
|
|
12
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { navigate } from 'astro:transitions/client';
|
|
2
|
+
import type { PageScope } from './lifecycle';
|
|
3
|
+
import type { PostItem } from '../utils/types';
|
|
4
|
+
import { createPostFilters, writeFiltersToUrl } from './postFilters';
|
|
5
|
+
import { createTagFold } from './tagFold';
|
|
6
|
+
|
|
7
|
+
export function initHomePanels(hero: HTMLElement, items: PostItem[], scope: PageScope): void {
|
|
8
|
+
const options = { signal: scope.signal };
|
|
9
|
+
const search = document.querySelector<HTMLDialogElement>('#search-modal');
|
|
10
|
+
const input = document.querySelector<HTMLInputElement>('#search-modal-input');
|
|
11
|
+
const searchToggle = document.getElementById('search-float-btn');
|
|
12
|
+
const sidebar = document.getElementById('sidebar');
|
|
13
|
+
const sidebarToggle = document.getElementById('sidebar-drawer-toggle');
|
|
14
|
+
const overlay = document.getElementById('sidebar-overlay');
|
|
15
|
+
const fold = createTagFold(
|
|
16
|
+
document.getElementById('tags-filter'),
|
|
17
|
+
document.getElementById('home-tags-filter-toggle'),
|
|
18
|
+
scope,
|
|
19
|
+
);
|
|
20
|
+
const filters = createPostFilters(items, input, scope, () => fold.refresh());
|
|
21
|
+
const apply = (): void => {
|
|
22
|
+
const url = writeFiltersToUrl(filters.value(), new URL(document.body.dataset.archiveHref ?? location.href, location.origin));
|
|
23
|
+
void navigate(`${url.pathname}${url.search}`);
|
|
24
|
+
};
|
|
25
|
+
searchToggle?.addEventListener(
|
|
26
|
+
'click',
|
|
27
|
+
() => {
|
|
28
|
+
search?.showModal();
|
|
29
|
+
fold.refresh();
|
|
30
|
+
input?.focus();
|
|
31
|
+
},
|
|
32
|
+
options,
|
|
33
|
+
);
|
|
34
|
+
document.getElementById('search-close-btn')?.addEventListener('click', () => search?.close(), options);
|
|
35
|
+
document.getElementById('search-apply-btn')?.addEventListener('click', apply, options);
|
|
36
|
+
search?.addEventListener(
|
|
37
|
+
'click',
|
|
38
|
+
(event) => {
|
|
39
|
+
if (event.target === search) search.close();
|
|
40
|
+
},
|
|
41
|
+
options,
|
|
42
|
+
);
|
|
43
|
+
input?.addEventListener(
|
|
44
|
+
'keydown',
|
|
45
|
+
(event) => {
|
|
46
|
+
if (event.key === 'Enter') {
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
apply();
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
options,
|
|
52
|
+
);
|
|
53
|
+
const setSidebar = (open: boolean): void => {
|
|
54
|
+
sidebar?.classList.toggle('open', open);
|
|
55
|
+
overlay?.classList.toggle('open', open);
|
|
56
|
+
sidebarToggle?.classList.toggle('open', open);
|
|
57
|
+
sidebarToggle?.setAttribute('aria-expanded', String(open));
|
|
58
|
+
sidebarToggle?.setAttribute('aria-label', open ? '关闭侧边栏' : '打开侧边栏');
|
|
59
|
+
document.body.classList.toggle('sidebar-drawer-open', open);
|
|
60
|
+
document.body.style.overflow = open ? 'hidden' : '';
|
|
61
|
+
};
|
|
62
|
+
sidebarToggle?.addEventListener('click', () => setSidebar(!sidebar?.classList.contains('open')), options);
|
|
63
|
+
overlay?.addEventListener('click', () => setSidebar(false), options);
|
|
64
|
+
document.addEventListener(
|
|
65
|
+
'keydown',
|
|
66
|
+
(event) => {
|
|
67
|
+
if (event.key === 'Escape' && sidebar?.classList.contains('open')) {
|
|
68
|
+
setSidebar(false);
|
|
69
|
+
sidebarToggle?.focus();
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
options,
|
|
73
|
+
);
|
|
74
|
+
let heroVisible = true;
|
|
75
|
+
const update = (): void => {
|
|
76
|
+
searchToggle?.classList.toggle('visible', !heroVisible);
|
|
77
|
+
sidebarToggle?.classList.toggle('visible', !heroVisible && window.innerWidth < 900);
|
|
78
|
+
};
|
|
79
|
+
const observer = new IntersectionObserver(
|
|
80
|
+
([entry]) => {
|
|
81
|
+
heroVisible = entry?.isIntersecting ?? false;
|
|
82
|
+
update();
|
|
83
|
+
},
|
|
84
|
+
{ threshold: 0.1 },
|
|
85
|
+
);
|
|
86
|
+
observer.observe(hero);
|
|
87
|
+
window.addEventListener(
|
|
88
|
+
'resize',
|
|
89
|
+
() => {
|
|
90
|
+
if (window.innerWidth >= 900) setSidebar(false);
|
|
91
|
+
update();
|
|
92
|
+
},
|
|
93
|
+
options,
|
|
94
|
+
);
|
|
95
|
+
scope.add(() => {
|
|
96
|
+
observer.disconnect();
|
|
97
|
+
search?.close();
|
|
98
|
+
setSidebar(false);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PageScope } from './lifecycle';
|
|
2
|
+
|
|
3
|
+
/** Scroll snapping is native; update the indicator only when scrolling settles. */
|
|
4
|
+
export function initHotContent(scope: PageScope): void {
|
|
5
|
+
const list = document.querySelector<HTMLElement>('.hot-content-carousel');
|
|
6
|
+
const dots = Array.from(document.querySelectorAll<HTMLElement>('.hot-scroll-dot'));
|
|
7
|
+
const items = Array.from(list?.querySelectorAll<HTMLElement>('.hot-content-item') ?? []);
|
|
8
|
+
if (!list || dots.length < 2) return;
|
|
9
|
+
const update = (): void => {
|
|
10
|
+
const start = list.getBoundingClientRect().left;
|
|
11
|
+
const distances = items.map((item) => Math.abs(item.getBoundingClientRect().left - start));
|
|
12
|
+
const index = distances.indexOf(Math.min(...distances));
|
|
13
|
+
dots.forEach((dot, i) => dot.classList.toggle('active', i === index));
|
|
14
|
+
};
|
|
15
|
+
list.addEventListener('scrollend', update, { signal: scope.signal });
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createLightboxController, type PageScope } from '@mintfolio/core/client';
|
|
2
|
+
|
|
3
|
+
/** Verdant provides its controls; all preview behavior comes from Core. */
|
|
4
|
+
export function initLightbox(root: HTMLElement, scope: PageScope): void {
|
|
5
|
+
const dialog = document.querySelector<HTMLDialogElement>('#image-lightbox');
|
|
6
|
+
const image = document.querySelector<HTMLImageElement>('#lightbox-image');
|
|
7
|
+
const viewport = document.getElementById('lightbox-viewport');
|
|
8
|
+
const zoomIn = document.querySelector<HTMLButtonElement>('#lightbox-zoom-in');
|
|
9
|
+
const zoomOut = document.querySelector<HTMLButtonElement>('#lightbox-zoom-out');
|
|
10
|
+
const zoomLevel = document.querySelector<HTMLOutputElement>('#lightbox-zoom-level');
|
|
11
|
+
const reset = document.getElementById('lightbox-reset');
|
|
12
|
+
if (!dialog || !image || !viewport || !zoomIn || !zoomOut || !zoomLevel || !reset) return;
|
|
13
|
+
createLightboxController({ root, dialog, image, viewport, zoomIn, zoomOut, zoomLevel, reset, close: document.getElementById('lightbox-close'), signal: scope.signal });
|
|
14
|
+
}
|