@hutusi/amytis 1.7.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 +6 -0
- package/README.md +14 -0
- package/TODO.md +15 -3
- package/bun.lock +5 -3
- 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 +0 -1
- 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/docs/ARCHITECTURE.md +8 -0
- package/docs/CONTRIBUTING.md +11 -0
- package/docs/DIGITAL_GARDEN.md +64 -0
- package/package.json +7 -3
- 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 +21 -1
- package/src/app/flows/[year]/[month]/[day]/page.tsx +32 -29
- 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/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 +0 -1
- package/src/app/posts/[slug]/page.tsx +4 -2
- package/src/app/search.json/route.ts +15 -1
- package/src/components/Backlinks.tsx +39 -0
- 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/KnowledgeGraph.tsx +324 -0
- package/src/components/MarkdownRenderer.tsx +13 -2
- package/src/components/Navbar.tsx +235 -9
- package/src/components/NoteContent.tsx +123 -0
- package/src/components/NoteSidebar.tsx +132 -0
- package/src/components/RecentNotesSection.tsx +6 -11
- package/src/components/Search.tsx +5 -1
- package/src/components/TagContentTabs.tsx +0 -1
- package/src/i18n/translations.ts +21 -1
- package/src/layouts/PostLayout.tsx +8 -3
- package/src/lib/markdown.ts +276 -3
- package/src/lib/remark-wikilinks.ts +59 -0
- package/src/lib/search-utils.ts +2 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { getAllNotes, getNoteBySlug, buildSlugRegistry, getBacklinks, getAdjacentNotes } from '@/lib/markdown';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
import { Metadata } from 'next';
|
|
4
|
+
import { siteConfig } from '../../../../site.config';
|
|
5
|
+
import { t, resolveLocale } from '@/lib/i18n';
|
|
6
|
+
import MarkdownRenderer from '@/components/MarkdownRenderer';
|
|
7
|
+
import NoteSidebar from '@/components/NoteSidebar';
|
|
8
|
+
import Tag from '@/components/Tag';
|
|
9
|
+
import Link from 'next/link';
|
|
10
|
+
|
|
11
|
+
export function generateStaticParams() {
|
|
12
|
+
const notes = getAllNotes();
|
|
13
|
+
// Return a placeholder when empty so Next.js static export doesn't error on the dynamic route
|
|
14
|
+
if (notes.length === 0) return [{ slug: '_empty' }];
|
|
15
|
+
return notes.map(note => ({ slug: note.slug }));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const dynamicParams = false;
|
|
19
|
+
|
|
20
|
+
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
|
|
21
|
+
const { slug } = await params;
|
|
22
|
+
const note = getNoteBySlug(slug);
|
|
23
|
+
if (!note) return { title: 'Not Found' };
|
|
24
|
+
return {
|
|
25
|
+
title: `${note.title} | ${resolveLocale(siteConfig.title)}`,
|
|
26
|
+
description: note.excerpt,
|
|
27
|
+
openGraph: {
|
|
28
|
+
title: note.title,
|
|
29
|
+
description: note.excerpt,
|
|
30
|
+
type: 'article',
|
|
31
|
+
publishedTime: note.date,
|
|
32
|
+
siteName: resolveLocale(siteConfig.title),
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default async function NotePage({ params }: { params: Promise<{ slug: string }> }) {
|
|
38
|
+
const { slug } = await params;
|
|
39
|
+
const note = getNoteBySlug(slug);
|
|
40
|
+
if (!note) notFound();
|
|
41
|
+
|
|
42
|
+
const slugRegistry = buildSlugRegistry();
|
|
43
|
+
const backlinks = getBacklinks(slug);
|
|
44
|
+
const { prev, next } = getAdjacentNotes(slug);
|
|
45
|
+
|
|
46
|
+
const showToc = note.toc !== false && note.headings.length > 0;
|
|
47
|
+
const visibleBacklinks = note.backlinks !== false ? backlinks : [];
|
|
48
|
+
const showSidebar = showToc || visibleBacklinks.length > 0;
|
|
49
|
+
|
|
50
|
+
const breadcrumb = (
|
|
51
|
+
<nav aria-label="Breadcrumb" className="flex items-center gap-1.5 text-sm text-muted">
|
|
52
|
+
<Link href="/notes" className="hover:text-accent no-underline">
|
|
53
|
+
{t('notes')}
|
|
54
|
+
</Link>
|
|
55
|
+
<span className="text-muted/40" aria-hidden="true">›</span>
|
|
56
|
+
<span className="text-foreground truncate">{note.title}</span>
|
|
57
|
+
</nav>
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="layout-main">
|
|
62
|
+
{!showSidebar && <div className="mb-6">{breadcrumb}</div>}
|
|
63
|
+
|
|
64
|
+
<div className={showSidebar
|
|
65
|
+
? 'grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] gap-8 items-start'
|
|
66
|
+
: 'max-w-3xl mx-auto'
|
|
67
|
+
}>
|
|
68
|
+
{showSidebar && (
|
|
69
|
+
<NoteSidebar
|
|
70
|
+
headings={note.headings}
|
|
71
|
+
showToc={showToc}
|
|
72
|
+
backlinks={visibleBacklinks}
|
|
73
|
+
breadcrumb={breadcrumb}
|
|
74
|
+
/>
|
|
75
|
+
)}
|
|
76
|
+
<article className="min-w-0 max-w-3xl mx-auto">
|
|
77
|
+
<header className="mb-8 border-b border-muted/10 pb-8">
|
|
78
|
+
{note.draft && (
|
|
79
|
+
<div className="mb-4">
|
|
80
|
+
<span className="text-xs font-bold text-red-500 bg-red-100 dark:bg-red-900/30 px-2 py-1 rounded tracking-widest inline-block">
|
|
81
|
+
DRAFT
|
|
82
|
+
</span>
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
<time className="text-sm font-mono text-accent" data-pagefind-meta="date[content]">
|
|
86
|
+
{note.date}
|
|
87
|
+
</time>
|
|
88
|
+
<h1 className="mt-2 text-3xl md:text-4xl font-serif font-bold text-heading leading-tight">
|
|
89
|
+
{note.title}
|
|
90
|
+
</h1>
|
|
91
|
+
{note.tags.length > 0 && (
|
|
92
|
+
<div className="flex flex-wrap gap-2 mt-4">
|
|
93
|
+
{note.tags.map(tag => (
|
|
94
|
+
<Tag key={tag} tag={tag} variant="default" />
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
</header>
|
|
99
|
+
|
|
100
|
+
<MarkdownRenderer content={note.content} slug={note.slug} slugRegistry={slugRegistry} />
|
|
101
|
+
|
|
102
|
+
{/* Prev/Next navigation */}
|
|
103
|
+
<nav aria-label="Note navigation" className="mt-12 pt-12 border-t border-muted/20 grid grid-cols-2 gap-4">
|
|
104
|
+
{prev ? (
|
|
105
|
+
<Link href={`/notes/${prev.slug}`} className="group text-left no-underline">
|
|
106
|
+
<span className="text-xs text-muted">{t('older')}</span>
|
|
107
|
+
<div className="text-sm font-medium text-heading group-hover:text-accent transition-colors truncate">
|
|
108
|
+
{prev.title}
|
|
109
|
+
</div>
|
|
110
|
+
<span className="text-xs font-mono text-muted">{prev.date}</span>
|
|
111
|
+
</Link>
|
|
112
|
+
) : <div />}
|
|
113
|
+
{next ? (
|
|
114
|
+
<Link href={`/notes/${next.slug}`} className="group text-right no-underline">
|
|
115
|
+
<span className="text-xs text-muted">{t('newer')}</span>
|
|
116
|
+
<div className="text-sm font-medium text-heading group-hover:text-accent transition-colors truncate">
|
|
117
|
+
{next.title}
|
|
118
|
+
</div>
|
|
119
|
+
<span className="text-xs font-mono text-muted">{next.date}</span>
|
|
120
|
+
</Link>
|
|
121
|
+
) : <div />}
|
|
122
|
+
</nav>
|
|
123
|
+
</article>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { getAllNotes, getNoteTags } from '@/lib/markdown';
|
|
2
|
+
import { siteConfig } from '../../../../../site.config';
|
|
3
|
+
import { Metadata } from 'next';
|
|
4
|
+
import { notFound } from 'next/navigation';
|
|
5
|
+
import { t, resolveLocale } from '@/lib/i18n';
|
|
6
|
+
import PageHeader from '@/components/PageHeader';
|
|
7
|
+
import NoteContent from '@/components/NoteContent';
|
|
8
|
+
import FlowHubTabs from '@/components/FlowHubTabs';
|
|
9
|
+
|
|
10
|
+
const PAGE_SIZE = siteConfig.pagination.notes ?? 20;
|
|
11
|
+
|
|
12
|
+
export function generateStaticParams() {
|
|
13
|
+
const allNotes = getAllNotes();
|
|
14
|
+
const totalPages = Math.ceil(allNotes.length / PAGE_SIZE);
|
|
15
|
+
const pageCount = Math.max(1, totalPages - 1);
|
|
16
|
+
return Array.from({ length: pageCount }, (_, i) => ({
|
|
17
|
+
page: (i + 2).toString(),
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const dynamicParams = false;
|
|
22
|
+
|
|
23
|
+
export async function generateMetadata({ params }: { params: Promise<{ page: string }> }): Promise<Metadata> {
|
|
24
|
+
const { page } = await params;
|
|
25
|
+
return {
|
|
26
|
+
title: `${t('notes')} - ${page} | ${resolveLocale(siteConfig.title)}`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default async function NotesPaginatedPage({ params }: { params: Promise<{ page: string }> }) {
|
|
31
|
+
const { page: pageStr } = await params;
|
|
32
|
+
const page = parseInt(pageStr, 10);
|
|
33
|
+
const allNotes = getAllNotes();
|
|
34
|
+
const totalPages = Math.ceil(allNotes.length / PAGE_SIZE);
|
|
35
|
+
|
|
36
|
+
if (page > totalPages) notFound();
|
|
37
|
+
|
|
38
|
+
const tags = getNoteTags();
|
|
39
|
+
const start = (page - 1) * PAGE_SIZE;
|
|
40
|
+
const notes = allNotes.slice(start, start + PAGE_SIZE);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="layout-main">
|
|
44
|
+
<PageHeader
|
|
45
|
+
titleKey="notes"
|
|
46
|
+
subtitleKey="page_of_total"
|
|
47
|
+
subtitleParams={{ page, total: totalPages }}
|
|
48
|
+
className="mb-12"
|
|
49
|
+
/>
|
|
50
|
+
<FlowHubTabs />
|
|
51
|
+
<NoteContent
|
|
52
|
+
notes={notes}
|
|
53
|
+
tags={tags}
|
|
54
|
+
pagination={{ currentPage: page, totalPages, basePath: '/notes' }}
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { getAllNotes, getNoteTags } from '@/lib/markdown';
|
|
2
|
+
import { siteConfig } from '../../../site.config';
|
|
3
|
+
import { Metadata } from 'next';
|
|
4
|
+
import { t, tWith, resolveLocale } from '@/lib/i18n';
|
|
5
|
+
import NoteContent from '@/components/NoteContent';
|
|
6
|
+
import FlowHubTabs from '@/components/FlowHubTabs';
|
|
7
|
+
|
|
8
|
+
const PAGE_SIZE = siteConfig.pagination.notes ?? 20;
|
|
9
|
+
|
|
10
|
+
export const metadata: Metadata = {
|
|
11
|
+
title: `${t('notes')} | ${resolveLocale(siteConfig.title)}`,
|
|
12
|
+
description: 'Knowledge base notes.',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export default function NotesPage() {
|
|
16
|
+
const allNotes = getAllNotes();
|
|
17
|
+
const totalPages = Math.ceil(allNotes.length / PAGE_SIZE);
|
|
18
|
+
const notes = allNotes.slice(0, PAGE_SIZE);
|
|
19
|
+
const tags = getNoteTags();
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className="layout-main">
|
|
23
|
+
<FlowHubTabs subtitle={tWith('notes_subtitle', { count: allNotes.length })} />
|
|
24
|
+
<NoteContent
|
|
25
|
+
notes={notes}
|
|
26
|
+
tags={tags}
|
|
27
|
+
pagination={totalPages > 1 ? { currentPage: 1, totalPages, basePath: '/notes' } : undefined}
|
|
28
|
+
/>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
package/src/app/page.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getPostBySlug, getAllPosts, getRelatedPosts, getSeriesPosts, getSeriesData, getAdjacentPosts, PostData } from '@/lib/markdown';
|
|
1
|
+
import { getPostBySlug, getAllPosts, getRelatedPosts, getSeriesPosts, getSeriesData, getAdjacentPosts, buildSlugRegistry, getBacklinks, PostData } from '@/lib/markdown';
|
|
2
2
|
import { notFound } from 'next/navigation';
|
|
3
3
|
import PostLayout from '@/layouts/PostLayout';
|
|
4
4
|
import SimpleLayout from '@/layouts/SimpleLayout';
|
|
@@ -99,6 +99,8 @@ export default async function PostPage({
|
|
|
99
99
|
|
|
100
100
|
const relatedPosts = getRelatedPosts(slug);
|
|
101
101
|
const { prev, next } = getAdjacentPosts(slug);
|
|
102
|
+
const slugRegistry = buildSlugRegistry();
|
|
103
|
+
const backlinks = getBacklinks(slug);
|
|
102
104
|
let seriesPosts: PostData[] = [];
|
|
103
105
|
let seriesTitle: string | undefined;
|
|
104
106
|
|
|
@@ -109,5 +111,5 @@ export default async function PostPage({
|
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
// Default to standard post layout
|
|
112
|
-
return <PostLayout post={post} relatedPosts={relatedPosts} seriesPosts={seriesPosts} seriesTitle={seriesTitle} prevPost={prev} nextPost={next} />;
|
|
114
|
+
return <PostLayout post={post} relatedPosts={relatedPosts} seriesPosts={seriesPosts} seriesTitle={seriesTitle} prevPost={prev} nextPost={next} backlinks={backlinks} slugRegistry={slugRegistry} />;
|
|
113
115
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getAllPosts, getAllBooks, getBookChapter, getAllFlows } from '@/lib/markdown';
|
|
1
|
+
import { getAllPosts, getAllBooks, getBookChapter, getAllFlows, getAllNotes } from '@/lib/markdown';
|
|
2
2
|
import { stripMarkdown } from '@/lib/search-utils';
|
|
3
3
|
|
|
4
4
|
export const dynamic = 'force-static';
|
|
@@ -49,5 +49,19 @@ export async function GET() {
|
|
|
49
49
|
});
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// Add notes to search index
|
|
53
|
+
const notes = getAllNotes();
|
|
54
|
+
for (const note of notes) {
|
|
55
|
+
searchIndex.push({
|
|
56
|
+
title: note.title,
|
|
57
|
+
slug: `notes/${note.slug}`,
|
|
58
|
+
date: note.date,
|
|
59
|
+
excerpt: note.excerpt,
|
|
60
|
+
category: 'Note',
|
|
61
|
+
tags: note.tags,
|
|
62
|
+
content: stripMarkdown(note.content),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
52
66
|
return Response.json(searchIndex);
|
|
53
67
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { BacklinkSource } from '@/lib/markdown';
|
|
3
|
+
import { t } from '@/lib/i18n';
|
|
4
|
+
|
|
5
|
+
interface BacklinksProps {
|
|
6
|
+
backlinks: BacklinkSource[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default function Backlinks({ backlinks }: BacklinksProps) {
|
|
10
|
+
if (!backlinks.length) return null;
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div className="mt-12 pt-12 border-t border-muted/20">
|
|
14
|
+
<h3 className="text-sm font-sans font-semibold uppercase tracking-widest text-muted mb-4">
|
|
15
|
+
{t('backlinks')}
|
|
16
|
+
</h3>
|
|
17
|
+
<div className="flex flex-col gap-4">
|
|
18
|
+
{backlinks.map(bl => (
|
|
19
|
+
<div key={`${bl.type}-${bl.slug}`} className="flex flex-col gap-1">
|
|
20
|
+
<div className="flex items-center gap-2">
|
|
21
|
+
<span className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted/60 border border-muted/20 rounded px-1.5 py-0.5">
|
|
22
|
+
{bl.type}
|
|
23
|
+
</span>
|
|
24
|
+
<Link
|
|
25
|
+
href={bl.url}
|
|
26
|
+
className="text-sm font-medium text-heading hover:text-accent no-underline transition-colors"
|
|
27
|
+
>
|
|
28
|
+
{bl.title}
|
|
29
|
+
</Link>
|
|
30
|
+
</div>
|
|
31
|
+
{bl.context && (
|
|
32
|
+
<p className="text-sm text-muted leading-relaxed">“{bl.context}”</p>
|
|
33
|
+
)}
|
|
34
|
+
</div>
|
|
35
|
+
))}
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useMemo } from 'react';
|
|
3
|
+
import { useState, useMemo, type ReactNode } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import { useLanguage } from '@/components/LanguageProvider';
|
|
6
6
|
|
|
@@ -10,11 +10,12 @@ interface FlowCalendarSidebarProps {
|
|
|
10
10
|
tags?: Record<string, number>;
|
|
11
11
|
selectedTag?: string | null;
|
|
12
12
|
onTagSelect?: (tag: string) => void;
|
|
13
|
+
breadcrumb?: ReactNode;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
|
16
17
|
|
|
17
|
-
export default function FlowCalendarSidebar({ entryDates, currentDate, tags, selectedTag, onTagSelect }: FlowCalendarSidebarProps) {
|
|
18
|
+
export default function FlowCalendarSidebar({ entryDates, currentDate, tags, selectedTag, onTagSelect, breadcrumb }: FlowCalendarSidebarProps) {
|
|
18
19
|
const { t } = useLanguage();
|
|
19
20
|
const initialDate = currentDate ? new Date(currentDate + 'T00:00:00') : new Date();
|
|
20
21
|
const [viewYear, setViewYear] = useState(initialDate.getFullYear());
|
|
@@ -77,6 +78,7 @@ export default function FlowCalendarSidebar({ entryDates, currentDate, tags, sel
|
|
|
77
78
|
|
|
78
79
|
return (
|
|
79
80
|
<aside className="hidden lg:block sticky top-20 self-start w-[280px] max-h-[calc(100vh-6rem)]">
|
|
81
|
+
{breadcrumb && <div className="mb-4">{breadcrumb}</div>}
|
|
80
82
|
<div className="border border-muted/20 rounded-lg p-4">
|
|
81
83
|
{/* Month navigation */}
|
|
82
84
|
<div className="flex items-center justify-between mb-3">
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useMemo } from 'react';
|
|
3
|
+
import { useState, useMemo, type ReactNode } from 'react';
|
|
4
4
|
import { useLanguage } from '@/components/LanguageProvider';
|
|
5
5
|
import FlowCalendarSidebar from '@/components/FlowCalendarSidebar';
|
|
6
6
|
import FlowTimelineEntry from '@/components/FlowTimelineEntry';
|
|
@@ -24,9 +24,10 @@ interface FlowContentProps {
|
|
|
24
24
|
totalPages: number;
|
|
25
25
|
basePath: string;
|
|
26
26
|
};
|
|
27
|
+
breadcrumb?: ReactNode;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
export default function FlowContent({ flows, entryDates, tags, currentDate, pagination }: FlowContentProps) {
|
|
30
|
+
export default function FlowContent({ flows, entryDates, tags, currentDate, pagination, breadcrumb }: FlowContentProps) {
|
|
30
31
|
const { t } = useLanguage();
|
|
31
32
|
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
|
32
33
|
|
|
@@ -47,6 +48,7 @@ export default function FlowContent({ flows, entryDates, tags, currentDate, pagi
|
|
|
47
48
|
tags={tags}
|
|
48
49
|
selectedTag={selectedTag}
|
|
49
50
|
onTagSelect={handleTagSelect}
|
|
51
|
+
breadcrumb={breadcrumb}
|
|
50
52
|
/>
|
|
51
53
|
|
|
52
54
|
<div className="flex-1 min-w-0">
|
|
@@ -72,7 +74,6 @@ export default function FlowContent({ flows, entryDates, tags, currentDate, pagi
|
|
|
72
74
|
<FlowTimelineEntry
|
|
73
75
|
key={flow.slug}
|
|
74
76
|
date={flow.date}
|
|
75
|
-
title={flow.title}
|
|
76
77
|
excerpt={flow.excerpt}
|
|
77
78
|
tags={flow.tags}
|
|
78
79
|
slug={flow.slug}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { usePathname } from 'next/navigation';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { useLanguage } from './LanguageProvider';
|
|
6
|
+
|
|
7
|
+
interface FlowHubTabsProps {
|
|
8
|
+
subtitle?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function FlowHubTabs({ subtitle }: FlowHubTabsProps) {
|
|
12
|
+
const pathname = usePathname();
|
|
13
|
+
const { t } = useLanguage();
|
|
14
|
+
|
|
15
|
+
// Normalize: strip trailing slash added by next.config trailingSlash:true
|
|
16
|
+
const path = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
|
17
|
+
|
|
18
|
+
const isFlowsActive = path === '/flows' || path.startsWith('/flows/page');
|
|
19
|
+
const isNotesActive = path === '/notes' || path.startsWith('/notes/page');
|
|
20
|
+
const isGraphActive = path.startsWith('/graph');
|
|
21
|
+
|
|
22
|
+
const tabs = [
|
|
23
|
+
{ href: '/flows', label: t('tab_daily_flow'), active: isFlowsActive },
|
|
24
|
+
{ href: '/notes', label: t('notes'), active: isNotesActive },
|
|
25
|
+
{ href: '/graph', label: t('tab_graph'), active: isGraphActive },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="mb-10">
|
|
30
|
+
<div className="flex items-end gap-8 border-b border-muted/20">
|
|
31
|
+
{tabs.map(tab => (
|
|
32
|
+
<Link
|
|
33
|
+
key={tab.href}
|
|
34
|
+
href={tab.href}
|
|
35
|
+
className={`pb-3 text-3xl font-bold no-underline border-b-2 -mb-px transition-colors ${
|
|
36
|
+
tab.active
|
|
37
|
+
? 'border-accent text-heading'
|
|
38
|
+
: 'border-transparent text-muted/30 hover:text-muted/60'
|
|
39
|
+
}`}
|
|
40
|
+
>
|
|
41
|
+
{tab.label}
|
|
42
|
+
</Link>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
{subtitle && (
|
|
46
|
+
<p className="mt-3 text-sm text-muted">{subtitle}</p>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -3,25 +3,23 @@ import Tag from './Tag';
|
|
|
3
3
|
|
|
4
4
|
interface FlowTimelineEntryProps {
|
|
5
5
|
date: string;
|
|
6
|
-
title: string;
|
|
7
6
|
excerpt: string;
|
|
8
7
|
tags: string[];
|
|
9
8
|
slug: string;
|
|
10
9
|
}
|
|
11
10
|
|
|
12
|
-
export default function FlowTimelineEntry({ date,
|
|
11
|
+
export default function FlowTimelineEntry({ date, excerpt, tags, slug }: FlowTimelineEntryProps) {
|
|
13
12
|
return (
|
|
14
13
|
<article className="relative pl-6 pb-8 border-l-2 border-muted/20 last:pb-0">
|
|
15
14
|
{/* Timeline dot */}
|
|
16
15
|
<div className="absolute -left-[5px] top-1.5 w-2 h-2 rounded-full bg-accent" />
|
|
17
16
|
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
</
|
|
23
|
-
|
|
24
|
-
<p className="text-sm text-muted leading-relaxed line-clamp-3">{excerpt}</p>
|
|
17
|
+
<Link href={`/flows/${slug}`} className="no-underline group">
|
|
18
|
+
<time className="text-sm font-mono text-accent group-hover:text-accent/70 transition-colors">{date}</time>
|
|
19
|
+
</Link>
|
|
20
|
+
{excerpt && (
|
|
21
|
+
<p className="mt-1.5 text-sm text-muted leading-relaxed line-clamp-3">{excerpt}</p>
|
|
22
|
+
)}
|
|
25
23
|
{tags.length > 0 && (
|
|
26
24
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
27
25
|
{tags.map(tag => (
|