Skip to content

Commit 1486fe3

Browse files
amide-initclaude
andcommitted
feat: add Hacker template — terminal aesthetic
Full hacker/terminal-themed portfolio template: - Pure black background with neon green monospace text - HeroSection rendered as an interactive terminal window (whoami, cat, env) - All sections prefixed with shell prompts (~/profile ❯ ls ...) - RepoCard, BlogCard, ProjectCard as left-bordered terminal list items - StatsSection uses pure ASCII bar charts (no recharts) — genuinely different - PhilosophySection rendered as cat output with # heading markers - VideoCard shows thumbnail with reduced opacity for terminal feel - Nav/footer in App.tsx switch to green monospace when hacker is active - Admin picker updated with 4-column grid and hacker card SVG preview - GitforgeConfig type updated to include 'hacker' option Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 68dc6e7 commit 1486fe3

24 files changed

Lines changed: 1023 additions & 27 deletions

src/App.tsx

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ function App() {
1919
// Theme is derived from the template setting — no user toggle needed.
2020
const template = (githubConfig as { template?: string }).template ?? 'minimal'
2121
const theme: Theme = template === 'classic' ? 'light' : 'dark'
22+
// hacker template overrides the shared nav/footer to terminal green
23+
const isHacker = template === 'hacker'
2224

2325
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
2426

@@ -113,14 +115,17 @@ function App() {
113115
return () => URL.revokeObjectURL(url)
114116
}, [hero.title])
115117

116-
const rootClasses =
117-
'min-h-screen bg-slate-50 text-slate-900 dark:bg-[#050509] dark:text-slate-50'
118-
const headerClasses =
119-
theme === 'dark'
118+
const rootClasses = isHacker
119+
? 'min-h-screen bg-black text-green-400'
120+
: 'min-h-screen bg-slate-50 text-slate-900 dark:bg-[#050509] dark:text-slate-50'
121+
const headerClasses = isHacker
122+
? 'sticky top-0 z-20 border-b border-green-900/60 bg-black/95 backdrop-blur font-mono'
123+
: theme === 'dark'
120124
? 'sticky top-0 z-20 border-b border-white/5 bg-[#050509]/90 backdrop-blur'
121125
: 'sticky top-0 z-20 border-b border-slate-200 bg-slate-50/90 backdrop-blur'
122-
const footerClasses =
123-
theme === 'dark'
126+
const footerClasses = isHacker
127+
? 'border-t border-green-900/60 bg-black py-4 text-xs text-green-900 font-mono'
128+
: theme === 'dark'
124129
? 'border-t border-slate-800 bg-gradient-to-b from-[#111120] to-[#050509] py-6 text-xs text-slate-400'
125130
: 'border-t border-slate-200 bg-gradient-to-b from-slate-50 to-slate-100 py-6 text-xs text-slate-600'
126131

@@ -138,29 +143,50 @@ function App() {
138143
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3 sm:px-6 sm:py-4">
139144
<Link
140145
to="/"
141-
className="inline-flex items-center gap-2 no-underline text-slate-900 dark:text-slate-100"
146+
className={`inline-flex items-center gap-2 no-underline ${isHacker ? 'text-green-500' : 'text-slate-900 dark:text-slate-100'}`}
142147
aria-label={`${hero.title} home`}
143148
>
144-
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-tr from-blue-600 to-cyan-400 text-xs font-semibold text-[#050509]" aria-hidden="true">
145-
{navInitials}
146-
</span>
149+
{isHacker ? (
150+
<span className="font-mono text-sm text-green-500">
151+
<span className="text-green-800">~/</span>{navInitials}
152+
<span className="inline-block w-1.5 h-3.5 bg-green-500 animate-pulse ml-0.5 align-middle" />
153+
</span>
154+
) : (
155+
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-tr from-blue-600 to-cyan-400 text-xs font-semibold text-[#050509]" aria-hidden="true">
156+
{navInitials}
157+
</span>
158+
)}
147159
</Link>
148160

149161
{/* Desktop nav — hidden below md */}
150-
<nav className="hidden md:flex items-center gap-3 text-xs font-medium text-slate-700 dark:text-slate-300" aria-label="Primary">
151-
<Link to="/" className="border-b border-transparent pb-0.5 transition-colors hover:border-slate-500 hover:text-slate-900 dark:hover:border-slate-400 dark:hover:text-slate-50">Home</Link>
152-
<Link to="/videos" className="border-b border-transparent pb-0.5 transition-colors hover:border-slate-500 hover:text-slate-900 dark:hover:border-slate-400 dark:hover:text-slate-50">Videos</Link>
153-
<Link to="/blogs" className="border-b border-transparent pb-0.5 transition-colors hover:border-slate-500 hover:text-slate-900 dark:hover:border-slate-400 dark:hover:text-slate-50">Blogs</Link>
154-
<Link to="/projects" className="border-b border-transparent pb-0.5 transition-colors hover:border-slate-500 hover:text-slate-900 dark:hover:border-slate-400 dark:hover:text-slate-50">Projects</Link>
155-
<a href={`${import.meta.env.BASE_URL}#stats`} className="border-b border-transparent pb-0.5 transition-colors hover:border-slate-500 hover:text-slate-900 dark:hover:border-slate-400 dark:hover:text-slate-50">Stats</a>
156-
</nav>
162+
{isHacker ? (
163+
<nav className="hidden md:flex items-center gap-4 text-xs text-green-700" aria-label="Primary">
164+
{(['/', '/videos', '/blogs', '/projects'] as const).map((path, i) => {
165+
const labels = ['~', 'videos', 'blogs', 'projects']
166+
return (
167+
<Link key={path} to={path} className="transition hover:text-green-400">
168+
{labels[i]}
169+
</Link>
170+
)
171+
})}
172+
<a href={`${import.meta.env.BASE_URL}#stats`} className="transition hover:text-green-400">stats</a>
173+
</nav>
174+
) : (
175+
<nav className="hidden md:flex items-center gap-3 text-xs font-medium text-slate-700 dark:text-slate-300" aria-label="Primary">
176+
<Link to="/" className="border-b border-transparent pb-0.5 transition-colors hover:border-slate-500 hover:text-slate-900 dark:hover:border-slate-400 dark:hover:text-slate-50">Home</Link>
177+
<Link to="/videos" className="border-b border-transparent pb-0.5 transition-colors hover:border-slate-500 hover:text-slate-900 dark:hover:border-slate-400 dark:hover:text-slate-50">Videos</Link>
178+
<Link to="/blogs" className="border-b border-transparent pb-0.5 transition-colors hover:border-slate-500 hover:text-slate-900 dark:hover:border-slate-400 dark:hover:text-slate-50">Blogs</Link>
179+
<Link to="/projects" className="border-b border-transparent pb-0.5 transition-colors hover:border-slate-500 hover:text-slate-900 dark:hover:border-slate-400 dark:hover:text-slate-50">Projects</Link>
180+
<a href={`${import.meta.env.BASE_URL}#stats`} className="border-b border-transparent pb-0.5 transition-colors hover:border-slate-500 hover:text-slate-900 dark:hover:border-slate-400 dark:hover:text-slate-50">Stats</a>
181+
</nav>
182+
)}
157183

158184
{/* Mobile controls: hamburger */}
159185
<div className="flex items-center gap-2 md:hidden">
160186
<button
161187
type="button"
162188
onClick={() => setMobileMenuOpen((prev) => !prev)}
163-
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-600 transition hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
189+
className={`flex h-8 w-8 items-center justify-center rounded-md transition ${isHacker ? 'text-green-700 hover:bg-green-900/20' : 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'}`}
164190
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
165191
>
166192
{mobileMenuOpen ? (
@@ -178,13 +204,16 @@ function App() {
178204

179205
{/* Mobile dropdown menu */}
180206
{mobileMenuOpen && (
181-
<nav className="border-t border-slate-200 bg-slate-50/95 backdrop-blur dark:border-white/5 dark:bg-[#050509]/95 md:hidden" aria-label="Mobile navigation">
182-
<div className="mx-auto flex max-w-5xl flex-col px-4 py-2 text-sm font-medium text-slate-700 dark:text-slate-300">
183-
<Link to="/" onClick={() => setMobileMenuOpen(false)} className="py-2.5 transition-colors hover:text-slate-900 dark:hover:text-slate-50">Home</Link>
184-
<Link to="/videos" onClick={() => setMobileMenuOpen(false)} className="border-t border-slate-200/60 py-2.5 transition-colors hover:text-slate-900 dark:border-white/5 dark:hover:text-slate-50">Videos</Link>
185-
<Link to="/blogs" onClick={() => setMobileMenuOpen(false)} className="border-t border-slate-200/60 py-2.5 transition-colors hover:text-slate-900 dark:border-white/5 dark:hover:text-slate-50">Blogs</Link>
186-
<Link to="/projects" onClick={() => setMobileMenuOpen(false)} className="border-t border-slate-200/60 py-2.5 transition-colors hover:text-slate-900 dark:border-white/5 dark:hover:text-slate-50">Projects</Link>
187-
<a href={`${import.meta.env.BASE_URL}#stats`} onClick={() => setMobileMenuOpen(false)} className="border-t border-slate-200/60 py-2.5 transition-colors hover:text-slate-900 dark:border-white/5 dark:hover:text-slate-50">Stats</a>
207+
<nav
208+
className={`border-t md:hidden backdrop-blur ${isHacker ? 'border-green-900/60 bg-black/95 font-mono text-xs text-green-700' : 'border-slate-200 bg-slate-50/95 dark:border-white/5 dark:bg-[#050509]/95'}`}
209+
aria-label="Mobile navigation"
210+
>
211+
<div className={`mx-auto flex max-w-5xl flex-col px-4 py-2 ${isHacker ? '' : 'text-sm font-medium text-slate-700 dark:text-slate-300'}`}>
212+
<Link to="/" onClick={() => setMobileMenuOpen(false)} className={`py-2.5 transition-colors ${isHacker ? 'hover:text-green-400' : 'hover:text-slate-900 dark:hover:text-slate-50'}`}>{isHacker ? '~ home' : 'Home'}</Link>
213+
<Link to="/videos" onClick={() => setMobileMenuOpen(false)} className={`border-t py-2.5 transition-colors ${isHacker ? 'border-green-900/40 hover:text-green-400' : 'border-slate-200/60 hover:text-slate-900 dark:border-white/5 dark:hover:text-slate-50'}`}>{isHacker ? '~/videos' : 'Videos'}</Link>
214+
<Link to="/blogs" onClick={() => setMobileMenuOpen(false)} className={`border-t py-2.5 transition-colors ${isHacker ? 'border-green-900/40 hover:text-green-400' : 'border-slate-200/60 hover:text-slate-900 dark:border-white/5 dark:hover:text-slate-50'}`}>{isHacker ? '~/blogs' : 'Blogs'}</Link>
215+
<Link to="/projects" onClick={() => setMobileMenuOpen(false)} className={`border-t py-2.5 transition-colors ${isHacker ? 'border-green-900/40 hover:text-green-400' : 'border-slate-200/60 hover:text-slate-900 dark:border-white/5 dark:hover:text-slate-50'}`}>{isHacker ? '~/projects' : 'Projects'}</Link>
216+
<a href={`${import.meta.env.BASE_URL}#stats`} onClick={() => setMobileMenuOpen(false)} className={`border-t py-2.5 transition-colors ${isHacker ? 'border-green-900/40 hover:text-green-400' : 'border-slate-200/60 hover:text-slate-900 dark:border-white/5 dark:hover:text-slate-50'}`}>{isHacker ? '~/stats' : 'Stats'}</a>
188217
</div>
189218
</nav>
190219
)}

src/admin/pages/AdminConfigPage.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,33 @@ const TEMPLATES = [
6363
</svg>
6464
),
6565
},
66+
{
67+
id: 'hacker' as const,
68+
label: 'Hacker',
69+
description: 'Terminal, green-on-black, ASCII',
70+
preview: (
71+
<svg viewBox="0 0 120 80" className="h-full w-full" aria-hidden="true">
72+
<rect width="120" height="80" fill="#000000" />
73+
<rect x="0" y="0" width="120" height="10" fill="#020d02" />
74+
<circle cx="8" cy="5" r="2" fill="#ff5f56" opacity="0.7" />
75+
<circle cx="15" cy="5" r="2" fill="#ffbd2e" opacity="0.7" />
76+
<circle cx="22" cy="5" r="2" fill="#27c93f" opacity="0.7" />
77+
<rect x="8" y="16" width="6" height="2" rx="0.5" fill="#004400" />
78+
<rect x="16" y="16" width="40" height="2" rx="0.5" fill="#00aa44" opacity="0.8" />
79+
<rect x="8" y="22" width="60" height="2" rx="0.5" fill="#006600" opacity="0.5" />
80+
<rect x="8" y="30" width="6" height="2" rx="0.5" fill="#004400" />
81+
<rect x="16" y="30" width="30" height="2" rx="0.5" fill="#00aa44" opacity="0.8" />
82+
<rect x="8" y="38" width="4" height="2" rx="0" fill="#002200" />
83+
<rect x="14" y="38" width="42" height="2" rx="0" fill="#00ff41" opacity="0.5" />
84+
<rect x="8" y="43" width="4" height="2" rx="0" fill="#002200" />
85+
<rect x="14" y="43" width="26" height="2" rx="0" fill="#00cc33" opacity="0.5" />
86+
<rect x="8" y="48" width="4" height="2" rx="0" fill="#002200" />
87+
<rect x="14" y="48" width="56" height="2" rx="0" fill="#00ff41" opacity="0.4" />
88+
<rect x="8" y="60" width="6" height="2" rx="0.5" fill="#004400" />
89+
<rect x="16" y="58" width="4" height="8" rx="0" fill="#00ff41" opacity="0.9" />
90+
</svg>
91+
),
92+
},
6693
]
6794

6895
export function AdminConfigPage() {
@@ -110,7 +137,7 @@ export function AdminConfigPage() {
110137
<p className="text-xs text-slate-400">
111138
Choose the visual layout for your portfolio. Affects all public pages.
112139
</p>
113-
<div className="mt-2 grid grid-cols-3 gap-3">
140+
<div className="mt-2 grid grid-cols-2 gap-3 sm:grid-cols-4">
114141
{TEMPLATES.map(({ id, label, description, preview }) => {
115142
const isActive = (config.template ?? 'minimal') === id
116143
return (

src/pages/BlogPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { githubConfig } from '../generated/githubData'
22
import MinimalBlogPage from '../templates/minimal/BlogPage'
33
import ClassicBlogPage from '../templates/classic/BlogPage'
44
import BentoBlogPage from '../templates/bento/BlogPage'
5+
import HackerBlogPage from '../templates/hacker/BlogPage'
56

67
export default function BlogPage() {
78
const template = (githubConfig as { template?: string }).template ?? 'minimal'
89
if (template === 'classic') return <ClassicBlogPage />
910
if (template === 'bento') return <BentoBlogPage />
11+
if (template === 'hacker') return <HackerBlogPage />
1012
return <MinimalBlogPage />
1113
}

src/pages/BlogsPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { githubConfig } from '../generated/githubData'
22
import MinimalBlogsPage from '../templates/minimal/BlogsPage'
33
import ClassicBlogsPage from '../templates/classic/BlogsPage'
44
import BentoBlogsPage from '../templates/bento/BlogsPage'
5+
import HackerBlogsPage from '../templates/hacker/BlogsPage'
56

67
export default function BlogsPage() {
78
const template = (githubConfig as { template?: string }).template ?? 'minimal'
89
if (template === 'classic') return <ClassicBlogsPage />
910
if (template === 'bento') return <BentoBlogsPage />
11+
if (template === 'hacker') return <HackerBlogsPage />
1012
return <MinimalBlogsPage />
1113
}

src/pages/HomePage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { githubConfig } from '../generated/githubData'
22
import MinimalHomePage from '../templates/minimal/HomePage'
33
import ClassicHomePage from '../templates/classic/HomePage'
44
import BentoHomePage from '../templates/bento/HomePage'
5+
import HackerHomePage from '../templates/hacker/HomePage'
56

67
export default function HomePage() {
78
const template = (githubConfig as { template?: string }).template ?? 'minimal'
89
if (template === 'classic') return <ClassicHomePage />
910
if (template === 'bento') return <BentoHomePage />
11+
if (template === 'hacker') return <HackerHomePage />
1012
return <MinimalHomePage />
1113
}

src/pages/ProjectsPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { githubConfig } from '../generated/githubData'
22
import MinimalProjectsPage from '../templates/minimal/ProjectsPage'
33
import ClassicProjectsPage from '../templates/classic/ProjectsPage'
44
import BentoProjectsPage from '../templates/bento/ProjectsPage'
5+
import HackerProjectsPage from '../templates/hacker/ProjectsPage'
56

67
export default function ProjectsPage() {
78
const template = (githubConfig as { template?: string }).template ?? 'minimal'
89
if (template === 'classic') return <ClassicProjectsPage />
910
if (template === 'bento') return <BentoProjectsPage />
11+
if (template === 'hacker') return <HackerProjectsPage />
1012
return <MinimalProjectsPage />
1113
}

src/pages/VideosPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { githubConfig } from '../generated/githubData'
22
import MinimalVideosPage from '../templates/minimal/VideosPage'
33
import ClassicVideosPage from '../templates/classic/VideosPage'
44
import BentoVideosPage from '../templates/bento/VideosPage'
5+
import HackerVideosPage from '../templates/hacker/VideosPage'
56

67
export default function VideosPage() {
78
const template = (githubConfig as { template?: string }).template ?? 'minimal'
89
if (template === 'classic') return <ClassicVideosPage />
910
if (template === 'bento') return <BentoVideosPage />
11+
if (template === 'hacker') return <HackerVideosPage />
1012
return <MinimalVideosPage />
1113
}

src/templates/hacker/BlogCard.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Link } from 'react-router-dom'
2+
import type { Blog } from '../../types/contentTypes'
3+
4+
function stripMarkdown(text: string, maxLen: number): string {
5+
const plain = text
6+
.replace(/#{1,6}\s/g, '')
7+
.replace(/\*\*([^*]+)\*\*/g, '$1')
8+
.replace(/\*([^*]+)\*/g, '$1')
9+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
10+
.replace(/`[^`]+`/g, '')
11+
.replace(/\n+/g, ' ')
12+
.trim()
13+
return plain.length <= maxLen ? plain : plain.slice(0, maxLen).trim() + '…'
14+
}
15+
16+
export default function BlogCard({ blog, featured }: { blog: Blog; featured?: boolean }) {
17+
const date = blog.createdAt?.slice(0, 10) ?? '????-??-??'
18+
const excerpt = stripMarkdown(blog.content, 100)
19+
20+
return (
21+
<Link
22+
to={`/blog/${blog.id}`}
23+
className="group block border-l-2 border-green-900 py-2 pl-4 font-mono text-sm transition hover:border-green-500 hover:bg-[#030d03]"
24+
>
25+
<div className="flex items-start gap-2">
26+
<span className="mt-0.5 shrink-0 select-none text-green-800"></span>
27+
<div className="min-w-0">
28+
<p className="text-green-300 leading-snug group-hover:text-green-100 transition-colors">
29+
{featured && (
30+
<span className="mr-1.5 border border-amber-900 px-1 text-[10px] text-amber-700">PIN</span>
31+
)}
32+
{blog.title || 'Untitled'}
33+
</p>
34+
{excerpt && (
35+
<p className="mt-0.5 text-xs text-green-800 line-clamp-1">{excerpt}</p>
36+
)}
37+
<p className="mt-0.5 text-[11px] text-green-900">{date}</p>
38+
</div>
39+
</div>
40+
</Link>
41+
)
42+
}

0 commit comments

Comments
 (0)