Skip to content

Commit 6e4ba0b

Browse files
authored
feat: add Netflix-style portfolio template (#30)
1 parent 4536c47 commit 6e4ba0b

24 files changed

Lines changed: 1089 additions & 18 deletions

src/App.tsx

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ function App() {
2121
const theme: Theme = template === 'classic' ? 'light' : 'dark'
2222
// hacker template overrides the shared nav/footer to terminal green
2323
const isHacker = template === 'hacker'
24+
// netflix template overrides the shared nav/footer to Netflix style
25+
const isNetflix = template === 'netflix'
2426

2527
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
2628

@@ -117,17 +119,23 @@ function App() {
117119

118120
const rootClasses = isHacker
119121
? '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'
122+
: isNetflix
123+
? 'min-h-screen bg-[#141414] text-white'
124+
: 'min-h-screen bg-slate-50 text-slate-900 dark:bg-[#050509] dark:text-slate-50'
121125
const headerClasses = isHacker
122126
? 'sticky top-0 z-20 border-b border-green-900/60 bg-black/95 backdrop-blur font-mono'
123-
: theme === 'dark'
124-
? 'sticky top-0 z-20 border-b border-white/5 bg-[#050509]/90 backdrop-blur'
125-
: 'sticky top-0 z-20 border-b border-slate-200 bg-slate-50/90 backdrop-blur'
127+
: isNetflix
128+
? 'sticky top-0 z-20 border-b border-white/5 bg-[#141414]/95 backdrop-blur'
129+
: theme === 'dark'
130+
? 'sticky top-0 z-20 border-b border-white/5 bg-[#050509]/90 backdrop-blur'
131+
: 'sticky top-0 z-20 border-b border-slate-200 bg-slate-50/90 backdrop-blur'
126132
const footerClasses = isHacker
127133
? 'border-t border-green-900/60 bg-black py-4 text-xs text-green-900 font-mono'
128-
: theme === 'dark'
129-
? 'border-t border-slate-800 bg-gradient-to-b from-[#111120] to-[#050509] py-6 text-xs text-slate-400'
130-
: 'border-t border-slate-200 bg-gradient-to-b from-slate-50 to-slate-100 py-6 text-xs text-slate-600'
134+
: isNetflix
135+
? 'border-t border-white/5 bg-[#141414] py-6 text-xs text-[#999]'
136+
: theme === 'dark'
137+
? 'border-t border-slate-800 bg-gradient-to-b from-[#111120] to-[#050509] py-6 text-xs text-slate-400'
138+
: 'border-t border-slate-200 bg-gradient-to-b from-slate-50 to-slate-100 py-6 text-xs text-slate-600'
131139

132140
const navInitials = (() => {
133141
const name = (hero.title || '').trim()
@@ -143,14 +151,16 @@ function App() {
143151
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3 sm:px-6 sm:py-4">
144152
<Link
145153
to="/"
146-
className={`inline-flex items-center gap-2 no-underline ${isHacker ? 'text-green-500' : 'text-slate-900 dark:text-slate-100'}`}
154+
className={`inline-flex items-center gap-2 no-underline ${isHacker ? 'text-green-500' : isNetflix ? 'text-white' : 'text-slate-900 dark:text-slate-100'}`}
147155
aria-label={`${hero.title} home`}
148156
>
149157
{isHacker ? (
150158
<span className="font-mono text-sm text-green-500">
151159
<span className="text-green-800">~/</span>{navInitials}
152160
<span className="inline-block w-1.5 h-3.5 bg-green-500 animate-pulse ml-0.5 align-middle" />
153161
</span>
162+
) : isNetflix ? (
163+
<span className="text-lg font-black text-[#e50914] tracking-tight">{navInitials}</span>
154164
) : (
155165
<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">
156166
{navInitials}
@@ -171,6 +181,14 @@ function App() {
171181
})}
172182
<a href={`${import.meta.env.BASE_URL}#stats`} className="transition hover:text-green-400">stats</a>
173183
</nav>
184+
) : isNetflix ? (
185+
<nav className="hidden md:flex items-center gap-5 text-sm font-medium text-[#e5e5e5]" aria-label="Primary">
186+
<Link to="/" className="transition hover:text-white">Home</Link>
187+
<Link to="/videos" className="transition hover:text-white">Videos</Link>
188+
<Link to="/blogs" className="transition hover:text-white">Blogs</Link>
189+
<Link to="/projects" className="transition hover:text-white">Projects</Link>
190+
<a href={`${import.meta.env.BASE_URL}#stats`} className="transition hover:text-white">Stats</a>
191+
</nav>
174192
) : (
175193
<nav className="hidden md:flex items-center gap-3 text-xs font-medium text-slate-700 dark:text-slate-300" aria-label="Primary">
176194
<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>
@@ -186,7 +204,7 @@ function App() {
186204
<button
187205
type="button"
188206
onClick={() => setMobileMenuOpen((prev) => !prev)}
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'}`}
207+
className={`flex h-8 w-8 items-center justify-center rounded-md transition ${isHacker ? 'text-green-700 hover:bg-green-900/20' : isNetflix ? 'text-white hover:bg-white/10' : 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'}`}
190208
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
191209
>
192210
{mobileMenuOpen ? (
@@ -205,15 +223,15 @@ function App() {
205223
{/* Mobile dropdown menu */}
206224
{mobileMenuOpen && (
207225
<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'}`}
226+
className={`border-t md:hidden backdrop-blur ${isHacker ? 'border-green-900/60 bg-black/95 font-mono text-xs text-green-700' : isNetflix ? 'border-white/5 bg-[#141414]/95 text-sm text-[#e5e5e5]' : 'border-slate-200 bg-slate-50/95 dark:border-white/5 dark:bg-[#050509]/95'}`}
209227
aria-label="Mobile navigation"
210228
>
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>
229+
<div className={`mx-auto flex max-w-5xl flex-col px-4 py-2 ${isHacker ? '' : isNetflix ? 'font-medium' : 'text-sm font-medium text-slate-700 dark:text-slate-300'}`}>
230+
<Link to="/" onClick={() => setMobileMenuOpen(false)} className={`py-2.5 transition-colors ${isHacker ? 'hover:text-green-400' : isNetflix ? 'hover:text-white' : 'hover:text-slate-900 dark:hover:text-slate-50'}`}>{isHacker ? '~ home' : 'Home'}</Link>
231+
<Link to="/videos" onClick={() => setMobileMenuOpen(false)} className={`border-t py-2.5 transition-colors ${isHacker ? 'border-green-900/40 hover:text-green-400' : isNetflix ? 'border-white/5 hover:text-white' : 'border-slate-200/60 hover:text-slate-900 dark:border-white/5 dark:hover:text-slate-50'}`}>Videos</Link>
232+
<Link to="/blogs" onClick={() => setMobileMenuOpen(false)} className={`border-t py-2.5 transition-colors ${isHacker ? 'border-green-900/40 hover:text-green-400' : isNetflix ? 'border-white/5 hover:text-white' : 'border-slate-200/60 hover:text-slate-900 dark:border-white/5 dark:hover:text-slate-50'}`}>Blogs</Link>
233+
<Link to="/projects" onClick={() => setMobileMenuOpen(false)} className={`border-t py-2.5 transition-colors ${isHacker ? 'border-green-900/40 hover:text-green-400' : isNetflix ? 'border-white/5 hover:text-white' : 'border-slate-200/60 hover:text-slate-900 dark:border-white/5 dark:hover:text-slate-50'}`}>Projects</Link>
234+
<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' : isNetflix ? 'border-white/5 hover:text-white' : 'border-slate-200/60 hover:text-slate-900 dark:border-white/5 dark:hover:text-slate-50'}`}>{isHacker ? '~/stats' : 'Stats'}</a>
217235
</div>
218236
</nav>
219237
)}

src/admin/pages/AdminTemplatePage.tsx

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,44 @@ const TEMPLATES = [
8585
</svg>
8686
),
8787
},
88+
{
89+
id: 'netflix' as const,
90+
label: 'Netflix',
91+
description: 'Cinematic, red-on-black, horizontal rows',
92+
preview: (
93+
<svg viewBox="0 0 120 80" className="h-full w-full" aria-hidden="true">
94+
<rect width="120" height="80" fill="#141414" />
95+
{/* top nav bar */}
96+
<rect x="0" y="0" width="120" height="10" fill="#141414" />
97+
<rect x="8" y="3" width="12" height="5" rx="1" fill="#e50914" />
98+
<rect x="30" y="4" width="10" height="2" rx="0.5" fill="#e5e5e5" opacity="0.6" />
99+
<rect x="44" y="4" width="10" height="2" rx="0.5" fill="#e5e5e5" opacity="0.4" />
100+
<rect x="58" y="4" width="10" height="2" rx="0.5" fill="#e5e5e5" opacity="0.4" />
101+
{/* Hero area */}
102+
<rect x="0" y="10" width="120" height="30" fill="#1a0000" />
103+
<rect x="0" y="10" width="120" height="30" fill="url(#nf-grad)" />
104+
<defs>
105+
<linearGradient id="nf-grad" x1="0" y1="0" x2="1" y2="0">
106+
<stop offset="0%" stopColor="#000000" stopOpacity="0.9" />
107+
<stop offset="60%" stopColor="#000000" stopOpacity="0.3" />
108+
<stop offset="100%" stopColor="#000000" stopOpacity="0" />
109+
</linearGradient>
110+
</defs>
111+
<rect x="8" y="15" width="30" height="5" rx="0.5" fill="#ffffff" opacity="0.9" />
112+
<rect x="8" y="23" width="50" height="2" rx="0.5" fill="#d2d2d2" opacity="0.5" />
113+
<rect x="8" y="33" width="18" height="5" rx="0.5" fill="#e50914" />
114+
<rect x="30" y="33" width="18" height="5" rx="0.5" fill="#ffffff" opacity="0.2" />
115+
{/* Row 1 */}
116+
<rect x="8" y="46" width="20" height="2" rx="0.5" fill="#ffffff" opacity="0.7" />
117+
<rect x="8" y="52" width="24" height="12" rx="1" fill="#1f1f1f" />
118+
<rect x="35" y="52" width="24" height="12" rx="1" fill="#1f1f1f" />
119+
<rect x="62" y="52" width="24" height="12" rx="1" fill="#1f1f1f" />
120+
<rect x="89" y="52" width="24" height="12" rx="1" fill="#1f1f1f" />
121+
<rect x="8" y="52" width="3" height="12" rx="0" fill="#e50914" opacity="0.6" />
122+
<rect x="35" y="52" width="3" height="12" rx="0" fill="#e50914" opacity="0.4" />
123+
</svg>
124+
),
125+
},
88126
]
89127

90128
export function AdminTemplatePage() {
@@ -103,7 +141,7 @@ export function AdminTemplatePage() {
103141
</p>
104142
</div>
105143

106-
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
144+
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
107145
{TEMPLATES.map(({ id, label, description, preview }) => {
108146
const isActive = (config.template ?? 'hacker') === id
109147
return (

src/pages/BlogPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import MinimalBlogPage from '../templates/minimal/BlogPage'
33
import ClassicBlogPage from '../templates/classic/BlogPage'
44
import BentoBlogPage from '../templates/bento/BlogPage'
55
import HackerBlogPage from '../templates/hacker/BlogPage'
6+
import NetflixBlogPage from '../templates/netflix/BlogPage'
67

78
export default function BlogPage() {
89
const template = (githubConfig as { template?: string }).template ?? 'hacker'
910
if (template === 'classic') return <ClassicBlogPage />
1011
if (template === 'bento') return <BentoBlogPage />
1112
if (template === 'hacker') return <HackerBlogPage />
13+
if (template === 'netflix') return <NetflixBlogPage />
1214
return <MinimalBlogPage />
1315
}

src/pages/BlogsPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import MinimalBlogsPage from '../templates/minimal/BlogsPage'
33
import ClassicBlogsPage from '../templates/classic/BlogsPage'
44
import BentoBlogsPage from '../templates/bento/BlogsPage'
55
import HackerBlogsPage from '../templates/hacker/BlogsPage'
6+
import NetflixBlogsPage from '../templates/netflix/BlogsPage'
67

78
export default function BlogsPage() {
89
const template = (githubConfig as { template?: string }).template ?? 'hacker'
910
if (template === 'classic') return <ClassicBlogsPage />
1011
if (template === 'bento') return <BentoBlogsPage />
1112
if (template === 'hacker') return <HackerBlogsPage />
13+
if (template === 'netflix') return <NetflixBlogsPage />
1214
return <MinimalBlogsPage />
1315
}

src/pages/HomePage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import MinimalHomePage from '../templates/minimal/HomePage'
33
import ClassicHomePage from '../templates/classic/HomePage'
44
import BentoHomePage from '../templates/bento/HomePage'
55
import HackerHomePage from '../templates/hacker/HomePage'
6+
import NetflixHomePage from '../templates/netflix/HomePage'
67

78
export default function HomePage() {
89
const template = (githubConfig as { template?: string }).template ?? 'hacker'
910
if (template === 'classic') return <ClassicHomePage />
1011
if (template === 'bento') return <BentoHomePage />
1112
if (template === 'hacker') return <HackerHomePage />
13+
if (template === 'netflix') return <NetflixHomePage />
1214
return <MinimalHomePage />
1315
}

src/pages/ProjectsPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import MinimalProjectsPage from '../templates/minimal/ProjectsPage'
33
import ClassicProjectsPage from '../templates/classic/ProjectsPage'
44
import BentoProjectsPage from '../templates/bento/ProjectsPage'
55
import HackerProjectsPage from '../templates/hacker/ProjectsPage'
6+
import NetflixProjectsPage from '../templates/netflix/ProjectsPage'
67

78
export default function ProjectsPage() {
89
const template = (githubConfig as { template?: string }).template ?? 'hacker'
910
if (template === 'classic') return <ClassicProjectsPage />
1011
if (template === 'bento') return <BentoProjectsPage />
1112
if (template === 'hacker') return <HackerProjectsPage />
13+
if (template === 'netflix') return <NetflixProjectsPage />
1214
return <MinimalProjectsPage />
1315
}

src/pages/VideosPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import MinimalVideosPage from '../templates/minimal/VideosPage'
33
import ClassicVideosPage from '../templates/classic/VideosPage'
44
import BentoVideosPage from '../templates/bento/VideosPage'
55
import HackerVideosPage from '../templates/hacker/VideosPage'
6+
import NetflixVideosPage from '../templates/netflix/VideosPage'
67

78
export default function VideosPage() {
89
const template = (githubConfig as { template?: string }).template ?? 'hacker'
910
if (template === 'classic') return <ClassicVideosPage />
1011
if (template === 'bento') return <BentoVideosPage />
1112
if (template === 'hacker') return <HackerVideosPage />
13+
if (template === 'netflix') return <NetflixVideosPage />
1214
return <MinimalVideosPage />
1315
}

src/templates/netflix/BlogCard.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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+
if (plain.length <= maxLen) return plain
14+
return plain.slice(0, maxLen).trim() + '…'
15+
}
16+
17+
type BlogCardProps = {
18+
blog: Blog
19+
excerptLength?: number
20+
large?: boolean
21+
}
22+
23+
export default function BlogCard({ blog, excerptLength = 100, large = false }: BlogCardProps) {
24+
const excerpt = stripMarkdown(blog.content, large ? 180 : excerptLength)
25+
26+
return (
27+
<article
28+
className={`group relative flex-shrink-0 overflow-hidden rounded-sm bg-[#1f1f1f] transition-transform duration-200 hover:scale-105 hover:z-10 ${
29+
large ? 'w-72 sm:w-80' : 'w-48 sm:w-56'
30+
}`}
31+
>
32+
<Link to={`/blog/${blog.id}`} className="block">
33+
{/* Banner */}
34+
<div className="relative flex aspect-video w-full items-center justify-center bg-gradient-to-br from-[#e50914]/80 to-[#8b0000]/80">
35+
<span className="text-3xl font-black text-white/20 select-none uppercase tracking-wider line-clamp-2 text-center px-2">
36+
{blog.title || 'Blog'}
37+
</span>
38+
{/* Hover overlay */}
39+
<div className="absolute inset-0 flex items-center justify-center bg-black/20 opacity-0 transition group-hover:opacity-100">
40+
<span className="rounded-sm bg-white/90 px-3 py-1 text-xs font-bold text-black">Read</span>
41+
</div>
42+
</div>
43+
44+
{/* Info */}
45+
<div className="p-3">
46+
<p className="text-[10px] font-bold uppercase tracking-wider text-[#e50914] mb-1">Article</p>
47+
<h3 className="line-clamp-2 text-xs font-semibold text-white">
48+
{blog.title || 'Untitled'}
49+
</h3>
50+
{excerpt && (
51+
<p className="mt-1 line-clamp-2 text-[11px] leading-relaxed text-[#999]">
52+
{excerpt}
53+
</p>
54+
)}
55+
</div>
56+
</Link>
57+
</article>
58+
)
59+
}

0 commit comments

Comments
 (0)