@hutusi/amytis 1.6.0 → 1.8.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/CHANGELOG.md +49 -0
- package/GEMINI.md +12 -2
- package/README.md +14 -0
- package/TODO.md +24 -16
- package/bun.lock +8 -3
- package/content/about.mdx +1 -0
- package/content/about.zh.mdx +21 -0
- package/content/flows/2026/02/05.md +0 -1
- package/content/flows/2026/02/10.mdx +2 -1
- package/content/flows/2026/02/15.md +2 -1
- package/content/flows/2026/02/18.mdx +2 -1
- package/content/flows/2026/02/20.md +15 -0
- package/content/links.mdx +42 -0
- package/content/links.zh.mdx +41 -0
- package/content/notes/algorithms-and-data-structures.mdx +51 -0
- package/content/notes/digital-garden-philosophy.mdx +36 -0
- package/content/notes/react-server-components.mdx +49 -0
- package/content/notes/tailwind-v4.mdx +45 -0
- package/content/notes/zettelkasten-method.mdx +33 -0
- package/content/posts/2026-02-20-i18n-routing-considerations.mdx +150 -0
- package/content/posts/multimedia-showcase/index.mdx +261 -0
- package/content/privacy.mdx +32 -0
- package/content/privacy.zh.mdx +32 -0
- package/docs/ARCHITECTURE.md +16 -0
- package/docs/CONTRIBUTING.md +11 -0
- package/docs/DIGITAL_GARDEN.md +64 -0
- package/package.json +8 -3
- package/scripts/copy-assets.ts +1 -1
- package/scripts/generate-knowledge-graph.ts +162 -0
- package/scripts/new-flow.ts +0 -5
- package/scripts/new-note.ts +53 -0
- package/site.config.ts +146 -44
- package/src/app/[slug]/page.tsx +0 -10
- package/src/app/archive/page.tsx +38 -10
- package/src/app/books/[slug]/page.tsx +18 -0
- package/src/app/flows/[year]/[month]/[day]/page.tsx +51 -31
- package/src/app/flows/[year]/[month]/page.tsx +15 -13
- package/src/app/flows/[year]/page.tsx +22 -15
- package/src/app/flows/page/[page]/page.tsx +3 -9
- package/src/app/flows/page.tsx +3 -8
- package/src/app/globals.css +41 -0
- package/src/app/graph/page.tsx +19 -0
- package/src/app/layout.tsx +47 -21
- package/src/app/notes/[slug]/page.tsx +128 -0
- package/src/app/notes/page/[page]/page.tsx +58 -0
- package/src/app/notes/page.tsx +31 -0
- package/src/app/page.tsx +134 -72
- package/src/app/posts/[slug]/page.tsx +8 -12
- package/src/app/search.json/route.ts +15 -1
- package/src/app/series/[slug]/page.tsx +18 -0
- package/src/app/subscribe/page.tsx +17 -0
- package/src/app/tags/[tag]/page.tsx +9 -26
- package/src/app/tags/page.tsx +3 -8
- package/src/components/AuthorCard.tsx +43 -0
- package/src/components/Backlinks.tsx +39 -0
- package/src/components/Comments.tsx +20 -4
- package/src/components/ExternalLinks.tsx +6 -2
- package/src/components/FlowCalendarSidebar.tsx +4 -2
- package/src/components/FlowContent.tsx +4 -3
- package/src/components/FlowHubTabs.tsx +50 -0
- package/src/components/FlowTimelineEntry.tsx +7 -9
- package/src/components/Footer.tsx +35 -26
- package/src/components/KnowledgeGraph.tsx +324 -0
- package/src/components/LanguageProvider.tsx +0 -5
- package/src/components/LanguageSwitch.tsx +117 -6
- package/src/components/LocaleSwitch.tsx +33 -0
- package/src/components/MarkdownRenderer.tsx +13 -2
- package/src/components/Navbar.tsx +266 -17
- package/src/components/NoteContent.tsx +123 -0
- package/src/components/NoteSidebar.tsx +132 -0
- package/src/components/PostNavigation.tsx +55 -0
- package/src/components/PostSidebar.tsx +172 -126
- package/src/components/ReadingProgressBar.tsx +6 -21
- package/src/components/RecentNotesSection.tsx +6 -11
- package/src/components/RelatedPosts.tsx +1 -1
- package/src/components/Search.tsx +29 -5
- package/src/components/SelectedBooksSection.tsx +12 -6
- package/src/components/ShareBar.tsx +115 -0
- package/src/components/SimpleLayoutHeader.tsx +5 -14
- package/src/components/SubscribePage.tsx +298 -0
- package/src/components/TagContentTabs.tsx +102 -0
- package/src/components/TagPageHeader.tsx +7 -13
- package/src/components/TagSidebar.tsx +142 -0
- package/src/components/TagsIndexClient.tsx +156 -0
- package/src/hooks/useScrollY.ts +41 -0
- package/src/i18n/translations.ts +105 -1
- package/src/layouts/PostLayout.tsx +40 -8
- package/src/layouts/SimpleLayout.tsx +53 -15
- package/src/lib/markdown.ts +347 -18
- package/src/lib/remark-wikilinks.ts +59 -0
- package/src/lib/search-utils.ts +2 -1
- package/src/components/TableOfContents.tsx +0 -158
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useLanguage } from './LanguageProvider';
|
|
5
|
+
import PostList from './PostList';
|
|
6
|
+
import FlowTimelineEntry from './FlowTimelineEntry';
|
|
7
|
+
import type { PostData } from '@/lib/markdown';
|
|
8
|
+
|
|
9
|
+
type Tab = 'all' | 'posts' | 'flows';
|
|
10
|
+
|
|
11
|
+
interface FlowEntry {
|
|
12
|
+
slug: string;
|
|
13
|
+
date: string;
|
|
14
|
+
title: string;
|
|
15
|
+
excerpt: string;
|
|
16
|
+
tags: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface TagContentTabsProps {
|
|
20
|
+
posts: PostData[];
|
|
21
|
+
flows: FlowEntry[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function TagContentTabs({ posts, flows }: TagContentTabsProps) {
|
|
25
|
+
const { t } = useLanguage();
|
|
26
|
+
const hasBoth = posts.length > 0 && flows.length > 0;
|
|
27
|
+
const [activeTab, setActiveTab] = useState<Tab>('all');
|
|
28
|
+
|
|
29
|
+
const showPosts = activeTab === 'all' || activeTab === 'posts';
|
|
30
|
+
const showFlows = activeTab === 'all' || activeTab === 'flows';
|
|
31
|
+
|
|
32
|
+
const tabs: { key: Tab; label: string; count: number }[] = [
|
|
33
|
+
{ key: 'all', label: t('tab_all'), count: posts.length + flows.length },
|
|
34
|
+
{ key: 'posts', label: t('posts'), count: posts.length },
|
|
35
|
+
{ key: 'flows', label: t('flow_notes'), count: flows.length },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div>
|
|
40
|
+
{/* Type tabs — only shown when both content types exist */}
|
|
41
|
+
{hasBoth && (
|
|
42
|
+
<div role="tablist" className="flex mb-8 border-b border-muted/20">
|
|
43
|
+
{tabs.map(({ key, label, count }) => (
|
|
44
|
+
<button
|
|
45
|
+
key={key}
|
|
46
|
+
type="button"
|
|
47
|
+
role="tab"
|
|
48
|
+
aria-selected={activeTab === key}
|
|
49
|
+
onClick={() => setActiveTab(key)}
|
|
50
|
+
className={`px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
|
51
|
+
activeTab === key
|
|
52
|
+
? 'text-accent border-accent'
|
|
53
|
+
: 'text-muted border-transparent hover:text-foreground'
|
|
54
|
+
}`}
|
|
55
|
+
>
|
|
56
|
+
{label}
|
|
57
|
+
<span className={`ml-1.5 text-xs font-mono ${activeTab === key ? 'text-accent/60' : 'text-muted/50'}`}>
|
|
58
|
+
{count}
|
|
59
|
+
</span>
|
|
60
|
+
</button>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
)}
|
|
64
|
+
|
|
65
|
+
{/* Posts section */}
|
|
66
|
+
{showPosts && posts.length > 0 && (
|
|
67
|
+
<div>
|
|
68
|
+
<h2 className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-6">
|
|
69
|
+
{t('posts')}
|
|
70
|
+
<span className="ml-1.5 font-mono font-normal normal-case tracking-normal text-muted/50">
|
|
71
|
+
{posts.length}
|
|
72
|
+
</span>
|
|
73
|
+
</h2>
|
|
74
|
+
<PostList posts={posts} />
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
|
|
78
|
+
{/* Flows section */}
|
|
79
|
+
{showFlows && flows.length > 0 && (
|
|
80
|
+
<div className={showPosts && posts.length > 0 ? 'mt-12' : ''}>
|
|
81
|
+
<h2 className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-4">
|
|
82
|
+
{t('flow_notes')}
|
|
83
|
+
<span className="ml-1.5 font-mono font-normal normal-case tracking-normal text-muted/50">
|
|
84
|
+
{flows.length}
|
|
85
|
+
</span>
|
|
86
|
+
</h2>
|
|
87
|
+
<div>
|
|
88
|
+
{flows.map(flow => (
|
|
89
|
+
<FlowTimelineEntry
|
|
90
|
+
key={flow.slug}
|
|
91
|
+
date={flow.date}
|
|
92
|
+
excerpt={flow.excerpt}
|
|
93
|
+
tags={flow.tags}
|
|
94
|
+
slug={flow.slug}
|
|
95
|
+
/>
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -5,18 +5,15 @@ import { useLanguage } from './LanguageProvider';
|
|
|
5
5
|
|
|
6
6
|
interface TagPageHeaderProps {
|
|
7
7
|
tag: string;
|
|
8
|
-
postCount: number;
|
|
9
8
|
}
|
|
10
9
|
|
|
11
|
-
export default function TagPageHeader({ tag
|
|
12
|
-
const { t
|
|
13
|
-
|
|
14
|
-
const subtitleKey = postCount === 1 ? 'tag_posts_found_one' : 'tag_posts_found';
|
|
15
|
-
const subtitle = tWith(subtitleKey, { count: postCount });
|
|
10
|
+
export default function TagPageHeader({ tag }: TagPageHeaderProps) {
|
|
11
|
+
const { t } = useLanguage();
|
|
16
12
|
|
|
17
13
|
return (
|
|
18
14
|
<>
|
|
19
|
-
|
|
15
|
+
{/* Back link: visible only on mobile (desktop has sidebar) */}
|
|
16
|
+
<nav className="mb-8 flex lg:hidden">
|
|
20
17
|
<Link
|
|
21
18
|
href="/tags"
|
|
22
19
|
className="text-xs font-bold uppercase tracking-widest text-muted hover:text-accent transition-colors no-underline"
|
|
@@ -25,13 +22,10 @@ export default function TagPageHeader({ tag, postCount }: TagPageHeaderProps) {
|
|
|
25
22
|
</Link>
|
|
26
23
|
</nav>
|
|
27
24
|
|
|
28
|
-
<header className="mb-
|
|
29
|
-
<h1 className="text-
|
|
30
|
-
<span className="text-accent/50 mr-
|
|
25
|
+
<header className="mb-10">
|
|
26
|
+
<h1 className="text-3xl md:text-4xl font-serif font-bold text-heading">
|
|
27
|
+
<span className="text-accent/50 mr-1">#</span>{tag}
|
|
31
28
|
</h1>
|
|
32
|
-
<p className="text-lg text-muted font-serif italic">
|
|
33
|
-
{subtitle}
|
|
34
|
-
</p>
|
|
35
29
|
</header>
|
|
36
30
|
</>
|
|
37
31
|
);
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import { t, tWith } from '@/lib/i18n';
|
|
6
|
+
import { LuTag, LuX, LuSearch } from 'react-icons/lu';
|
|
7
|
+
|
|
8
|
+
const INITIAL_SHOW = 12;
|
|
9
|
+
|
|
10
|
+
interface TagSidebarProps {
|
|
11
|
+
tags: Record<string, number>;
|
|
12
|
+
activeTag: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function TagSidebar({ tags, activeTag }: TagSidebarProps) {
|
|
16
|
+
const [filter, setFilter] = useState('');
|
|
17
|
+
const [expanded, setExpanded] = useState(false);
|
|
18
|
+
|
|
19
|
+
const totalCount = Object.keys(tags).length;
|
|
20
|
+
|
|
21
|
+
const sortedTags = Object.entries(tags)
|
|
22
|
+
.sort((a, b) => b[1] - a[1])
|
|
23
|
+
.filter(([tag]) => !filter || tag.toLowerCase().includes(filter.toLowerCase()));
|
|
24
|
+
|
|
25
|
+
const activeIndex = sortedTags.findIndex(([tag]) => tag === activeTag);
|
|
26
|
+
|
|
27
|
+
// Compute visible tags:
|
|
28
|
+
// - Filtering or expanded: show all
|
|
29
|
+
// - Default: show first INITIAL_SHOW, then append active tag (with a separator)
|
|
30
|
+
// if it falls beyond the initial slice — keeps sidebar compact while
|
|
31
|
+
// always making the selected tag visible
|
|
32
|
+
const getVisibleTags = (): { entries: [string, number][]; appendedAt: number | null } => {
|
|
33
|
+
if (filter || expanded) return { entries: sortedTags, appendedAt: null };
|
|
34
|
+
const initial = sortedTags.slice(0, INITIAL_SHOW);
|
|
35
|
+
if (activeIndex >= INITIAL_SHOW) {
|
|
36
|
+
return { entries: [...initial, sortedTags[activeIndex]], appendedAt: INITIAL_SHOW };
|
|
37
|
+
}
|
|
38
|
+
return { entries: initial, appendedAt: null };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const { entries: visibleTags, appendedAt } = getVisibleTags();
|
|
42
|
+
const remainingCount = sortedTags.length - visibleTags.length;
|
|
43
|
+
const showExpandButton = !filter && !expanded && remainingCount > 0;
|
|
44
|
+
// Only allow collapsing if it won't hide the active tag
|
|
45
|
+
const showCollapseButton = expanded && !filter && (activeIndex === -1 || activeIndex < INITIAL_SHOW);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<aside className="hidden lg:block flex-shrink-0">
|
|
49
|
+
<div className="sticky top-24">
|
|
50
|
+
|
|
51
|
+
{/* Section heading → links to all tags, shows total count */}
|
|
52
|
+
<Link
|
|
53
|
+
href="/tags"
|
|
54
|
+
className="flex items-center gap-1.5 text-[10px] font-sans font-bold uppercase tracking-widest text-muted hover:text-accent transition-colors no-underline mb-3"
|
|
55
|
+
>
|
|
56
|
+
<LuTag className="w-3 h-3" />
|
|
57
|
+
<span>{t('tags')}</span>
|
|
58
|
+
<span className="ml-auto font-mono font-normal normal-case tracking-normal text-muted/50">
|
|
59
|
+
{totalCount}
|
|
60
|
+
</span>
|
|
61
|
+
</Link>
|
|
62
|
+
|
|
63
|
+
{/* Filter input with clear button */}
|
|
64
|
+
<div className="relative mb-3">
|
|
65
|
+
<LuSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-muted/40 pointer-events-none" />
|
|
66
|
+
<input
|
|
67
|
+
type="text"
|
|
68
|
+
value={filter}
|
|
69
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
70
|
+
placeholder="Filter…"
|
|
71
|
+
aria-label={t('filter_tags')}
|
|
72
|
+
className="w-full pl-8 pr-7 py-1.5 text-xs bg-muted/5 border border-muted/15 rounded-lg outline-none focus:border-accent/40 text-foreground placeholder:text-muted/40 transition-colors"
|
|
73
|
+
/>
|
|
74
|
+
{filter && (
|
|
75
|
+
<button
|
|
76
|
+
onClick={() => setFilter('')}
|
|
77
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted/40 hover:text-muted transition-colors p-0.5 rounded"
|
|
78
|
+
aria-label="Clear filter"
|
|
79
|
+
>
|
|
80
|
+
<LuX className="w-3 h-3" />
|
|
81
|
+
</button>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
{/* Tag list — no overflow, no scrollbar */}
|
|
86
|
+
<nav className="space-y-0.5">
|
|
87
|
+
{visibleTags.map(([tag, count], index) => {
|
|
88
|
+
const isActive = tag === activeTag;
|
|
89
|
+
// Thin separator before the appended active tag
|
|
90
|
+
const showSeparator = appendedAt !== null && index === appendedAt;
|
|
91
|
+
return (
|
|
92
|
+
<div key={tag}>
|
|
93
|
+
{showSeparator && <div className="my-1.5 h-px bg-muted/10" />}
|
|
94
|
+
<Link
|
|
95
|
+
href={`/tags/${encodeURIComponent(tag)}`}
|
|
96
|
+
className={`flex items-center justify-between px-2.5 py-1.5 rounded-lg text-sm no-underline transition-colors ${
|
|
97
|
+
isActive
|
|
98
|
+
? 'bg-accent/10 text-accent font-medium'
|
|
99
|
+
: 'text-foreground/70 hover:text-foreground hover:bg-muted/10'
|
|
100
|
+
}`}
|
|
101
|
+
>
|
|
102
|
+
<span className="truncate">{tag}</span>
|
|
103
|
+
<span className={`ml-2 text-xs font-mono flex-shrink-0 ${isActive ? 'text-accent/70' : 'text-muted/50'}`}>
|
|
104
|
+
{count}
|
|
105
|
+
</span>
|
|
106
|
+
</Link>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
})}
|
|
110
|
+
|
|
111
|
+
{/* Expand button */}
|
|
112
|
+
{showExpandButton && (
|
|
113
|
+
<button
|
|
114
|
+
onClick={() => setExpanded(true)}
|
|
115
|
+
aria-expanded={false}
|
|
116
|
+
className="w-full text-left px-2.5 py-1.5 text-xs text-muted/50 hover:text-accent transition-colors"
|
|
117
|
+
>
|
|
118
|
+
{tWith('more_tags', { count: remainingCount })}
|
|
119
|
+
</button>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{/* Collapse button */}
|
|
123
|
+
{showCollapseButton && (
|
|
124
|
+
<button
|
|
125
|
+
onClick={() => setExpanded(false)}
|
|
126
|
+
aria-expanded={true}
|
|
127
|
+
className="w-full text-left px-2.5 py-1.5 text-xs text-muted/50 hover:text-accent transition-colors"
|
|
128
|
+
>
|
|
129
|
+
{t('collapse_tags')}
|
|
130
|
+
</button>
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
{/* Empty state */}
|
|
134
|
+
{visibleTags.length === 0 && (
|
|
135
|
+
<p className="text-xs text-muted/60 italic px-2.5 py-2">{t('no_tags_found')}</p>
|
|
136
|
+
)}
|
|
137
|
+
</nav>
|
|
138
|
+
|
|
139
|
+
</div>
|
|
140
|
+
</aside>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import { LuSearch, LuX } from 'react-icons/lu';
|
|
6
|
+
import { useLanguage } from './LanguageProvider';
|
|
7
|
+
|
|
8
|
+
interface TagsIndexClientProps {
|
|
9
|
+
tags: Record<string, number>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type SortMode = 'popular' | 'alpha';
|
|
13
|
+
|
|
14
|
+
function getTagClasses(count: number, min: number, max: number): string {
|
|
15
|
+
const ratio = max === min ? 0.5 : (count - min) / (max - min);
|
|
16
|
+
if (ratio >= 0.8) return 'text-xl font-bold px-5 py-2.5';
|
|
17
|
+
if (ratio >= 0.6) return 'text-lg font-semibold px-5 py-2';
|
|
18
|
+
if (ratio >= 0.4) return 'text-base font-medium px-4 py-2';
|
|
19
|
+
if (ratio >= 0.2) return 'text-sm px-3.5 py-1.5';
|
|
20
|
+
return 'text-xs px-3 py-1.5';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function TagLink({ tag, count, min, max }: { tag: string; count: number; min: number; max: number }) {
|
|
24
|
+
return (
|
|
25
|
+
<Link
|
|
26
|
+
href={`/tags/${encodeURIComponent(tag)}`}
|
|
27
|
+
className={`group inline-flex items-baseline gap-1.5 rounded-xl border border-muted/20 bg-muted/5 hover:bg-background hover:border-accent hover:shadow-md hover:shadow-accent/5 no-underline transition-all duration-200 ${getTagClasses(count, min, max)}`}
|
|
28
|
+
>
|
|
29
|
+
<span className="text-foreground group-hover:text-accent transition-colors">{tag}</span>
|
|
30
|
+
<span className="font-mono text-muted/50 group-hover:text-accent/50 transition-colors" style={{ fontSize: '0.7em' }}>{count}</span>
|
|
31
|
+
</Link>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default function TagsIndexClient({ tags }: TagsIndexClientProps) {
|
|
36
|
+
const { t, tWith } = useLanguage();
|
|
37
|
+
const [filter, setFilter] = useState('');
|
|
38
|
+
const [sort, setSort] = useState<SortMode>('popular');
|
|
39
|
+
|
|
40
|
+
const total = Object.keys(tags).length;
|
|
41
|
+
const allEntries = Object.entries(tags);
|
|
42
|
+
const counts = allEntries.map(([, c]) => c);
|
|
43
|
+
const min = Math.min(...counts);
|
|
44
|
+
const max = Math.max(...counts);
|
|
45
|
+
|
|
46
|
+
const filtered = allEntries
|
|
47
|
+
.filter(([tag]) => !filter || tag.toLowerCase().includes(filter.toLowerCase()))
|
|
48
|
+
.sort((a, b) =>
|
|
49
|
+
sort === 'popular'
|
|
50
|
+
? b[1] - a[1]
|
|
51
|
+
: a[0].localeCompare(b[0])
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// Group by first letter for A-Z mode
|
|
55
|
+
const letterGroups = sort === 'alpha'
|
|
56
|
+
? filtered.reduce<Record<string, [string, number][]>>((acc, entry) => {
|
|
57
|
+
const letter = /^[a-zA-Z]/.test(entry[0]) ? entry[0][0].toUpperCase() : '#';
|
|
58
|
+
if (!acc[letter]) acc[letter] = [];
|
|
59
|
+
acc[letter].push(entry);
|
|
60
|
+
return acc;
|
|
61
|
+
}, {})
|
|
62
|
+
: null;
|
|
63
|
+
|
|
64
|
+
const sortedLetters = letterGroups
|
|
65
|
+
? Object.keys(letterGroups).sort((a, b) => a === '#' ? 1 : b === '#' ? -1 : a.localeCompare(b))
|
|
66
|
+
: null;
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div>
|
|
70
|
+
{/* Controls */}
|
|
71
|
+
<div className="flex flex-col sm:flex-row gap-3 mb-10">
|
|
72
|
+
<div className="relative flex-1 max-w-sm">
|
|
73
|
+
<LuSearch className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted/50 pointer-events-none" />
|
|
74
|
+
<input
|
|
75
|
+
type="text"
|
|
76
|
+
value={filter}
|
|
77
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
78
|
+
placeholder="Filter tags…"
|
|
79
|
+
aria-label={t('filter_tags')}
|
|
80
|
+
className="w-full pl-9 pr-8 py-2 text-sm bg-muted/5 border border-muted/15 rounded-lg outline-none focus:border-accent/40 text-foreground placeholder:text-muted/40 transition-colors"
|
|
81
|
+
/>
|
|
82
|
+
{filter && (
|
|
83
|
+
<button
|
|
84
|
+
onClick={() => setFilter('')}
|
|
85
|
+
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted/40 hover:text-muted transition-colors p-0.5 rounded"
|
|
86
|
+
aria-label="Clear filter"
|
|
87
|
+
>
|
|
88
|
+
<LuX className="w-3.5 h-3.5" />
|
|
89
|
+
</button>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<div className="flex rounded-lg border border-muted/15 overflow-hidden text-xs font-sans font-semibold self-start">
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
onClick={() => setSort('popular')}
|
|
97
|
+
aria-pressed={sort === 'popular'}
|
|
98
|
+
className={`px-4 py-2 transition-colors ${sort === 'popular' ? 'bg-accent/10 text-accent' : 'text-muted hover:text-foreground hover:bg-muted/5'}`}
|
|
99
|
+
>
|
|
100
|
+
{t('sort_popular')}
|
|
101
|
+
</button>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
onClick={() => setSort('alpha')}
|
|
105
|
+
aria-pressed={sort === 'alpha'}
|
|
106
|
+
className={`px-4 py-2 border-l border-muted/15 transition-colors ${sort === 'alpha' ? 'bg-accent/10 text-accent' : 'text-muted hover:text-foreground hover:bg-muted/5'}`}
|
|
107
|
+
>
|
|
108
|
+
{t('sort_az')}
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{/* Result count when filtering */}
|
|
114
|
+
{filter && (
|
|
115
|
+
<p className="text-xs font-mono text-muted mb-6">
|
|
116
|
+
{tWith('tags_count', { shown: filtered.length, total })}
|
|
117
|
+
</p>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
{/* Popular mode: flat size-scaled cloud */}
|
|
121
|
+
{sort === 'popular' && (
|
|
122
|
+
<div className="flex flex-wrap gap-3 items-baseline">
|
|
123
|
+
{filtered.map(([tag, count]) => (
|
|
124
|
+
<TagLink key={tag} tag={tag} count={count} min={min} max={max} />
|
|
125
|
+
))}
|
|
126
|
+
{filtered.length === 0 && (
|
|
127
|
+
<p className="text-sm text-muted italic">{tWith('tags_no_match', { filter })}</p>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
{/* A-Z mode: grouped under letter section headers */}
|
|
133
|
+
{sort === 'alpha' && sortedLetters && (
|
|
134
|
+
<div>
|
|
135
|
+
{sortedLetters.length === 0 ? (
|
|
136
|
+
<p className="text-sm text-muted italic">{tWith('tags_no_match', { filter })}</p>
|
|
137
|
+
) : (
|
|
138
|
+
sortedLetters.map((letter, i) => (
|
|
139
|
+
<div key={letter} className={i > 0 ? 'mt-10' : ''}>
|
|
140
|
+
<div className="flex items-center gap-3 mb-4">
|
|
141
|
+
<span className="text-xs font-mono font-bold text-muted/40 w-4">{letter}</span>
|
|
142
|
+
<div className="flex-1 h-px bg-muted/10" />
|
|
143
|
+
</div>
|
|
144
|
+
<div className="flex flex-wrap gap-3 items-baseline">
|
|
145
|
+
{letterGroups![letter].map(([tag, count]) => (
|
|
146
|
+
<TagLink key={tag} tag={tag} count={count} min={min} max={max} />
|
|
147
|
+
))}
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
))
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Module-level subscriber set — only one DOM scroll listener exists
|
|
7
|
+
* regardless of how many components call this hook.
|
|
8
|
+
*/
|
|
9
|
+
const listeners = new Set<(y: number) => void>();
|
|
10
|
+
|
|
11
|
+
function onScroll() {
|
|
12
|
+
const y = window.scrollY;
|
|
13
|
+
listeners.forEach(fn => fn(y));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns the current window.scrollY, updating on every scroll event.
|
|
18
|
+
* A single passive scroll listener is shared across all consumers.
|
|
19
|
+
*/
|
|
20
|
+
export function useScrollY(): number {
|
|
21
|
+
const [scrollY, setScrollY] = useState(0);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (listeners.size === 0) {
|
|
25
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
26
|
+
}
|
|
27
|
+
listeners.add(setScrollY);
|
|
28
|
+
// Sync on mount via RAF to avoid cascading render error
|
|
29
|
+
const rafId = requestAnimationFrame(() => setScrollY(window.scrollY));
|
|
30
|
+
|
|
31
|
+
return () => {
|
|
32
|
+
cancelAnimationFrame(rafId);
|
|
33
|
+
listeners.delete(setScrollY);
|
|
34
|
+
if (listeners.size === 0) {
|
|
35
|
+
window.removeEventListener('scroll', onScroll);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
return scrollY;
|
|
41
|
+
}
|
package/src/i18n/translations.ts
CHANGED
|
@@ -25,8 +25,12 @@ export const translations = {
|
|
|
25
25
|
categories: "Categories",
|
|
26
26
|
articles: "Articles",
|
|
27
27
|
posts: "Posts",
|
|
28
|
+
links: "Links",
|
|
28
29
|
explore: "Explore",
|
|
29
30
|
connect: "Connect",
|
|
31
|
+
rss_feed: "RSS Feed",
|
|
32
|
+
privacy: "Privacy",
|
|
33
|
+
built_with: "Built with Amytis",
|
|
30
34
|
on_this_page: "On this page",
|
|
31
35
|
back_to_top: "Back to top",
|
|
32
36
|
archive_subtitle: "{count} posts across {years} years.",
|
|
@@ -56,7 +60,7 @@ export const translations = {
|
|
|
56
60
|
selected_books: "Selected Books",
|
|
57
61
|
flow: "Flow",
|
|
58
62
|
recent_notes: "Recent Notes",
|
|
59
|
-
all_flows: "All
|
|
63
|
+
all_flows: "All Flows",
|
|
60
64
|
no_flows: "No notes yet.",
|
|
61
65
|
flow_subtitle: "{count} daily notes.",
|
|
62
66
|
flows_in_year: "Notes in {year}",
|
|
@@ -75,6 +79,54 @@ export const translations = {
|
|
|
75
79
|
search_tip_phrase: "Quoted string for exact matching (\" \" = Exact match)",
|
|
76
80
|
search_tip_and: "Use spaces to combine keywords (Space = AND)",
|
|
77
81
|
search_tip_exclude: "Exclude a term (- = Exclude)",
|
|
82
|
+
discuss_post: "Discuss this post",
|
|
83
|
+
share_post: "Share",
|
|
84
|
+
copy_link: "Copy link",
|
|
85
|
+
link_copied: "Copied!",
|
|
86
|
+
flow_notes: "Flow Notes",
|
|
87
|
+
tag_post_count: "{count} posts",
|
|
88
|
+
tag_post_count_one: "1 post",
|
|
89
|
+
tag_flow_count: "{count} flow notes",
|
|
90
|
+
tag_flow_count_one: "1 flow note",
|
|
91
|
+
subscribe: "Subscribe",
|
|
92
|
+
subscribe_subtitle: "Stay updated with new posts and notes via your preferred channel.",
|
|
93
|
+
rss_readers: "RSS Readers",
|
|
94
|
+
rss_description: "Subscribe with any RSS reader for automatic updates when new content is published.",
|
|
95
|
+
email_newsletter: "Email Newsletter",
|
|
96
|
+
email_newsletter_description: "Get new posts delivered directly to your inbox.",
|
|
97
|
+
telegram_channel: "Telegram",
|
|
98
|
+
telegram_channel_description: "Instant updates via Telegram channel.",
|
|
99
|
+
wechat_official: "WeChat Official Account",
|
|
100
|
+
wechat_description: "Follow on WeChat for updates.",
|
|
101
|
+
scan_qr_code: "Scan to follow",
|
|
102
|
+
copy_feed_url: "Copy feed URL",
|
|
103
|
+
feed_url_copied: "Copied!",
|
|
104
|
+
join_channel: "Join Channel",
|
|
105
|
+
subscribe_on_substack: "Subscribe on Substack",
|
|
106
|
+
subscribe_via_email: "Subscribe via Email",
|
|
107
|
+
social_connections: "Social",
|
|
108
|
+
older: "Older",
|
|
109
|
+
newer: "Newer",
|
|
110
|
+
tab_all: "All",
|
|
111
|
+
post_navigation: "Post navigation",
|
|
112
|
+
filter_tags: "Filter tags",
|
|
113
|
+
no_tags_found: "No tags found",
|
|
114
|
+
more_tags: "+ {count} more",
|
|
115
|
+
collapse_tags: "Show less",
|
|
116
|
+
sort_popular: "Popular",
|
|
117
|
+
sort_az: "A–Z",
|
|
118
|
+
tags_count: "{shown} / {total} tags",
|
|
119
|
+
tags_no_match: "No tags match \"{filter}\"",
|
|
120
|
+
notes: "Notes",
|
|
121
|
+
notes_subtitle: "{count} knowledge base notes.",
|
|
122
|
+
tab_daily_flow: "Daily",
|
|
123
|
+
tab_graph: "Graph",
|
|
124
|
+
backlinks: "Backlinks",
|
|
125
|
+
graph_subtitle: "A visual map of connected knowledge.",
|
|
126
|
+
search_type_note: "Note",
|
|
127
|
+
all_notes: "All Notes",
|
|
128
|
+
no_notes: "No notes yet.",
|
|
129
|
+
more: "More",
|
|
78
130
|
},
|
|
79
131
|
zh: {
|
|
80
132
|
home: "首页",
|
|
@@ -102,8 +154,12 @@ export const translations = {
|
|
|
102
154
|
categories: "分类",
|
|
103
155
|
articles: "文章",
|
|
104
156
|
posts: "文章",
|
|
157
|
+
links: "链接",
|
|
105
158
|
explore: "探索",
|
|
106
159
|
connect: "连接",
|
|
160
|
+
rss_feed: "RSS 订阅",
|
|
161
|
+
privacy: "隐私政策",
|
|
162
|
+
built_with: "基于 Amytis 构建",
|
|
107
163
|
on_this_page: "本页目录",
|
|
108
164
|
back_to_top: "返回顶部",
|
|
109
165
|
archive_subtitle: "横跨 {years} 年,共 {count} 篇文章。",
|
|
@@ -152,6 +208,54 @@ export const translations = {
|
|
|
152
208
|
search_tip_phrase: "加引号精确匹配短语(\" \" 表示精确匹配)",
|
|
153
209
|
search_tip_and: "使用空格组合关键词(空格表示 AND)",
|
|
154
210
|
search_tip_exclude: "排除关键词(- 表示排除)",
|
|
211
|
+
discuss_post: "讨论这篇文章",
|
|
212
|
+
share_post: "分享",
|
|
213
|
+
copy_link: "复制链接",
|
|
214
|
+
link_copied: "已复制",
|
|
215
|
+
flow_notes: "随笔",
|
|
216
|
+
tag_post_count: "{count} 篇文章",
|
|
217
|
+
tag_post_count_one: "1 篇文章",
|
|
218
|
+
tag_flow_count: "{count} 条随笔",
|
|
219
|
+
tag_flow_count_one: "1 条随笔",
|
|
220
|
+
subscribe: "订阅",
|
|
221
|
+
subscribe_subtitle: "通过您喜爱的方式订阅,及时获取新文章和随笔。",
|
|
222
|
+
rss_readers: "RSS 阅读器",
|
|
223
|
+
rss_description: "通过 RSS 阅读器订阅,发布新内容时自动获取更新。",
|
|
224
|
+
email_newsletter: "邮件订阅",
|
|
225
|
+
email_newsletter_description: "将新文章直接发送到您的邮箱。",
|
|
226
|
+
telegram_channel: "Telegram 频道",
|
|
227
|
+
telegram_channel_description: "通过 Telegram 频道获取即时更新。",
|
|
228
|
+
wechat_official: "微信公众号",
|
|
229
|
+
wechat_description: "关注微信公众号获取更新。",
|
|
230
|
+
scan_qr_code: "扫码关注",
|
|
231
|
+
copy_feed_url: "复制订阅链接",
|
|
232
|
+
feed_url_copied: "已复制!",
|
|
233
|
+
join_channel: "加入频道",
|
|
234
|
+
subscribe_on_substack: "在 Substack 订阅",
|
|
235
|
+
subscribe_via_email: "邮件订阅",
|
|
236
|
+
social_connections: "社交媒体",
|
|
237
|
+
older: "更早",
|
|
238
|
+
newer: "更新",
|
|
239
|
+
tab_all: "全部",
|
|
240
|
+
post_navigation: "文章导航",
|
|
241
|
+
filter_tags: "筛选标签",
|
|
242
|
+
no_tags_found: "未找到标签",
|
|
243
|
+
more_tags: "还有 {count} 个",
|
|
244
|
+
collapse_tags: "收起",
|
|
245
|
+
sort_popular: "热门",
|
|
246
|
+
sort_az: "A–Z",
|
|
247
|
+
tags_count: "{shown} / {total} 个标签",
|
|
248
|
+
tags_no_match: "未找到匹配\"{filter}\"的标签",
|
|
249
|
+
notes: "笔记",
|
|
250
|
+
notes_subtitle: "共 {count} 条知识库笔记。",
|
|
251
|
+
tab_daily_flow: "随笔",
|
|
252
|
+
tab_graph: "图谱",
|
|
253
|
+
backlinks: "反向链接",
|
|
254
|
+
graph_subtitle: "知识连接的可视化地图。",
|
|
255
|
+
search_type_note: "笔记",
|
|
256
|
+
all_notes: "全部笔记",
|
|
257
|
+
no_notes: "暂无笔记。",
|
|
258
|
+
more: "更多",
|
|
155
259
|
},
|
|
156
260
|
};
|
|
157
261
|
|