@hutusi/amytis 1.5.5

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.
Files changed (179) hide show
  1. package/.github/workflows/ci.yml +33 -0
  2. package/.github/workflows/publish.yml +53 -0
  3. package/AGENTS.md +41 -0
  4. package/CLAUDE.md +200 -0
  5. package/GEMINI.md +84 -0
  6. package/README.md +172 -0
  7. package/TODO.md +76 -0
  8. package/bun.lock +1530 -0
  9. package/content/about.mdx +23 -0
  10. package/content/books/sample-book/index.mdx +24 -0
  11. package/content/books/sample-book/introduction.mdx +34 -0
  12. package/content/books/sample-book/setup.mdx +48 -0
  13. package/content/books/sample-book/writing-content.mdx +49 -0
  14. package/content/flows/2026/02/05.md +8 -0
  15. package/content/flows/2026/02/10.mdx +8 -0
  16. package/content/flows/2026/02/15.md +8 -0
  17. package/content/flows/2026/02/18.mdx +14 -0
  18. package/content/posts/2026-01-12-the-art-of-algorithms.mdx +49 -0
  19. package/content/posts/2026-01-15-nested-image-test/images/test.svg +5 -0
  20. package/content/posts/2026-01-15-nested-image-test/index.mdx +27 -0
  21. package/content/posts/2026-01-21-kitchen-sink/assets/test.svg +5 -0
  22. package/content/posts/2026-01-21-kitchen-sink/index.mdx +169 -0
  23. package/content/posts/asynchronous-javascript.mdx +49 -0
  24. package/content/posts/draft-post.mdx +13 -0
  25. package/content/posts/future-post.mdx +12 -0
  26. package/content/posts/legacy-markdown.md +60 -0
  27. package/content/posts/markdown-features.mdx +78 -0
  28. package/content/posts/modern-css-layouts.mdx +45 -0
  29. package/content/posts/multilingual-test.mdx +124 -0
  30. package/content/posts/syntax-highlighting-showcase.mdx +528 -0
  31. package/content/posts/understanding-react-hooks.mdx +48 -0
  32. package/content/posts/welcome-to-amytis.mdx +21 -0
  33. package/content/posts//344/270/255/346/226/207/346/265/213/350/257/225/346/226/207/347/253/240.mdx +54 -0
  34. package/content/series/ai-nexus-weekly/index.mdx +10 -0
  35. package/content/series/ai-nexus-weekly/week-1.mdx +20 -0
  36. package/content/series/ai-nexus-weekly/week-10.mdx +20 -0
  37. package/content/series/ai-nexus-weekly/week-11.mdx +20 -0
  38. package/content/series/ai-nexus-weekly/week-12.mdx +20 -0
  39. package/content/series/ai-nexus-weekly/week-2.mdx +20 -0
  40. package/content/series/ai-nexus-weekly/week-3.mdx +20 -0
  41. package/content/series/ai-nexus-weekly/week-4.mdx +20 -0
  42. package/content/series/ai-nexus-weekly/week-5.mdx +20 -0
  43. package/content/series/ai-nexus-weekly/week-6.mdx +20 -0
  44. package/content/series/ai-nexus-weekly/week-7.mdx +20 -0
  45. package/content/series/ai-nexus-weekly/week-8.mdx +20 -0
  46. package/content/series/ai-nexus-weekly/week-9.mdx +20 -0
  47. package/content/series/digital-garden/01-philosophy/index.mdx +23 -0
  48. package/content/series/digital-garden/01-philosophy.mdx +30 -0
  49. package/content/series/digital-garden/02-architecture.mdx +19 -0
  50. package/content/series/digital-garden/index.mdx +11 -0
  51. package/content/series/markdown-showcase/index.mdx +11 -0
  52. package/content/series/markdown-showcase/mathematical-notation.mdx +32 -0
  53. package/content/series/markdown-showcase/syntax-highlighting.mdx +119 -0
  54. package/content/series/markdown-showcase/visuals-and-diagrams.mdx +27 -0
  55. package/content/series/nextjs-deep-dive/01-getting-started.mdx +66 -0
  56. package/content/series/nextjs-deep-dive/02-routing-mastery/assets/diagram.svg +8 -0
  57. package/content/series/nextjs-deep-dive/02-routing-mastery/assets/m-p-model.png +0 -0
  58. package/content/series/nextjs-deep-dive/02-routing-mastery/index.mdx +138 -0
  59. package/content/series/nextjs-deep-dive/index.mdx +12 -0
  60. package/docs/ARCHITECTURE.md +103 -0
  61. package/docs/CONTRIBUTING.md +86 -0
  62. package/docs/deployment.md +319 -0
  63. package/eslint.config.mjs +18 -0
  64. package/next.config.ts +25 -0
  65. package/package.json +81 -0
  66. package/postcss.config.mjs +7 -0
  67. package/public/file.svg +1 -0
  68. package/public/globe.svg +1 -0
  69. package/public/icon.svg +9 -0
  70. package/public/logo.svg +11 -0
  71. package/public/next-image-export-optimizer-hashes.json +7 -0
  72. package/public/next.svg +1 -0
  73. package/public/screenshot.png +0 -0
  74. package/public/vercel.svg +1 -0
  75. package/public/window.svg +1 -0
  76. package/scripts/copy-assets.ts +211 -0
  77. package/scripts/new-flow.ts +47 -0
  78. package/scripts/new-from-images.ts +141 -0
  79. package/scripts/new-from-pdf.ts +105 -0
  80. package/scripts/new-post.ts +98 -0
  81. package/scripts/new-series.ts +40 -0
  82. package/scripts/series-draft.ts +136 -0
  83. package/site.config.ts +91 -0
  84. package/src/app/[slug]/page.tsx +67 -0
  85. package/src/app/archive/page.tsx +147 -0
  86. package/src/app/authors/[author]/page.tsx +210 -0
  87. package/src/app/books/[slug]/[chapter]/page.tsx +54 -0
  88. package/src/app/books/[slug]/page.tsx +156 -0
  89. package/src/app/books/page.tsx +63 -0
  90. package/src/app/favicon.ico +0 -0
  91. package/src/app/feed.xml/route.ts +44 -0
  92. package/src/app/flows/[year]/[month]/[day]/page.tsx +105 -0
  93. package/src/app/flows/[year]/[month]/page.tsx +72 -0
  94. package/src/app/flows/[year]/page.tsx +82 -0
  95. package/src/app/flows/page/[page]/page.tsx +63 -0
  96. package/src/app/flows/page.tsx +38 -0
  97. package/src/app/globals.css +406 -0
  98. package/src/app/layout.tsx +114 -0
  99. package/src/app/page/[page]/page.tsx +60 -0
  100. package/src/app/page.tsx +110 -0
  101. package/src/app/posts/[slug]/page.tsx +119 -0
  102. package/src/app/posts/page/[page]/page.tsx +58 -0
  103. package/src/app/posts/page.tsx +40 -0
  104. package/src/app/search.json/route.ts +49 -0
  105. package/src/app/series/[slug]/page/[page]/page.tsx +141 -0
  106. package/src/app/series/[slug]/page.tsx +139 -0
  107. package/src/app/series/page.tsx +96 -0
  108. package/src/app/sitemap.ts +112 -0
  109. package/src/app/tags/[tag]/page.tsx +76 -0
  110. package/src/app/tags/page.tsx +37 -0
  111. package/src/components/Analytics.tsx +49 -0
  112. package/src/components/AuthorStats.tsx +34 -0
  113. package/src/components/BookMobileNav.tsx +171 -0
  114. package/src/components/BookSidebar.tsx +275 -0
  115. package/src/components/CodeBlock.tsx +110 -0
  116. package/src/components/Comments.tsx +63 -0
  117. package/src/components/CoverImage.tsx +93 -0
  118. package/src/components/CuratedSeriesSection.tsx +124 -0
  119. package/src/components/ExternalLinks.tsx +45 -0
  120. package/src/components/FeaturedStoriesSection.tsx +106 -0
  121. package/src/components/FlowCalendarSidebar.tsx +249 -0
  122. package/src/components/FlowContent.tsx +96 -0
  123. package/src/components/FlowTimelineEntry.tsx +34 -0
  124. package/src/components/Footer.tsx +104 -0
  125. package/src/components/Hero.tsx +126 -0
  126. package/src/components/HorizontalScroll.tsx +128 -0
  127. package/src/components/LanguageProvider.tsx +80 -0
  128. package/src/components/LanguageSwitch.tsx +17 -0
  129. package/src/components/LatestWritingSection.tsx +45 -0
  130. package/src/components/MarkdownRenderer.tsx +135 -0
  131. package/src/components/Mermaid.tsx +89 -0
  132. package/src/components/Navbar.tsx +243 -0
  133. package/src/components/PageHeader.tsx +39 -0
  134. package/src/components/Pagination.tsx +120 -0
  135. package/src/components/PostCard.tsx +30 -0
  136. package/src/components/PostList.tsx +104 -0
  137. package/src/components/PostSidebar.tsx +225 -0
  138. package/src/components/ReadingProgressBar.tsx +37 -0
  139. package/src/components/RecentNotesSection.tsx +56 -0
  140. package/src/components/RelatedPosts.tsx +34 -0
  141. package/src/components/Search.tsx +151 -0
  142. package/src/components/SelectedBooksSection.tsx +80 -0
  143. package/src/components/SeriesCatalog.tsx +112 -0
  144. package/src/components/SeriesList.tsx +167 -0
  145. package/src/components/SeriesSidebar.tsx +132 -0
  146. package/src/components/SimpleLayoutHeader.tsx +38 -0
  147. package/src/components/Skeleton.tsx +131 -0
  148. package/src/components/TableOfContents.tsx +158 -0
  149. package/src/components/Tag.tsx +47 -0
  150. package/src/components/TagPageHeader.tsx +38 -0
  151. package/src/components/ThemeProvider.tsx +12 -0
  152. package/src/components/ThemeToggle.tsx +68 -0
  153. package/src/components/TranslatedText.tsx +13 -0
  154. package/src/fonts/Inter-Bold.woff2 +0 -0
  155. package/src/fonts/Inter-Regular.woff2 +0 -0
  156. package/src/fonts/LibreBaskerville-Bold.ttf +0 -0
  157. package/src/fonts/LibreBaskerville-Italic.ttf +0 -0
  158. package/src/fonts/LibreBaskerville-Regular.ttf +0 -0
  159. package/src/i18n/translations.ts +135 -0
  160. package/src/layouts/BookLayout.tsx +109 -0
  161. package/src/layouts/PostLayout.tsx +118 -0
  162. package/src/layouts/SimpleLayout.tsx +31 -0
  163. package/src/lib/i18n.ts +35 -0
  164. package/src/lib/markdown.test.ts +127 -0
  165. package/src/lib/markdown.ts +1067 -0
  166. package/src/lib/rehype-image-metadata.ts +54 -0
  167. package/src/lib/shuffle.ts +11 -0
  168. package/templates/default.mdx +13 -0
  169. package/tests/e2e/navigation.test.ts +51 -0
  170. package/tests/e2e/series-routes.test.ts +63 -0
  171. package/tests/e2e/smoke.test.ts +19 -0
  172. package/tests/integration/markdown-features.test.ts +54 -0
  173. package/tests/integration/posts.test.ts +57 -0
  174. package/tests/integration/reading-time-headings.test.ts +79 -0
  175. package/tests/integration/series-draft.test.ts +46 -0
  176. package/tests/integration/series.test.ts +79 -0
  177. package/tests/tooling/new-from-images.test.ts +173 -0
  178. package/tests/tooling/new-post.test.ts +72 -0
  179. package/tsconfig.json +34 -0
@@ -0,0 +1,80 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import CoverImage from './CoverImage';
5
+ import { useLanguage } from './LanguageProvider';
6
+
7
+ export interface BookItem {
8
+ slug: string;
9
+ title: string;
10
+ excerpt?: string;
11
+ coverImage?: string;
12
+ authors: string[];
13
+ chapterCount: number;
14
+ firstChapter?: string;
15
+ }
16
+
17
+ interface SelectedBooksSectionProps {
18
+ books: BookItem[];
19
+ }
20
+
21
+ export default function SelectedBooksSection({ books }: SelectedBooksSectionProps) {
22
+ const { t } = useLanguage();
23
+
24
+ if (books.length === 0) return null;
25
+
26
+ return (
27
+ <section className="mb-24">
28
+ <div className="flex items-center justify-between mb-12">
29
+ <h2 className="text-3xl font-serif font-bold text-heading">{t('selected_books')}</h2>
30
+ <Link href="/books" className="text-sm font-sans font-bold uppercase tracking-widest text-muted hover:text-accent transition-colors no-underline hover:underline">
31
+ {t('all_books')} →
32
+ </Link>
33
+ </div>
34
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
35
+ {books.map(book => (
36
+ <Link key={book.slug} href={`/books/${book.slug}`} className="group block no-underline">
37
+ <div className="card-base h-full group flex flex-col p-0 overflow-hidden">
38
+ <div className="relative h-48 w-full overflow-hidden bg-muted/10">
39
+ <CoverImage
40
+ src={book.coverImage}
41
+ title={book.title}
42
+ slug={book.slug}
43
+ className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
44
+ />
45
+ </div>
46
+ <div className="p-8 flex flex-col flex-1">
47
+ <span className="badge-accent mb-4 inline-block">
48
+ {book.chapterCount} {t('chapters_count')}
49
+ </span>
50
+ <h3 className="mb-3 font-serif text-2xl font-bold text-heading group-hover:text-accent transition-colors">
51
+ {book.title}
52
+ </h3>
53
+ {book.authors.length > 0 && (
54
+ <p className="text-xs text-muted mb-3">
55
+ {t('written_by')} {book.authors.slice(0, 3).join(', ')}
56
+ </p>
57
+ )}
58
+ {book.excerpt && (
59
+ <p className="text-muted font-serif italic leading-relaxed line-clamp-3">
60
+ {book.excerpt}
61
+ </p>
62
+ )}
63
+ {book.firstChapter && (
64
+ <div className="mt-auto pt-6 border-t border-muted/10">
65
+ <span className="text-sm font-sans font-bold text-accent flex items-center gap-1.5">
66
+ {t('start_reading')}
67
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
68
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
69
+ </svg>
70
+ </span>
71
+ </div>
72
+ )}
73
+ </div>
74
+ </div>
75
+ </Link>
76
+ ))}
77
+ </div>
78
+ </section>
79
+ );
80
+ }
@@ -0,0 +1,112 @@
1
+ import Link from 'next/link';
2
+ import { PostData } from '@/lib/markdown';
3
+ import CoverImage from './CoverImage';
4
+
5
+ interface SeriesCatalogProps {
6
+ posts: PostData[];
7
+ startIndex?: number;
8
+ totalPosts?: number;
9
+ }
10
+
11
+ export default function SeriesCatalog({ posts, startIndex = 0, totalPosts }: SeriesCatalogProps) {
12
+ const total = totalPosts ?? posts.length;
13
+ return (
14
+ <div className="relative">
15
+ {/* Timeline connector line */}
16
+ <div className="absolute left-[19px] top-8 bottom-8 w-px bg-gradient-to-b from-accent/40 via-muted/20 to-muted/10 hidden md:block" />
17
+
18
+ <div className="space-y-6">
19
+ {posts.map((post, index) => (
20
+ <article key={post.slug} className="group relative">
21
+ <Link
22
+ href={`/posts/${post.slug}`}
23
+ className="block no-underline"
24
+ >
25
+ <div className="flex gap-6 md:gap-8">
26
+ {/* Left side: Number indicator */}
27
+ <div className="hidden md:flex flex-col items-center">
28
+ <div className="relative z-10 flex h-10 w-10 items-center justify-center rounded-full bg-background border-2 border-muted/20 group-hover:border-accent/50 transition-colors">
29
+ <span className="text-sm font-mono font-bold text-muted group-hover:text-accent transition-colors">
30
+ {String(startIndex + index + 1).padStart(2, '0')}
31
+ </span>
32
+ </div>
33
+ </div>
34
+
35
+ {/* Right side: Content card */}
36
+ <div className="flex-1 rounded-2xl border border-muted/20 bg-muted/5 overflow-hidden transition-all duration-300 group-hover:border-accent/30 group-hover:bg-muted/10 group-hover:shadow-lg group-hover:shadow-accent/5">
37
+ <div className="flex flex-col sm:flex-row">
38
+ {/* Thumbnail */}
39
+ <div className="relative w-full sm:w-48 h-40 sm:h-auto flex-shrink-0 overflow-hidden bg-muted/10">
40
+ <CoverImage
41
+ src={post.coverImage}
42
+ title={post.title}
43
+ slug={post.slug}
44
+ className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
45
+ />
46
+ {/* Mobile number badge */}
47
+ <div className="absolute top-3 left-3 md:hidden flex h-8 w-8 items-center justify-center rounded-full bg-background/90 backdrop-blur border border-muted/20">
48
+ <span className="text-xs font-mono font-bold text-muted">
49
+ {String(startIndex + index + 1).padStart(2, '0')}
50
+ </span>
51
+ </div>
52
+ </div>
53
+
54
+ {/* Content */}
55
+ <div className="flex-1 p-5 sm:p-6 flex flex-col">
56
+ {/* Meta info */}
57
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-muted mb-3">
58
+ <span>{post.date}</span>
59
+ <span className="hidden sm:inline">•</span>
60
+ <span className="text-accent/80">{post.readingTime}</span>
61
+ {post.category && (
62
+ <>
63
+ <span className="hidden sm:inline">•</span>
64
+ <span className="uppercase tracking-wider">{post.category}</span>
65
+ </>
66
+ )}
67
+ </div>
68
+
69
+ {/* Title */}
70
+ <h3 className="font-serif text-xl font-bold text-heading mb-2 leading-snug group-hover:text-accent transition-colors line-clamp-2">
71
+ {post.title}
72
+ </h3>
73
+
74
+ {/* Excerpt */}
75
+ {post.excerpt && (
76
+ <p className="text-sm text-muted leading-relaxed line-clamp-2 mb-4">
77
+ {post.excerpt}
78
+ </p>
79
+ )}
80
+
81
+ {/* Tags */}
82
+ {post.tags && post.tags.length > 0 && (
83
+ <div className="mt-auto flex flex-wrap gap-2">
84
+ {post.tags.slice(0, 3).map(tag => (
85
+ <span
86
+ key={tag}
87
+ className="text-xs px-2 py-0.5 rounded-full bg-muted/10 text-muted/70"
88
+ >
89
+ {tag}
90
+ </span>
91
+ ))}
92
+ </div>
93
+ )}
94
+ </div>
95
+ </div>
96
+ </div>
97
+ </div>
98
+ </Link>
99
+ </article>
100
+ ))}
101
+ </div>
102
+
103
+ {/* Series progress summary */}
104
+ <div className="mt-10 pt-8 border-t border-muted/10 text-center">
105
+ <p className="text-sm text-muted">
106
+ <span className="font-mono text-accent">{total}</span>
107
+ {total === 1 ? ' article' : ' articles'} in this series
108
+ </p>
109
+ </div>
110
+ </div>
111
+ );
112
+ }
@@ -0,0 +1,167 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import Link from 'next/link';
5
+ import { PostData } from '@/lib/markdown';
6
+ import { useLanguage } from './LanguageProvider';
7
+
8
+ interface SeriesListProps {
9
+ seriesSlug: string;
10
+ seriesTitle: string;
11
+ posts: PostData[];
12
+ currentSlug: string;
13
+ }
14
+
15
+ export default function SeriesList({ seriesSlug, seriesTitle, posts, currentSlug }: SeriesListProps) {
16
+ const { t } = useLanguage();
17
+ const [isExpanded, setIsExpanded] = useState(false);
18
+
19
+ if (!posts || posts.length === 0) return null;
20
+
21
+ const currentIndex = posts.findIndex(p => p.slug === currentSlug);
22
+ const prevPost = currentIndex > 0 ? posts[currentIndex - 1] : null;
23
+ const nextPost = currentIndex < posts.length - 1 ? posts[currentIndex + 1] : null;
24
+
25
+ return (
26
+ <div className="p-5 bg-muted/5 rounded-xl border border-muted/20">
27
+ {/* Header */}
28
+ <div className="flex items-center justify-between mb-4">
29
+ <Link
30
+ href={`/series/${seriesSlug}`}
31
+ className="group flex items-center gap-2 no-underline"
32
+ >
33
+ <span className="text-[10px] font-sans font-bold uppercase tracking-widest text-accent">
34
+ {t('series')}
35
+ </span>
36
+ <span className="text-[10px] text-muted">•</span>
37
+ <span className="text-sm font-serif font-bold text-heading group-hover:text-accent transition-colors">
38
+ {seriesTitle}
39
+ </span>
40
+ </Link>
41
+ <span className="text-xs font-mono text-muted bg-muted/10 px-2 py-0.5 rounded-full">
42
+ {currentIndex + 1}/{posts.length}
43
+ </span>
44
+ </div>
45
+
46
+ {/* Progress bar */}
47
+ <div className="h-1 bg-muted/10 rounded-full overflow-hidden mb-4">
48
+ <div
49
+ className="h-full bg-accent/60 rounded-full transition-all duration-500"
50
+ style={{ width: `${((currentIndex + 1) / posts.length) * 100}%` }}
51
+ />
52
+ </div>
53
+
54
+ {/* Prev / Next navigation */}
55
+ <div className="flex gap-3 mb-3">
56
+ {prevPost ? (
57
+ <Link
58
+ href={`/posts/${prevPost.slug}`}
59
+ className="flex-1 flex items-center gap-2 py-2.5 px-3 rounded-lg bg-muted/5 hover:bg-muted/10 no-underline transition-colors group"
60
+ >
61
+ <svg className="w-4 h-4 flex-shrink-0 text-muted group-hover:text-accent transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
62
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
63
+ </svg>
64
+ <div className="min-w-0">
65
+ <span className="block text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-0.5">{t('prev')}</span>
66
+ <span className="block text-sm text-foreground/80 group-hover:text-foreground truncate transition-colors">{prevPost.title}</span>
67
+ </div>
68
+ </Link>
69
+ ) : (
70
+ <div className="flex-1" />
71
+ )}
72
+ {nextPost ? (
73
+ <Link
74
+ href={`/posts/${nextPost.slug}`}
75
+ className="flex-1 flex items-center justify-end gap-2 py-2.5 px-3 rounded-lg bg-muted/5 hover:bg-muted/10 no-underline transition-colors group text-right"
76
+ >
77
+ <div className="min-w-0">
78
+ <span className="block text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-0.5">{t('next')}</span>
79
+ <span className="block text-sm text-foreground/80 group-hover:text-foreground truncate transition-colors">{nextPost.title}</span>
80
+ </div>
81
+ <svg className="w-4 h-4 flex-shrink-0 text-muted group-hover:text-accent transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
82
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
83
+ </svg>
84
+ </Link>
85
+ ) : (
86
+ <div className="flex-1" />
87
+ )}
88
+ </div>
89
+
90
+ {/* Toggle to expand full list */}
91
+ <button
92
+ onClick={() => setIsExpanded(!isExpanded)}
93
+ className="w-full flex items-center justify-center gap-2 py-2 text-xs font-sans text-muted hover:text-accent transition-colors"
94
+ >
95
+ <span className="h-px flex-1 bg-muted/10" />
96
+ <span className="flex items-center gap-1">
97
+ {isExpanded ? t('hide') : t('all_posts')}
98
+ <svg
99
+ className={`w-3 h-3 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
100
+ fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
101
+ >
102
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
103
+ </svg>
104
+ </span>
105
+ <span className="h-px flex-1 bg-muted/10" />
106
+ </button>
107
+
108
+ {/* Collapsible posts list */}
109
+ {isExpanded && (
110
+ <ol className="space-y-2 mt-3 animate-slide-down">
111
+ {posts.map((post, index) => {
112
+ const isCurrent = post.slug === currentSlug;
113
+ const isPast = index < currentIndex;
114
+
115
+ return (
116
+ <li key={post.slug}>
117
+ {isCurrent ? (
118
+ <div className="flex items-center gap-3 py-1.5 px-2 -mx-2 rounded-lg bg-accent/5">
119
+ <span className="flex-shrink-0 w-5 h-5 rounded-full bg-accent text-white text-[10px] font-mono font-bold flex items-center justify-center">
120
+ {String(index + 1).padStart(2, '0')}
121
+ </span>
122
+ <span className="text-sm font-semibold text-accent truncate">
123
+ {post.title}
124
+ </span>
125
+ </div>
126
+ ) : (
127
+ <Link
128
+ href={`/posts/${post.slug}`}
129
+ className="group flex items-center gap-3 py-1.5 px-2 -mx-2 rounded-lg hover:bg-muted/5 no-underline transition-colors"
130
+ >
131
+ <span className={`flex-shrink-0 w-5 h-5 rounded-full text-[10px] font-mono font-bold flex items-center justify-center transition-colors ${
132
+ isPast
133
+ ? 'bg-accent/20 text-accent'
134
+ : 'bg-muted/10 text-muted group-hover:bg-muted/20'
135
+ }`}>
136
+ {String(index + 1).padStart(2, '0')}
137
+ </span>
138
+ <span className={`text-sm truncate transition-colors ${
139
+ isPast
140
+ ? 'text-foreground/70 group-hover:text-foreground'
141
+ : 'text-muted group-hover:text-foreground'
142
+ }`}>
143
+ {post.title}
144
+ </span>
145
+ </Link>
146
+ )}
147
+ </li>
148
+ );
149
+ })}
150
+ </ol>
151
+ )}
152
+
153
+ {/* Footer */}
154
+ <div className="mt-4 pt-3 border-t border-muted/10">
155
+ <Link
156
+ href={`/series/${seriesSlug}`}
157
+ className="text-xs font-sans text-muted hover:text-accent transition-colors no-underline flex items-center gap-1"
158
+ >
159
+ {t('view_full_series')}
160
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
161
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
162
+ </svg>
163
+ </Link>
164
+ </div>
165
+ </div>
166
+ );
167
+ }
@@ -0,0 +1,132 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+ import Link from 'next/link';
5
+ import { PostData } from '@/lib/markdown';
6
+ import { useLanguage } from './LanguageProvider';
7
+
8
+ interface SeriesSidebarProps {
9
+ seriesSlug: string;
10
+ seriesTitle: string;
11
+ posts: PostData[];
12
+ currentSlug: string;
13
+ }
14
+
15
+ export default function SeriesSidebar({ seriesSlug, seriesTitle, posts, currentSlug }: SeriesSidebarProps) {
16
+ const { t } = useLanguage();
17
+ const currentIndex = posts.findIndex(p => p.slug === currentSlug);
18
+ const currentItemRef = useRef<HTMLLIElement>(null);
19
+ const sidebarRef = useRef<HTMLElement>(null);
20
+
21
+ useEffect(() => {
22
+ if (currentItemRef.current && sidebarRef.current) {
23
+ const sidebar = sidebarRef.current;
24
+ const item = currentItemRef.current;
25
+ const itemTop = item.offsetTop;
26
+ const itemHeight = item.offsetHeight;
27
+ const sidebarHeight = sidebar.clientHeight;
28
+
29
+ // Scroll so the current item is roughly centered in the sidebar
30
+ sidebar.scrollTop = itemTop - sidebarHeight / 2 + itemHeight / 2;
31
+ }
32
+ }, [currentSlug]);
33
+
34
+ return (
35
+ <aside
36
+ ref={sidebarRef}
37
+ className="hidden lg:block sticky top-28 self-start max-h-[calc(100vh-8rem)] overflow-y-auto w-56 pr-4 scrollbar-hide"
38
+ >
39
+ {/* Series Header */}
40
+ <div className="mb-6 pb-4 border-b border-muted/10">
41
+ <Link
42
+ href={`/series/${seriesSlug}`}
43
+ className="group block no-underline"
44
+ >
45
+ <span className="text-[10px] font-sans font-bold uppercase tracking-widest text-accent mb-2 block">
46
+ {t('series')}
47
+ </span>
48
+ <h3 className="font-serif font-bold text-heading text-lg leading-snug group-hover:text-accent transition-colors">
49
+ {seriesTitle}
50
+ </h3>
51
+ </Link>
52
+
53
+ {/* Progress indicator */}
54
+ <div className="mt-3 flex items-center gap-3">
55
+ <div className="flex-1 h-1 bg-muted/10 rounded-full overflow-hidden">
56
+ <div
57
+ className="h-full bg-accent/60 rounded-full transition-all duration-500"
58
+ style={{ width: `${((currentIndex + 1) / posts.length) * 100}%` }}
59
+ />
60
+ </div>
61
+ <span className="text-xs font-mono text-muted whitespace-nowrap">
62
+ {currentIndex + 1}/{posts.length}
63
+ </span>
64
+ </div>
65
+ </div>
66
+
67
+ {/* Posts List */}
68
+ <nav aria-label="Series navigation">
69
+ <ul className="space-y-1 relative">
70
+ {/* Timeline connector line */}
71
+ <div className="absolute left-[11px] top-3 bottom-3 w-px bg-muted/15" />
72
+
73
+ {posts.map((post, index) => {
74
+ const isCurrent = post.slug === currentSlug;
75
+ const isPast = index < currentIndex;
76
+
77
+ return (
78
+ <li key={post.slug} ref={isCurrent ? currentItemRef : undefined} className="relative">
79
+ <Link
80
+ href={`/posts/${post.slug}`}
81
+ className={`group flex items-start gap-3 py-2 px-2 -mx-2 rounded-lg no-underline transition-all duration-200 ${
82
+ isCurrent
83
+ ? 'bg-accent/5'
84
+ : 'hover:bg-muted/5'
85
+ }`}
86
+ aria-current={isCurrent ? 'page' : undefined}
87
+ >
88
+ {/* Number indicator */}
89
+ <div className={`relative z-10 flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-mono font-bold transition-colors ${
90
+ isCurrent
91
+ ? 'bg-accent text-white shadow-sm shadow-accent/30'
92
+ : isPast
93
+ ? 'bg-accent/20 text-accent'
94
+ : 'bg-muted/10 text-muted group-hover:bg-muted/20 group-hover:text-foreground'
95
+ }`}>
96
+ {String(index + 1).padStart(2, '0')}
97
+ </div>
98
+
99
+ {/* Content */}
100
+ <div className="flex-1 min-w-0 pt-0.5">
101
+ <span className={`block text-sm leading-snug transition-colors ${
102
+ isCurrent
103
+ ? 'text-accent font-semibold'
104
+ : isPast
105
+ ? 'text-foreground/70 group-hover:text-foreground'
106
+ : 'text-muted group-hover:text-foreground'
107
+ }`}>
108
+ {post.title}
109
+ </span>
110
+ </div>
111
+ </Link>
112
+ </li>
113
+ );
114
+ })}
115
+ </ul>
116
+ </nav>
117
+
118
+ {/* Footer link */}
119
+ <div className="mt-6 pt-4 border-t border-muted/10">
120
+ <Link
121
+ href={`/series/${seriesSlug}`}
122
+ className="text-xs font-sans text-muted hover:text-accent transition-colors no-underline flex items-center gap-1"
123
+ >
124
+ {t('view_full_series')}
125
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
126
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
127
+ </svg>
128
+ </Link>
129
+ </div>
130
+ </aside>
131
+ );
132
+ }
@@ -0,0 +1,38 @@
1
+ 'use client';
2
+
3
+ import { useLanguage } from './LanguageProvider';
4
+ import { TranslationKey } from '@/i18n/translations';
5
+ import { resolveLocaleValue } from '@/lib/i18n';
6
+
7
+ interface SimpleLayoutHeaderProps {
8
+ title: string;
9
+ excerpt?: string;
10
+ titleKey?: TranslationKey;
11
+ subtitleKey?: TranslationKey;
12
+ titleOverride?: string | Record<string, string>;
13
+ subtitleOverride?: string | Record<string, string>;
14
+ }
15
+
16
+ export default function SimpleLayoutHeader({ title, excerpt, titleKey, subtitleKey, titleOverride, subtitleOverride }: SimpleLayoutHeaderProps) {
17
+ const { t, language } = useLanguage();
18
+
19
+ const displayTitle = titleOverride
20
+ ? resolveLocaleValue(titleOverride, language)
21
+ : titleKey
22
+ ? t(titleKey)
23
+ : title;
24
+ const displaySubtitle = subtitleOverride
25
+ ? resolveLocaleValue(subtitleOverride, language)
26
+ : subtitleKey
27
+ ? t(subtitleKey)
28
+ : excerpt;
29
+
30
+ return (
31
+ <header className="page-header">
32
+ <h1 className="page-title">{displayTitle}</h1>
33
+ {displaySubtitle && (
34
+ <p className="page-subtitle">{displaySubtitle}</p>
35
+ )}
36
+ </header>
37
+ );
38
+ }
@@ -0,0 +1,131 @@
1
+ interface SkeletonProps {
2
+ className?: string;
3
+ variant?: 'text' | 'circular' | 'rectangular';
4
+ width?: string;
5
+ height?: string;
6
+ }
7
+
8
+ /**
9
+ * Skeleton loading placeholder component.
10
+ * Use for content that is loading to improve perceived performance.
11
+ */
12
+ export function Skeleton({
13
+ className = '',
14
+ variant = 'rectangular',
15
+ width,
16
+ height,
17
+ }: SkeletonProps) {
18
+ const baseClasses = 'animate-pulse bg-muted/20';
19
+
20
+ const variantClasses = {
21
+ text: 'rounded',
22
+ circular: 'rounded-full',
23
+ rectangular: 'rounded-lg',
24
+ };
25
+
26
+ const style = {
27
+ width: width || undefined,
28
+ height: height || undefined,
29
+ };
30
+
31
+ return (
32
+ <div
33
+ className={`${baseClasses} ${variantClasses[variant]} ${className}`}
34
+ style={style}
35
+ aria-hidden="true"
36
+ />
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Skeleton for a post card with image and text.
42
+ */
43
+ export function PostCardSkeleton() {
44
+ return (
45
+ <div className="card-base p-0 overflow-hidden">
46
+ <Skeleton className="h-48 w-full rounded-none" />
47
+ <div className="p-6 space-y-4">
48
+ <Skeleton className="h-4 w-20" variant="text" />
49
+ <Skeleton className="h-6 w-3/4" variant="text" />
50
+ <Skeleton className="h-4 w-full" variant="text" />
51
+ <Skeleton className="h-4 w-2/3" variant="text" />
52
+ </div>
53
+ </div>
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Skeleton for a post list item.
59
+ */
60
+ export function PostListSkeleton() {
61
+ return (
62
+ <div className="py-8 border-b border-muted/10 flex gap-8">
63
+ <div className="flex-1 space-y-3">
64
+ <Skeleton className="h-3 w-24" variant="text" />
65
+ <Skeleton className="h-6 w-3/4" variant="text" />
66
+ <Skeleton className="h-4 w-full" variant="text" />
67
+ <Skeleton className="h-4 w-1/2" variant="text" />
68
+ <div className="flex gap-2 pt-2">
69
+ <Skeleton className="h-3 w-16" variant="text" />
70
+ <Skeleton className="h-3 w-12" variant="text" />
71
+ </div>
72
+ </div>
73
+ <Skeleton className="w-24 h-24 md:w-32 md:h-24 shrink-0 rounded-lg" />
74
+ </div>
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Skeleton for a series card.
80
+ */
81
+ export function SeriesCardSkeleton() {
82
+ return (
83
+ <div className="card-base p-0 overflow-hidden">
84
+ <Skeleton className="h-56 w-full rounded-none" />
85
+ <div className="p-8 space-y-4">
86
+ <Skeleton className="h-5 w-20 rounded-full" />
87
+ <Skeleton className="h-7 w-2/3" variant="text" />
88
+ <Skeleton className="h-4 w-full" variant="text" />
89
+ <div className="pt-6 border-t border-muted/10 space-y-2">
90
+ <div className="flex items-center gap-3">
91
+ <Skeleton className="w-6 h-6" variant="circular" />
92
+ <Skeleton className="h-4 w-32" variant="text" />
93
+ </div>
94
+ <div className="flex items-center gap-3">
95
+ <Skeleton className="w-6 h-6" variant="circular" />
96
+ <Skeleton className="h-4 w-40" variant="text" />
97
+ </div>
98
+ <div className="flex items-center gap-3">
99
+ <Skeleton className="w-6 h-6" variant="circular" />
100
+ <Skeleton className="h-4 w-28" variant="text" />
101
+ </div>
102
+ </div>
103
+ </div>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Skeleton for featured story.
110
+ */
111
+ export function FeaturedStorySkeleton() {
112
+ return (
113
+ <div className="grid grid-cols-1 md:grid-cols-12 gap-8 items-center">
114
+ <Skeleton className="md:col-span-7 aspect-[16/9] rounded-2xl" />
115
+ <div className="md:col-span-5 space-y-4">
116
+ <div className="flex items-center gap-3">
117
+ <Skeleton className="h-3 w-20" variant="text" />
118
+ <Skeleton className="h-3 w-16" variant="text" />
119
+ </div>
120
+ <Skeleton className="h-10 w-full" variant="text" />
121
+ <Skeleton className="h-10 w-3/4" variant="text" />
122
+ <Skeleton className="h-4 w-full" variant="text" />
123
+ <Skeleton className="h-4 w-full" variant="text" />
124
+ <Skeleton className="h-4 w-2/3" variant="text" />
125
+ <Skeleton className="h-3 w-24 mt-4" variant="text" />
126
+ </div>
127
+ </div>
128
+ );
129
+ }
130
+
131
+ export default Skeleton;