Compare commits

...

8 Commits

Author SHA1 Message Date
denshooter
bd6007f299 Merge branch 'dev' into production
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 8m0s
2026-02-23 16:03:38 +01:00
denshooter
b162fc8a4f fix: prevent page scroll on load by using container scrollTop instead of scrollIntoView in BentoChat
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 18m31s
2026-02-23 16:03:32 +01:00
denshooter
a5449d2adb fix: use external network for dev compose to avoid label conflicts
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 16m12s
The portfolio_dev network was created manually by the pipeline, causing
docker-compose to fail with label mismatch errors. Now:
- Network is marked as external in compose (compose doesn't try to own it)
- Network creation moved before compose up in the pipeline
- Redundant network check later in pipeline removed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:37:35 +01:00
denshooter
a5048634b8 fix: add DB wait-for-ready logic and explicit network names
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 9m24s
- start-with-migrate.js now waits for the database TCP port to be
  reachable before running Prisma migrations (15 retries, 2s interval).
  Prevents the container from crashing and restarting in a loop when
  postgres is still starting up.
- Add explicit 'name:' to both production and dev compose networks
  to prevent docker-compose project prefix mismatch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:33:27 +01:00
denshooter
b5d64b3f0a fix: set explicit network name to prevent compose prefix mismatch
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
Docker Compose prefixes network names with the project name by default.
The app container (started via docker run) was connecting to 'portfolio_dev'
while postgres/redis were on '<project>_portfolio_dev' - different networks.
Setting 'name: portfolio_dev' forces the exact name so all containers
share the same network.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:30:11 +01:00
denshooter
d21669ee6d fix: remove unnecessary host port mappings from dev database containers
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
Postgres and Redis only need to be reachable via the internal Docker
network (portfolio_dev). Removing host port bindings prevents conflicts
with production or other services and reduces attack surface.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:13:16 +01:00
denshooter
3fd7329dc5 fix: use non-conflicting ports for dev database containers
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
Change dev PostgreSQL host port from 5432 to 5433 and dev Redis from
6379 to 6380 to avoid conflicts with production containers or other
services on the host. Internal Docker network ports remain unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:11:30 +01:00
denshooter
c449e9e0a8 style: comprehensive mobile responsive overhaul across all sections
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
- Hero: smoother font scaling (text-[2.75rem] -> sm -> md -> lg), smaller
  photo on mobile, reduced gaps and padding
- About: responsive bento grid with smaller border-radius, compact hobbies
  grid (2-col on mobile), hidden descriptions on small screens
- Projects: wider aspect ratio on mobile (16/10), show tags from sm:,
  smoother title scaling
- Contact: compact form inputs, responsive connect links, smaller gaps
- Footer: reduced top padding and gap on mobile
- HomePage: smaller wave separators (h-12 on mobile)
- 404: compact card padding and button sizing
- ActivityFeed: smaller quote text and min-height on mobile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:09:45 +01:00
13 changed files with 274 additions and 206 deletions

View File

@@ -70,6 +70,11 @@ jobs:
echo "🔄 Removing old images to force re-pull..."
docker rmi postgres:15-alpine redis:7-alpine 2>/dev/null || true
# Ensure networks exist before compose starts (network is external)
echo "🌐 Ensuring networks exist..."
docker network create portfolio_dev 2>/dev/null || true
docker network create proxy 2>/dev/null || true
# Pull images with correct architecture (Docker will auto-detect)
echo "📥 Pulling images for current architecture..."
docker compose -f $COMPOSE_FILE pull postgres redis
@@ -192,25 +197,6 @@ jobs:
fi
fi
# Ensure networks exist
echo "🌐 Checking for networks..."
if ! docker network inspect proxy >/dev/null 2>&1; then
echo "⚠️ Proxy network not found, creating it..."
docker network create proxy 2>/dev/null || echo "Network might already exist or creation failed"
else
echo "✅ Proxy network exists"
fi
if ! docker network inspect portfolio_dev >/dev/null 2>&1; then
echo "⚠️ Portfolio dev network not found, creating it..."
docker network create portfolio_dev 2>/dev/null || echo "Network might already exist or creation failed"
else
echo "✅ Portfolio dev network exists"
fi
# Connect proxy network to portfolio_dev network if needed
# (This allows the app to access both proxy and DB/Redis)
# Start new container with updated image
echo "🆕 Starting new dev container..."
docker run -d \

View File

@@ -39,12 +39,12 @@ export default function HomePage() {
/>
<Header />
{/* Spacer to prevent navbar overlap */}
<div className="h-24 md:h-32" aria-hidden="true"></div>
<div className="h-16 sm:h-24 md:h-32" aria-hidden="true"></div>
<main className="relative">
<Hero />
{/* Wavy Separator 1 - Hero to About */}
<div className="relative h-24 overflow-hidden">
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120"
@@ -67,7 +67,7 @@ export default function HomePage() {
<About />
{/* Wavy Separator 2 - About to Projects */}
<div className="relative h-24 overflow-hidden">
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120"
@@ -90,7 +90,7 @@ export default function HomePage() {
<Projects />
{/* Wavy Separator 3 - Projects to Contact */}
<div className="relative h-24 overflow-hidden">
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120"

View File

@@ -76,23 +76,23 @@ const About = () => {
};
return (
<section id="about" className="py-32 px-6 bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
<section id="about" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-8">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
{/* 1. Large Bio Text */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="md:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
className="md:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
<div className="space-y-8">
<h2 className="text-4xl md:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase">
<div className="space-y-5 sm:space-y-6 md:space-y-8">
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase">
{t("title")}<span className="text-liquid-mint">.</span>
</h2>
<div className="prose prose-stone dark:prose-invert max-w-none text-xl md:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
<div className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
{isLoading ? (
<div className="space-y-3">
<Skeleton className="h-6 w-full" />
@@ -105,10 +105,10 @@ const About = () => {
<p>{t("p1")} {t("p2")}</p>
)}
</div>
<div className="pt-8">
<div className="inline-block bg-stone-50 dark:bg-stone-800 px-8 py-4 rounded-3xl border border-stone-100 dark:border-stone-700">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-mint mb-2">{t("funFactTitle")}</p>
{isLoading ? <Skeleton className="h-5 w-48" /> : <p className="text-base font-bold opacity-90">{t("funFactBody")}</p>}
<div className="pt-4 sm:pt-6 md:pt-8">
<div className="inline-block bg-stone-50 dark:bg-stone-800 px-5 py-3 sm:px-8 sm:py-4 rounded-2xl sm:rounded-3xl border border-stone-100 dark:border-stone-700">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-mint mb-1 sm:mb-2">{t("funFactTitle")}</p>
{isLoading ? <Skeleton className="h-5 w-48" /> : <p className="text-sm sm:text-base font-bold opacity-90">{t("funFactBody")}</p>}
</div>
</div>
</div>
@@ -120,10 +120,10 @@ const About = () => {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.1 }}
className="md:col-span-4 bg-stone-900 rounded-[3rem] p-10 border border-stone-800 shadow-2xl text-white overflow-hidden relative flex flex-col"
className="md:col-span-4 bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white overflow-hidden relative flex flex-col"
>
<div className="relative z-10 h-full">
<h3 className="text-xl font-black mb-10 flex items-center gap-2 uppercase tracking-widest text-liquid-mint">
<h3 className="text-lg sm:text-xl font-black mb-6 sm:mb-8 md:mb-10 flex items-center gap-2 uppercase tracking-widest text-liquid-mint">
<Activity size={20} /> Status
</h3>
<ActivityFeed idleQuote={cmsMessages["about.quote.idle"]} locale={locale} />
@@ -137,11 +137,11 @@ const About = () => {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.2 }}
className="md:col-span-12 lg:col-span-4 bg-stone-50 dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 flex flex-col shadow-sm"
className="md:col-span-12 lg:col-span-4 bg-stone-50 dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 flex flex-col shadow-sm"
>
<div className="flex items-center gap-2 mb-8">
<MessageSquare className="text-liquid-purple" size={24} />
<h3 className="text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter text-liquid-purple">AI Assistant</h3>
<div className="flex items-center gap-2 mb-5 sm:mb-8">
<MessageSquare className="text-liquid-purple" size={20} />
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter text-liquid-purple">AI Assistant</h3>
</div>
<div className="flex-1">
<BentoChat />
@@ -154,9 +154,9 @@ const About = () => {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.3 }}
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-12">
<div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-4 gap-6 sm:gap-8 md:gap-12">
{isLoading ? (
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-6">
@@ -186,18 +186,18 @@ const About = () => {
</motion.div>
{/* 5. Library, Gear & Snippets */}
<div className="md:col-span-12 grid grid-cols-1 lg:grid-cols-12 gap-6 md:gap-8">
<div className="md:col-span-12 grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
{/* Library - Larger Span */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.4 }}
className="lg:col-span-7 bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col group overflow-hidden relative min-h-[500px]"
className="lg:col-span-7 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col group overflow-hidden relative min-h-[350px] sm:min-h-[400px] md:min-h-[500px]"
>
<div className="relative z-10 flex flex-col h-full">
<div className="flex justify-between items-center mb-10">
<h3 className="text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-3 uppercase tracking-tighter">
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-10">
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter">
<BookOpen className="text-liquid-purple" size={24} /> Library
</h3>
<Link href={`/${locale}/books`} className="group/link flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-all">
@@ -211,20 +211,20 @@ const About = () => {
</div>
</motion.div>
<div className="lg:col-span-5 flex flex-col gap-6 md:gap-8">
<div className="lg:col-span-5 flex flex-col gap-4 sm:gap-6 md:gap-8">
{/* My Gear (Uses) */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.5 }}
className="bg-stone-900 rounded-[3rem] p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group flex-1"
className="bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group flex-1"
>
<div className="relative z-10">
<h3 className="text-2xl font-black mb-8 flex items-center gap-3 uppercase tracking-tighter text-white">
<h3 className="text-xl sm:text-2xl font-black mb-5 sm:mb-8 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter text-white">
<Cpu className="text-liquid-mint" size={24} /> My Gear
</h3>
<div className="grid grid-cols-2 gap-6">
<div className="grid grid-cols-2 gap-4 sm:gap-6">
<div className="space-y-1">
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">Main</p>
<p className="text-sm font-bold text-stone-100">MacBook M4 Pro</p>
@@ -251,10 +251,10 @@ const About = () => {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.6 }}
className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between group overflow-hidden relative flex-1"
className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between group overflow-hidden relative flex-1"
>
<div className="relative z-10">
<h3 className="text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-3 uppercase tracking-tighter mb-6">
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter mb-4 sm:mb-6">
<Terminal className="text-liquid-purple" size={24} /> Snippets
</h3>
<div className="space-y-3">
@@ -291,20 +291,20 @@ const About = () => {
transition={{ delay: 0.5 }}
className="md:col-span-12"
>
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
<div className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between">
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6 mb-6 sm:mb-8 md:mb-12">
{isLoading ? (
Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
) : (
hobbies.map((hobby) => {
const Icon = iconMap[hobby.icon] || Lightbulb;
return (
<div key={hobby.id} className="p-6 bg-stone-50 dark:bg-stone-800 rounded-2xl border border-stone-100 dark:border-stone-700 hover:border-liquid-mint transition-colors group">
<div className="flex items-center gap-3 mb-3">
<Icon size={20} className="text-liquid-mint group-hover:scale-110 transition-transform shrink-0" />
<h4 className="font-bold text-sm text-stone-800 dark:text-stone-200 uppercase tracking-tight">{hobby.title}</h4>
<div key={hobby.id} className="p-3 sm:p-4 md:p-6 bg-stone-50 dark:bg-stone-800 rounded-xl sm:rounded-2xl border border-stone-100 dark:border-stone-700 hover:border-liquid-mint transition-colors group">
<div className="flex items-center gap-2 sm:gap-3 mb-1.5 sm:mb-3">
<Icon size={16} className="text-liquid-mint group-hover:scale-110 transition-transform shrink-0 sm:w-5 sm:h-5" />
<h4 className="font-bold text-xs sm:text-sm text-stone-800 dark:text-stone-200 uppercase tracking-tight">{hobby.title}</h4>
</div>
<p className="text-xs text-stone-500 dark:text-stone-400 font-medium leading-relaxed">
<p className="text-[11px] sm:text-xs text-stone-500 dark:text-stone-400 font-medium leading-relaxed hidden sm:block">
{hobby.description}
</p>
</div>
@@ -312,9 +312,9 @@ const About = () => {
})
)}
</div>
<div className="space-y-2 border-t border-stone-100 dark:border-stone-800 pt-8">
<h3 className="text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">{t("hobbiesTitle")}</h3>
<p className="text-stone-500 font-light text-lg">{locale === 'de' ? 'Neugier über die Softwareentwicklung hinaus.' : 'Curiosity beyond software engineering.'}</p>
<div className="space-y-1 sm:space-y-2 border-t border-stone-100 dark:border-stone-800 pt-4 sm:pt-6 md:pt-8">
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">{t("hobbiesTitle")}</h3>
<p className="text-stone-500 font-light text-sm sm:text-base md:text-lg">{locale === 'de' ? 'Neugier über die Softwareentwicklung hinaus.' : 'Curiosity beyond software engineering.'}</p>
</div>
</div>
</motion.div>
@@ -337,13 +337,13 @@ const About = () => {
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="relative w-full max-w-3xl bg-white dark:bg-stone-900 rounded-[2.5rem] shadow-2xl border border-stone-200 dark:border-stone-800 overflow-hidden flex flex-col max-h-[90vh]"
className="relative w-full max-w-3xl bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] shadow-2xl border border-stone-200 dark:border-stone-800 overflow-hidden flex flex-col max-h-[90vh]"
>
<div className="p-8 md:p-10 overflow-y-auto">
<div className="flex justify-between items-start mb-8">
<div className="p-5 sm:p-8 md:p-10 overflow-y-auto">
<div className="flex justify-between items-start mb-5 sm:mb-8">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-purple mb-2">{selectedSnippet.category}</p>
<h3 className="text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tighter">{selectedSnippet.title}</h3>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-purple mb-1 sm:mb-2">{selectedSnippet.category}</p>
<h3 className="text-xl sm:text-2xl md:text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tighter">{selectedSnippet.title}</h3>
</div>
<button
onClick={() => setSelectedSnippet(null)}
@@ -353,21 +353,21 @@ const About = () => {
</button>
</div>
<p className="text-stone-600 dark:text-stone-400 mb-8 leading-relaxed">
<p className="text-sm sm:text-base text-stone-600 dark:text-stone-400 mb-5 sm:mb-8 leading-relaxed">
{selectedSnippet.description}
</p>
<div className="relative group/code">
<div className="absolute top-4 right-4 flex gap-2">
<div className="absolute top-3 right-3 sm:top-4 sm:right-4 flex gap-2">
<button
onClick={() => copyToClipboard(selectedSnippet.code)}
className="p-2.5 bg-white/10 backdrop-blur-md hover:bg-white/20 rounded-lg border border-white/10 transition-all text-white"
className="p-2 sm:p-2.5 bg-white/10 backdrop-blur-md hover:bg-white/20 rounded-lg border border-white/10 transition-all text-white"
title="Copy Code"
>
{copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
</button>
</div>
<pre className="bg-stone-950 p-6 rounded-2xl overflow-x-auto text-sm font-mono text-stone-300 border border-stone-800 leading-relaxed">
<pre className="bg-stone-950 p-4 sm:p-6 rounded-xl sm:rounded-2xl overflow-x-auto text-xs sm:text-sm font-mono text-stone-300 border border-stone-800 leading-relaxed">
<code>{selectedSnippet.code}</code>
</pre>
</div>

View File

@@ -115,7 +115,7 @@ export default function ActivityFeed({
<div className="w-10 h-10 rounded-full bg-liquid-mint/10 flex items-center justify-center">
<QuoteIcon size={18} className="text-emerald-600 dark:text-liquid-mint" />
</div>
<div className="min-h-[120px] relative">
<div className="min-h-[80px] sm:min-h-[120px] relative">
<AnimatePresence mode="wait">
<motion.div
key={quoteIndex}
@@ -125,7 +125,7 @@ export default function ActivityFeed({
transition={{ duration: 0.5 }}
className="space-y-4"
>
<p className="text-xl md:text-2xl font-light leading-tight text-stone-700 dark:text-stone-300 italic">
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-tight text-stone-700 dark:text-stone-300 italic">
&ldquo;{allQuotes[quoteIndex].content}&rdquo;
</p>
<p className="text-xs font-black text-stone-400 dark:text-stone-500 uppercase tracking-widest">

View File

@@ -22,7 +22,7 @@ export default function BentoChat() {
const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [conversationId, setConversationId] = useState<string>("default");
const scrollRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
try {
@@ -44,8 +44,13 @@ export default function BentoChat() {
}, []);
useEffect(() => {
if (messages.length > 0) localStorage.setItem("chatMessages", JSON.stringify(messages));
scrollRef.current?.scrollIntoView({ behavior: "smooth" });
if (messages.length > 0) {
localStorage.setItem("chatMessages", JSON.stringify(messages));
}
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [messages]);
const handleSend = async () => {
@@ -73,7 +78,10 @@ export default function BentoChat() {
return (
<div className="flex flex-col h-full min-h-[300px]">
<div className="flex-1 overflow-y-auto pr-2 scrollbar-hide space-y-4 mb-4">
<div
ref={containerRef}
className="flex-1 overflow-y-auto pr-2 scrollbar-hide space-y-4 mb-4"
>
{messages.map((m) => (
<div key={m.id} className={`flex ${m.sender === "user" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[90%] rounded-2xl px-4 py-2 text-sm shadow-sm ${m.sender === "user" ? "bg-liquid-purple text-white" : "bg-white dark:bg-stone-800 text-stone-900 dark:text-stone-100 border border-stone-100 dark:border-stone-700"}`}>
@@ -86,7 +94,6 @@ export default function BentoChat() {
<div className="bg-stone-100 dark:bg-stone-800 rounded-2xl px-4 py-2"><Loader2 size={14} className="animate-spin text-stone-400" /></div>
</div>
)}
<div ref={scrollRef} />
</div>
<div className="relative">

View File

@@ -155,26 +155,26 @@ const Contact = () => {
return (
<section
id="contact"
className="py-32 px-6 bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500"
className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500"
>
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-8">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
{/* Header Card */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="md:col-span-12 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
className="md:col-span-12 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
<div className="max-w-3xl">
<h2 className="text-4xl md:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase mb-8">
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase mb-4 sm:mb-6 md:mb-8">
{t("title")}<span className="text-liquid-mint">.</span>
</h2>
{cmsDoc ? (
<RichTextClient doc={cmsDoc} className="prose prose-stone dark:prose-invert max-w-none text-xl md:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400" />
<RichTextClient doc={cmsDoc} className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400" />
) : (
<p className="text-xl md:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
{t("subtitle")}
</p>
)}
@@ -187,11 +187,11 @@ const Contact = () => {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.1 }}
className="md:col-span-12 lg:col-span-4 flex flex-col gap-6"
className="md:col-span-12 lg:col-span-4 flex flex-col gap-4 sm:gap-6"
>
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex-1 flex flex-col justify-between relative overflow-hidden group">
<div className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex-1 flex flex-col justify-between relative overflow-hidden group">
<div className="relative z-10">
<div className="flex justify-between items-center mb-12">
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-12">
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">Connect</h4>
<div className="flex items-center gap-2 px-3 py-1 bg-emerald-500/10 rounded-full border border-emerald-500/20">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
@@ -199,12 +199,12 @@ const Contact = () => {
</div>
</div>
<div className="space-y-8">
<div className="space-y-5 sm:space-y-6 md:space-y-8">
{/* Email */}
<a href="mailto:contact@dk0.dev" className="flex items-center justify-between group/link">
<div className="flex flex-col">
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-400 mb-1">Email</span>
<span className="text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">contact@dk0.dev</span>
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">contact@dk0.dev</span>
</div>
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
<Mail size={16} />
@@ -217,7 +217,7 @@ const Contact = () => {
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
<div className="flex flex-col">
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-400 mb-1">Code</span>
<span className="text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">GitHub</span>
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">GitHub</span>
</div>
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
<Github size={16} />
@@ -230,7 +230,7 @@ const Contact = () => {
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
<div className="flex flex-col">
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-400 mb-1">Professional</span>
<span className="text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-[#0077b5] transition-colors">LinkedIn</span>
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-[#0077b5] transition-colors">LinkedIn</span>
</div>
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
<Linkedin size={16} />
@@ -239,7 +239,7 @@ const Contact = () => {
</div>
</div>
<div className="mt-12 pt-8 border-t border-stone-100 dark:border-stone-800 relative z-10">
<div className="mt-6 sm:mt-8 md:mt-12 pt-4 sm:pt-6 md:pt-8 border-t border-stone-100 dark:border-stone-800 relative z-10">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 mb-2">Location</p>
<div className="flex items-center gap-2 text-stone-900 dark:text-stone-50">
<MapPin size={14} className="text-liquid-mint" />
@@ -255,14 +255,14 @@ const Contact = () => {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.2 }}
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-12 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-12 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
<h3 className="text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter mb-10">
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter mb-6 sm:mb-8 md:mb-10">
{tForm("title")}
</h3>
<form onSubmit={handleSubmit} className="space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6 md:space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 md:gap-8">
<div className="space-y-2">
<label htmlFor="name" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
{tForm("labels.name")}
@@ -275,7 +275,7 @@ const Contact = () => {
onChange={handleChange}
onBlur={handleBlur}
required
className="w-full px-6 py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-2xl text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
placeholder={tForm("placeholders.name")}
/>
</div>
@@ -292,7 +292,7 @@ const Contact = () => {
onChange={handleChange}
onBlur={handleBlur}
required
className="w-full px-6 py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-2xl text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
placeholder={tForm("placeholders.email")}
/>
</div>
@@ -310,7 +310,7 @@ const Contact = () => {
onChange={handleChange}
onBlur={handleBlur}
required
className="w-full px-6 py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-2xl text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
placeholder={tForm("placeholders.subject")}
/>
</div>
@@ -327,7 +327,7 @@ const Contact = () => {
onBlur={handleBlur}
required
rows={5}
className="w-full px-6 py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-2xl text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium resize-none"
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium resize-none"
placeholder={tForm("placeholders.message")}
/>
</div>
@@ -337,7 +337,7 @@ const Contact = () => {
disabled={isSubmitting}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className="w-full py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-2xl font-black text-xs uppercase tracking-[0.3em] flex items-center justify-center gap-3 shadow-xl hover:shadow-2xl transition-all disabled:opacity-50"
className="w-full py-4 sm:py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] sm:tracking-[0.3em] flex items-center justify-center gap-3 shadow-xl hover:shadow-2xl transition-all disabled:opacity-50"
>
{isSubmitting ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />

View File

@@ -15,10 +15,10 @@ const Footer = () => {
};
return (
<footer className="bg-[#fdfcf8] dark:bg-stone-950 pt-32 pb-12 px-6 overflow-hidden transition-colors duration-500">
<footer className="bg-[#fdfcf8] dark:bg-stone-950 pt-16 sm:pt-24 md:pt-32 pb-8 sm:pb-12 px-4 sm:px-6 overflow-hidden transition-colors duration-500">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-12 gap-12 items-end">
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 sm:gap-10 md:gap-12 items-end">
{/* Copyright & Info */}
<div className="md:col-span-4 space-y-6">
<div className="w-12 h-12 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900 font-black text-xs">
@@ -63,7 +63,7 @@ const Footer = () => {
</div>
{/* Bottom Bar */}
<div className="mt-20 pt-8 border-t border-stone-100 dark:border-stone-900 flex flex-col md:flex-row justify-between items-center gap-4">
<div className="mt-10 sm:mt-16 md:mt-20 pt-6 sm:pt-8 border-t border-stone-100 dark:border-stone-900 flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-[10px] font-bold text-stone-400 uppercase tracking-widest">
Built with Next.js, Directus & Passion.
</p>

View File

@@ -41,22 +41,22 @@ const Hero = () => {
/>
</div>
<div className="relative z-10 max-w-7xl mx-auto w-full pt-20">
<div className="flex flex-col lg:flex-row items-center gap-12 lg:gap-24">
<div className="relative z-10 max-w-7xl mx-auto w-full pt-12 sm:pt-16 md:pt-20">
<div className="flex flex-col lg:flex-row items-center gap-8 sm:gap-10 lg:gap-24">
{/* Left: Text Content */}
<div className="flex-1 text-center lg:text-left space-y-10">
<div className="flex-1 text-center lg:text-left space-y-6 sm:space-y-8 md:space-y-10">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="inline-flex items-center gap-3 px-5 py-2.5 rounded-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 shadow-sm"
className="inline-flex items-center gap-2 sm:gap-3 px-4 sm:px-5 py-2 sm:py-2.5 rounded-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 shadow-sm"
>
<span className="w-2.5 h-2.5 bg-emerald-500 rounded-full animate-pulse" />
<span className="font-mono text-[10px] font-black uppercase tracking-[0.3em] text-stone-500">{getLabel("hero.badge", "Student & Self-Hoster")}</span>
<span className="w-2 h-2 sm:w-2.5 sm:h-2.5 bg-emerald-500 rounded-full animate-pulse" />
<span className="font-mono text-[10px] sm:text-[11px] font-black uppercase tracking-[0.2em] sm:tracking-[0.3em] text-stone-500">{getLabel("hero.badge", "Student & Self-Hoster")}</span>
</motion.div>
<h1 className="text-6xl md:text-[9.5rem] font-black tracking-tighter leading-[0.8] text-stone-900 dark:text-stone-50 uppercase">
<h1 className="text-[2.75rem] sm:text-6xl md:text-8xl lg:text-[9.5rem] font-black tracking-tighter leading-[0.85] text-stone-900 dark:text-stone-50 uppercase">
<motion.span
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
@@ -69,7 +69,7 @@ const Hero = () => {
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="block text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 via-liquid-sky to-liquid-purple pb-4"
className="block text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 via-liquid-sky to-liquid-purple pb-2 sm:pb-4"
>
{getLabel("hero.line2", "Stuff.")}
</motion.span>
@@ -79,7 +79,7 @@ const Hero = () => {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1, delay: 0.4 }}
className="text-xl md:text-2xl text-stone-600 dark:text-stone-400 max-w-xl mx-auto lg:mx-0 font-light leading-relaxed tracking-tight"
className="text-base sm:text-lg md:text-xl lg:text-2xl text-stone-600 dark:text-stone-400 max-w-xl mx-auto lg:mx-0 font-light leading-relaxed tracking-tight"
>
{t("description")}
</motion.p>
@@ -88,9 +88,9 @@ const Hero = () => {
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.6 }}
className="flex flex-col sm:flex-row items-center gap-8 justify-center lg:justify-start pt-4"
className="flex flex-col sm:flex-row items-center gap-4 sm:gap-8 justify-center lg:justify-start pt-2 sm:pt-4"
>
<a href="#projects" className="group relative px-12 py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-3xl font-black text-xs uppercase tracking-[0.2em] hover:scale-105 active:scale-95 transition-all shadow-2xl">
<a href="#projects" className="group relative px-8 sm:px-12 py-4 sm:py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-2xl sm:rounded-3xl font-black text-xs uppercase tracking-[0.2em] hover:scale-105 active:scale-95 transition-all shadow-2xl">
<div className="absolute inset-0 bg-white/10 -translate-x-full group-hover:translate-x-full transition-transform duration-1000" />
{t("ctaWork")}
</a>
@@ -111,15 +111,15 @@ const Hero = () => {
opacity: { duration: 1 },
scale: { duration: 1 }
}}
className="relative w-72 h-72 md:w-[500px] md:h-[500px] shrink-0 mt-12 lg:mt-0"
className="relative w-52 h-52 sm:w-64 sm:h-64 md:w-80 md:h-80 lg:w-[500px] lg:h-[500px] shrink-0 mt-4 sm:mt-8 lg:mt-0"
>
<div className="absolute inset-0 bg-gradient-to-tr from-liquid-mint to-liquid-purple rounded-[5rem] rotate-12 scale-90 opacity-20 blur-3xl" />
<div className="relative w-full h-full rounded-[4rem] overflow-hidden border-[24px] border-white dark:border-stone-900 shadow-[0_50px_100px_-20px_rgba(0,0,0,0.4)]">
<div className="absolute inset-0 bg-gradient-to-tr from-liquid-mint to-liquid-purple rounded-[3rem] sm:rounded-[4rem] lg:rounded-[5rem] rotate-12 scale-90 opacity-20 blur-3xl" />
<div className="relative w-full h-full rounded-[2.5rem] sm:rounded-[3rem] lg:rounded-[4rem] overflow-hidden border-[12px] sm:border-[16px] lg:border-[24px] border-white dark:border-stone-900 shadow-[0_30px_60px_-15px_rgba(0,0,0,0.3)] sm:shadow-[0_50px_100px_-20px_rgba(0,0,0,0.4)]">
<Image src="/images/me.jpg" alt="Dennis Konkol" fill className="object-cover" priority />
</div>
<div className="absolute -bottom-6 -left-6 bg-white dark:bg-stone-800 px-8 py-4 rounded-[2rem] shadow-2xl border border-stone-100 dark:border-stone-700">
<span className="font-mono text-sm font-black tracking-tighter uppercase">dk<span className="text-red-500">0</span>.dev</span>
<div className="absolute -bottom-4 -left-4 sm:-bottom-6 sm:-left-6 bg-white dark:bg-stone-800 px-5 py-3 sm:px-8 sm:py-4 rounded-xl sm:rounded-[2rem] shadow-2xl border border-stone-100 dark:border-stone-700">
<span className="font-mono text-xs sm:text-sm font-black tracking-tighter uppercase">dk<span className="text-red-500">0</span>.dev</span>
</div>
</motion.div>

View File

@@ -47,14 +47,14 @@ const Projects = () => {
}, []);
return (
<section id="projects" className="py-32 px-4 bg-stone-50 dark:bg-stone-950">
<section id="projects" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-stone-50 dark:bg-stone-950">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col md:flex-row justify-between items-end mb-16 gap-6">
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-8 sm:mb-12 md:mb-16 gap-4 sm:gap-6">
<div>
<h2 className="text-4xl md:text-6xl font-black text-stone-900 dark:text-stone-50 tracking-tighter mb-4 uppercase">
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black text-stone-900 dark:text-stone-50 tracking-tighter mb-2 sm:mb-4 uppercase">
Selected Work<span className="text-liquid-mint">.</span>
</h2>
<p className="text-xl text-stone-500 max-w-xl font-light">
<p className="text-base sm:text-lg md:text-xl text-stone-500 max-w-xl font-light">
Projects that pushed my boundaries.
</p>
</div>
@@ -63,7 +63,7 @@ const Projects = () => {
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-12">
{loading ? (
Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="space-y-6">
@@ -85,7 +85,7 @@ const Projects = () => {
>
<Link href={`/${locale}/projects/${project.slug}`} className="block">
{/* Image Card */}
<div className="relative aspect-[4/3] rounded-3xl overflow-hidden bg-stone-200 dark:bg-stone-900 mb-6">
<div className="relative aspect-[16/10] sm:aspect-[4/3] rounded-2xl sm:rounded-3xl overflow-hidden bg-stone-200 dark:bg-stone-900 mb-4 sm:mb-6">
{project.imageUrl ? (
<Image
src={project.imageUrl}
@@ -105,14 +105,14 @@ const Projects = () => {
{/* Text Content */}
<div className="flex justify-between items-start">
<div>
<h3 className="text-2xl font-bold text-stone-900 dark:text-stone-100 mb-2 group-hover:underline decoration-2 underline-offset-4">
<h3 className="text-lg sm:text-xl md:text-2xl font-bold text-stone-900 dark:text-stone-100 mb-1 sm:mb-2 group-hover:underline decoration-2 underline-offset-4">
{project.title}
</h3>
<p className="text-stone-500 dark:text-stone-400 line-clamp-2 max-w-md">
<p className="text-sm sm:text-base text-stone-500 dark:text-stone-400 line-clamp-2 max-w-md">
{project.description}
</p>
</div>
<div className="hidden md:flex gap-2">
<div className="hidden sm:flex gap-2">
{project.tags.slice(0, 2).map(tag => (
<span key={tag} className="px-3 py-1 rounded-full border border-stone-200 dark:border-stone-800 text-xs font-medium text-stone-600 dark:text-stone-400">
{tag}

View File

@@ -17,41 +17,41 @@ export default function NotFound() {
if (!mounted) return null;
return (
<main className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 py-24 px-6 flex items-center justify-center transition-colors duration-500">
<main className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 py-16 sm:py-20 md:py-24 px-4 sm:px-6 flex items-center justify-center transition-colors duration-500">
<div className="max-w-7xl mx-auto w-full">
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-8 max-w-5xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8 max-w-5xl mx-auto">
{/* Main Error Card */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between min-h-[400px]"
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between min-h-[300px] sm:min-h-[400px]"
>
<div>
<div className="flex items-center gap-3 mb-12">
<div className="flex items-center gap-3 mb-6 sm:mb-8 md:mb-12">
<div className="w-10 h-10 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900 font-black text-xs">
404
</div>
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-400">Error Report</span>
</div>
<h1 className="text-5xl md:text-8xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50 leading-[0.8] mb-8">
<h1 className="text-4xl sm:text-5xl md:text-8xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50 leading-[0.85] mb-4 sm:mb-6 md:mb-8">
Page not <br/>Found<span className="text-liquid-mint">.</span>
</h1>
<p className="text-xl md:text-2xl font-light text-stone-500 max-w-md leading-relaxed">
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light text-stone-500 max-w-md leading-relaxed">
The content you are looking for has been moved, deleted, or never existed.
</p>
</div>
<div className="mt-12 flex flex-wrap gap-4">
<div className="mt-8 sm:mt-10 md:mt-12 flex flex-wrap gap-3 sm:gap-4">
<Link
href="/"
className="group relative px-10 py-4 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-2xl font-black text-xs uppercase tracking-[0.2em] shadow-xl hover:scale-105 transition-all"
className="group relative px-6 sm:px-10 py-3 sm:py-4 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] shadow-xl hover:scale-105 transition-all"
>
Return Home
</Link>
<button
onClick={() => router.back()}
className="px-10 py-4 bg-white dark:bg-stone-800 text-stone-900 dark:text-stone-100 border border-stone-200 dark:border-stone-700 rounded-2xl font-black text-xs uppercase tracking-[0.2em] hover:bg-stone-50 dark:hover:bg-stone-700 transition-all"
className="px-6 sm:px-10 py-3 sm:py-4 bg-white dark:bg-stone-800 text-stone-900 dark:text-stone-100 border border-stone-200 dark:border-stone-700 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] hover:bg-stone-50 dark:hover:bg-stone-700 transition-all"
>
Go Back
</button>
@@ -59,22 +59,22 @@ export default function NotFound() {
</motion.div>
{/* Sidebar Cards */}
<div className="md:col-span-12 lg:col-span-4 flex flex-col gap-6">
<div className="md:col-span-12 lg:col-span-4 flex flex-col gap-4 sm:gap-6">
{/* Search/Explore Projects */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }}
className="bg-stone-900 rounded-[2.5rem] p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group flex-1 flex flex-col justify-between"
className="bg-stone-900 rounded-2xl sm:rounded-[2.5rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group flex-1 flex flex-col justify-between"
>
<div className="relative z-10">
<Search className="text-liquid-mint mb-6" size={32} />
<h3 className="text-2xl font-black uppercase tracking-tighter mb-2">Explore Work</h3>
<Search className="text-liquid-mint mb-4 sm:mb-6" size={28} />
<h3 className="text-xl sm:text-2xl font-black uppercase tracking-tighter mb-1 sm:mb-2">Explore Work</h3>
<p className="text-stone-400 text-sm font-medium">Maybe what you need is in my project archive?</p>
</div>
<Link
href="/projects"
className="mt-8 inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-liquid-mint group-hover:gap-4 transition-all"
className="mt-5 sm:mt-8 inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-liquid-mint group-hover:gap-4 transition-all"
>
View Projects <ArrowLeft className="rotate-180" size={14} />
</Link>
@@ -86,11 +86,11 @@ export default function NotFound() {
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
className="bg-white dark:bg-stone-900 rounded-[2.5rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex-1 flex flex-col justify-between group"
className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex-1 flex flex-col justify-between group"
>
<div className="relative z-10">
<Terminal className="text-liquid-purple mb-6" size={32} />
<h3 className="text-2xl font-black uppercase tracking-tighter mb-2 text-stone-900 dark:text-stone-50">Technical</h3>
<Terminal className="text-liquid-purple mb-4 sm:mb-6" size={28} />
<h3 className="text-xl sm:text-2xl font-black uppercase tracking-tighter mb-1 sm:mb-2 text-stone-900 dark:text-stone-50">Technical</h3>
<p className="text-stone-500 dark:text-stone-400 text-sm font-medium">Check out my collection of code snippets and notes.</p>
</div>
<Link

View File

@@ -3,8 +3,6 @@ services:
postgres:
image: postgres:16-alpine
container_name: portfolio_postgres_dev
ports:
- "5432:5432"
environment:
POSTGRES_DB: portfolio_dev
POSTGRES_USER: portfolio_user
@@ -26,8 +24,6 @@ services:
redis:
image: redis:7-alpine
container_name: portfolio_redis_dev
ports:
- "6379:6379"
volumes:
- redis_dev_data:/data
networks:
@@ -40,7 +36,7 @@ services:
networks:
portfolio_dev:
driver: bridge
external: true
volumes:
postgres_dev_data:

View File

@@ -113,6 +113,7 @@ volumes:
networks:
portfolio_net:
name: portfolio_net
driver: bridge
proxy:
external: true

View File

@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-require-imports */
/**
* Container entrypoint: apply Prisma migrations, then start Next server.
* Container entrypoint: wait for DB, apply Prisma migrations, then start Next server.
*
* Why:
* - In real deployments you want schema changes applied automatically per deploy.
@@ -8,8 +8,11 @@
*
* Controls:
* - Set `SKIP_PRISMA_MIGRATE=true` to skip migrations (emergency / debugging).
* - DB_WAIT_RETRIES (default 15) and DB_WAIT_INTERVAL_MS (default 2000) control
* how long to wait for the database before giving up.
*/
const { spawnSync } = require("node:child_process");
const { createConnection } = require("node:net");
const fs = require("node:fs");
const path = require("node:path");
@@ -24,45 +27,120 @@ function run(cmd, args, opts = {}) {
throw res.error;
}
if (typeof res.status === "number" && res.status !== 0) {
// propagate exit code
process.exit(res.status);
}
}
const skip = String(process.env.SKIP_PRISMA_MIGRATE || "").toLowerCase() === "true";
if (!skip) {
const autoBaseline =
String(process.env.PRISMA_AUTO_BASELINE || "").toLowerCase() === "true";
// Avoid relying on `npx` resolution in minimal runtimes.
// We copy `node_modules/prisma` into the runtime image.
if (autoBaseline) {
try {
const migrationsDir = path.join(process.cwd(), "prisma", "migrations");
const entries = fs
.readdirSync(migrationsDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
const initMigration = entries.find((n) => n.endsWith("_init"));
if (initMigration) {
// This is the documented "baseline" flow for existing databases:
// mark the initial migration as already applied.
run("node", [
"node_modules/prisma/build/index.js",
"migrate",
"resolve",
"--applied",
initMigration,
]);
}
} catch (_err) {
// If baseline fails we continue to migrate deploy, which will surface the real issue.
}
/**
* Wait for a TCP port to be reachable.
* Parses DATABASE_URL to extract host and port.
*/
function waitForDb() {
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
console.log("[startup] No DATABASE_URL set, skipping DB wait.");
return Promise.resolve();
}
run("node", ["node_modules/prisma/build/index.js", "migrate", "deploy"]);
} else {
console.log("SKIP_PRISMA_MIGRATE=true -> skipping prisma migrate deploy");
let host, port;
try {
// postgresql://user:pass@host:port/db?schema=public
const match = dbUrl.match(/@([^:/?]+):(\d+)/);
if (!match) throw new Error("Could not parse host:port from DATABASE_URL");
host = match[1];
port = parseInt(match[2], 10);
} catch (_err) {
console.log("[startup] Could not parse DATABASE_URL, skipping DB wait.");
return Promise.resolve();
}
const maxRetries = parseInt(process.env.DB_WAIT_RETRIES || "15", 10);
const intervalMs = parseInt(process.env.DB_WAIT_INTERVAL_MS || "2000", 10);
return new Promise((resolve, reject) => {
let attempt = 0;
function tryConnect() {
attempt++;
const sock = createConnection({ host, port }, () => {
sock.destroy();
console.log(`[startup] Database at ${host}:${port} is reachable.`);
resolve();
});
sock.setTimeout(1500);
sock.on("error", () => {
sock.destroy();
if (attempt >= maxRetries) {
console.error(
`[startup] Database at ${host}:${port} not reachable after ${maxRetries} attempts. Proceeding anyway...`
);
resolve(); // still try migration - prisma will give a clear error
} else {
console.log(
`[startup] Waiting for database at ${host}:${port}... (${attempt}/${maxRetries})`
);
setTimeout(tryConnect, intervalMs);
}
});
sock.on("timeout", () => {
sock.destroy();
if (attempt >= maxRetries) {
console.error(
`[startup] Database at ${host}:${port} timed out after ${maxRetries} attempts. Proceeding anyway...`
);
resolve();
} else {
console.log(
`[startup] Waiting for database at ${host}:${port}... (${attempt}/${maxRetries})`
);
setTimeout(tryConnect, intervalMs);
}
});
}
tryConnect();
});
}
run("node", ["server.js"]);
async function main() {
// 1. Wait for database to be reachable
await waitForDb();
// 2. Run migrations
const skip =
String(process.env.SKIP_PRISMA_MIGRATE || "").toLowerCase() === "true";
if (!skip) {
const autoBaseline =
String(process.env.PRISMA_AUTO_BASELINE || "").toLowerCase() === "true";
if (autoBaseline) {
try {
const migrationsDir = path.join(process.cwd(), "prisma", "migrations");
const entries = fs
.readdirSync(migrationsDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
const initMigration = entries.find((n) => n.endsWith("_init"));
if (initMigration) {
run("node", [
"node_modules/prisma/build/index.js",
"migrate",
"resolve",
"--applied",
initMigration,
]);
}
} catch (_err) {
// If baseline fails we continue to migrate deploy, which will surface the real issue.
}
}
run("node", ["node_modules/prisma/build/index.js", "migrate", "deploy"]);
} else {
console.log("SKIP_PRISMA_MIGRATE=true -> skipping prisma migrate deploy");
}
// 3. Start the server
run("node", ["server.js"]);
}
main();