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,10 +70,15 @@ jobs:
echo "🔄 Removing old images to force re-pull..." echo "🔄 Removing old images to force re-pull..."
docker rmi postgres:15-alpine redis:7-alpine 2>/dev/null || true 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) # Pull images with correct architecture (Docker will auto-detect)
echo "📥 Pulling images for current architecture..." echo "📥 Pulling images for current architecture..."
docker compose -f $COMPOSE_FILE pull postgres redis docker compose -f $COMPOSE_FILE pull postgres redis
# Start containers # Start containers
echo "📦 Starting PostgreSQL and Redis containers..." echo "📦 Starting PostgreSQL and Redis containers..."
docker compose -f $COMPOSE_FILE up -d postgres redis docker compose -f $COMPOSE_FILE up -d postgres redis
@@ -192,25 +197,6 @@ jobs:
fi fi
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 # Start new container with updated image
echo "🆕 Starting new dev container..." echo "🆕 Starting new dev container..."
docker run -d \ docker run -d \

View File

@@ -39,12 +39,12 @@ export default function HomePage() {
/> />
<Header /> <Header />
{/* Spacer to prevent navbar overlap */} {/* 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"> <main className="relative">
<Hero /> <Hero />
{/* Wavy Separator 1 - Hero to About */} {/* 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 <svg
className="absolute inset-0 w-full h-full" className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120" viewBox="0 0 1440 120"
@@ -67,7 +67,7 @@ export default function HomePage() {
<About /> <About />
{/* Wavy Separator 2 - About to Projects */} {/* 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 <svg
className="absolute inset-0 w-full h-full" className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120" viewBox="0 0 1440 120"
@@ -90,7 +90,7 @@ export default function HomePage() {
<Projects /> <Projects />
{/* Wavy Separator 3 - Projects to Contact */} {/* 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 <svg
className="absolute inset-0 w-full h-full" className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120" viewBox="0 0 1440 120"

View File

@@ -76,23 +76,23 @@ const About = () => {
}; };
return ( 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="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 */} {/* 1. Large Bio Text */}
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} 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"> <div className="space-y-5 sm:space-y-6 md:space-y-8">
<h2 className="text-4xl md:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase"> <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> {t("title")}<span className="text-liquid-mint">.</span>
</h2> </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 ? ( {isLoading ? (
<div className="space-y-3"> <div className="space-y-3">
<Skeleton className="h-6 w-full" /> <Skeleton className="h-6 w-full" />
@@ -105,10 +105,10 @@ const About = () => {
<p>{t("p1")} {t("p2")}</p> <p>{t("p1")} {t("p2")}</p>
)} )}
</div> </div>
<div className="pt-8"> <div className="pt-4 sm:pt-6 md: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"> <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-2">{t("funFactTitle")}</p> <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-base font-bold opacity-90">{t("funFactBody")}</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> </div>
</div> </div>
@@ -120,10 +120,10 @@ const About = () => {
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ delay: 0.1 }} 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"> <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 <Activity size={20} /> Status
</h3> </h3>
<ActivityFeed idleQuote={cmsMessages["about.quote.idle"]} locale={locale} /> <ActivityFeed idleQuote={cmsMessages["about.quote.idle"]} locale={locale} />
@@ -137,11 +137,11 @@ const About = () => {
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ delay: 0.2 }} 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"> <div className="flex items-center gap-2 mb-5 sm:mb-8">
<MessageSquare className="text-liquid-purple" size={24} /> <MessageSquare className="text-liquid-purple" size={20} />
<h3 className="text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter text-liquid-purple">AI Assistant</h3> <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>
<div className="flex-1"> <div className="flex-1">
<BentoChat /> <BentoChat />
@@ -154,9 +154,9 @@ const About = () => {
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ delay: 0.3 }} 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 ? ( {isLoading ? (
Array.from({ length: 4 }).map((_, i) => ( Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-6"> <div key={i} className="space-y-6">
@@ -186,18 +186,18 @@ const About = () => {
</motion.div> </motion.div>
{/* 5. Library, Gear & Snippets */} {/* 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 */} {/* Library - Larger Span */}
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ delay: 0.4 }} 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="relative z-10 flex flex-col h-full">
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-10">
<h3 className="text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-3 uppercase tracking-tighter"> <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 <BookOpen className="text-liquid-purple" size={24} /> Library
</h3> </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"> <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> </div>
</motion.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) */} {/* My Gear (Uses) */}
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ delay: 0.5 }} 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"> <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 <Cpu className="text-liquid-mint" size={24} /> My Gear
</h3> </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"> <div className="space-y-1">
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">Main</p> <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> <p className="text-sm font-bold text-stone-100">MacBook M4 Pro</p>
@@ -246,15 +246,15 @@ const About = () => {
<div className="absolute bottom-0 right-0 w-32 h-32 bg-liquid-mint/10 blur-3xl rounded-full -mr-16 -mb-16" /> <div className="absolute bottom-0 right-0 w-32 h-32 bg-liquid-mint/10 blur-3xl rounded-full -mr-16 -mb-16" />
</motion.div> </motion.div>
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ delay: 0.6 }} 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"> <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 <Terminal className="text-liquid-purple" size={24} /> Snippets
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
@@ -291,20 +291,20 @@ const About = () => {
transition={{ delay: 0.5 }} transition={{ delay: 0.5 }}
className="md:col-span-12" 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="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-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-12"> <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 ? ( {isLoading ? (
Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />) Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
) : ( ) : (
hobbies.map((hobby) => { hobbies.map((hobby) => {
const Icon = iconMap[hobby.icon] || Lightbulb; const Icon = iconMap[hobby.icon] || Lightbulb;
return ( 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 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-3 mb-3"> <div className="flex items-center gap-2 sm:gap-3 mb-1.5 sm:mb-3">
<Icon size={20} className="text-liquid-mint group-hover:scale-110 transition-transform shrink-0" /> <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-sm text-stone-800 dark:text-stone-200 uppercase tracking-tight">{hobby.title}</h4> <h4 className="font-bold text-xs sm:text-sm text-stone-800 dark:text-stone-200 uppercase tracking-tight">{hobby.title}</h4>
</div> </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} {hobby.description}
</p> </p>
</div> </div>
@@ -312,9 +312,9 @@ const About = () => {
}) })
)} )}
</div> </div>
<div className="space-y-2 border-t border-stone-100 dark:border-stone-800 pt-8"> <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-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">{t("hobbiesTitle")}</h3> <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-lg">{locale === 'de' ? 'Neugier über die Softwareentwicklung hinaus.' : 'Curiosity beyond software engineering.'}</p> <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>
</div> </div>
</motion.div> </motion.div>
@@ -337,13 +337,13 @@ const About = () => {
initial={{ opacity: 0, scale: 0.9, y: 20 }} initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }} 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="p-5 sm:p-8 md:p-10 overflow-y-auto">
<div className="flex justify-between items-start mb-8"> <div className="flex justify-between items-start mb-5 sm:mb-8">
<div> <div>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-purple mb-2">{selectedSnippet.category}</p> <p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-purple mb-1 sm:mb-2">{selectedSnippet.category}</p>
<h3 className="text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tighter">{selectedSnippet.title}</h3> <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> </div>
<button <button
onClick={() => setSelectedSnippet(null)} onClick={() => setSelectedSnippet(null)}
@@ -353,21 +353,21 @@ const About = () => {
</button> </button>
</div> </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} {selectedSnippet.description}
</p> </p>
<div className="relative group/code"> <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 <button
onClick={() => copyToClipboard(selectedSnippet.code)} 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" title="Copy Code"
> >
{copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />} {copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
</button> </button>
</div> </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> <code>{selectedSnippet.code}</code>
</pre> </pre>
</div> </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"> <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" /> <QuoteIcon size={18} className="text-emerald-600 dark:text-liquid-mint" />
</div> </div>
<div className="min-h-[120px] relative"> <div className="min-h-[80px] sm:min-h-[120px] relative">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.div <motion.div
key={quoteIndex} key={quoteIndex}
@@ -125,7 +125,7 @@ export default function ActivityFeed({
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
className="space-y-4" 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; &ldquo;{allQuotes[quoteIndex].content}&rdquo;
</p> </p>
<p className="text-xs font-black text-stone-400 dark:text-stone-500 uppercase tracking-widest"> <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 [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [conversationId, setConversationId] = useState<string>("default"); const [conversationId, setConversationId] = useState<string>("default");
const scrollRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
try { try {
@@ -44,8 +44,13 @@ export default function BentoChat() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (messages.length > 0) localStorage.setItem("chatMessages", JSON.stringify(messages)); if (messages.length > 0) {
scrollRef.current?.scrollIntoView({ behavior: "smooth" }); localStorage.setItem("chatMessages", JSON.stringify(messages));
}
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [messages]); }, [messages]);
const handleSend = async () => { const handleSend = async () => {
@@ -73,7 +78,10 @@ export default function BentoChat() {
return ( return (
<div className="flex flex-col h-full min-h-[300px]"> <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) => ( {messages.map((m) => (
<div key={m.id} className={`flex ${m.sender === "user" ? "justify-end" : "justify-start"}`}> <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"}`}> <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 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>
)} )}
<div ref={scrollRef} />
</div> </div>
<div className="relative"> <div className="relative">

View File

@@ -155,26 +155,26 @@ const Contact = () => {
return ( return (
<section <section
id="contact" 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="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 */} {/* Header Card */}
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} 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"> <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> {t("title")}<span className="text-liquid-mint">.</span>
</h2> </h2>
{cmsDoc ? ( {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")} {t("subtitle")}
</p> </p>
)} )}
@@ -187,11 +187,11 @@ const Contact = () => {
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ delay: 0.1 }} 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="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> <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"> <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" /> <span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
@@ -199,12 +199,12 @@ const Contact = () => {
</div> </div>
</div> </div>
<div className="space-y-8"> <div className="space-y-5 sm:space-y-6 md:space-y-8">
{/* Email */} {/* Email */}
<a href="mailto:contact@dk0.dev" className="flex items-center justify-between group/link"> <a href="mailto:contact@dk0.dev" className="flex items-center justify-between group/link">
<div className="flex flex-col"> <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-[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>
<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"> <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} /> <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"> <a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
<div className="flex flex-col"> <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-[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>
<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"> <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} /> <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"> <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"> <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-[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>
<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"> <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} /> <Linkedin size={16} />
@@ -239,7 +239,7 @@ const Contact = () => {
</div> </div>
</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> <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"> <div className="flex items-center gap-2 text-stone-900 dark:text-stone-50">
<MapPin size={14} className="text-liquid-mint" /> <MapPin size={14} className="text-liquid-mint" />
@@ -255,14 +255,14 @@ const Contact = () => {
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ delay: 0.2 }} 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")} {tForm("title")}
</h3> </h3>
<form onSubmit={handleSubmit} className="space-y-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-8"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 md:gap-8">
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="name" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4"> <label htmlFor="name" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
{tForm("labels.name")} {tForm("labels.name")}
@@ -275,7 +275,7 @@ const Contact = () => {
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur} onBlur={handleBlur}
required 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")} placeholder={tForm("placeholders.name")}
/> />
</div> </div>
@@ -292,7 +292,7 @@ const Contact = () => {
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur} onBlur={handleBlur}
required 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")} placeholder={tForm("placeholders.email")}
/> />
</div> </div>
@@ -310,7 +310,7 @@ const Contact = () => {
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur} onBlur={handleBlur}
required 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")} placeholder={tForm("placeholders.subject")}
/> />
</div> </div>
@@ -327,7 +327,7 @@ const Contact = () => {
onBlur={handleBlur} onBlur={handleBlur}
required required
rows={5} 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")} placeholder={tForm("placeholders.message")}
/> />
</div> </div>
@@ -337,7 +337,7 @@ const Contact = () => {
disabled={isSubmitting} disabled={isSubmitting}
whileHover={{ scale: 1.01 }} whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }} 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 ? ( {isSubmitting ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> <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 ( 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="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 */} {/* Copyright & Info */}
<div className="md:col-span-4 space-y-6"> <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"> <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> </div>
{/* Bottom Bar */} {/* 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"> <p className="text-[10px] font-bold text-stone-400 uppercase tracking-widest">
Built with Next.js, Directus & Passion. Built with Next.js, Directus & Passion.
</p> </p>

View File

@@ -41,23 +41,23 @@ const Hero = () => {
/> />
</div> </div>
<div className="relative z-10 max-w-7xl mx-auto w-full pt-20"> <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-12 lg:gap-24"> <div className="flex flex-col lg:flex-row items-center gap-8 sm:gap-10 lg:gap-24">
{/* Left: Text Content */} {/* 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 <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }} 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="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] font-black uppercase tracking-[0.3em] text-stone-500">{getLabel("hero.badge", "Student & Self-Hoster")}</span> <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> </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 <motion.span
initial={{ opacity: 0, x: -50 }} initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.8, delay: 0.1 }} transition={{ duration: 0.8, delay: 0.1 }}
@@ -65,32 +65,32 @@ const Hero = () => {
> >
{getLabel("hero.line1", "Building")} {getLabel("hero.line1", "Building")}
</motion.span> </motion.span>
<motion.span <motion.span
initial={{ opacity: 0, x: -50 }} initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.8, delay: 0.2 }} 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.")} {getLabel("hero.line2", "Stuff.")}
</motion.span> </motion.span>
</h1> </h1>
<motion.p <motion.p
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ duration: 1, delay: 0.4 }} 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")} {t("description")}
</motion.p> </motion.p>
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.6 }} 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" /> <div className="absolute inset-0 bg-white/10 -translate-x-full group-hover:translate-x-full transition-transform duration-1000" />
{t("ctaWork")} {t("ctaWork")}
</a> </a>
@@ -101,25 +101,25 @@ const Hero = () => {
</div> </div>
{/* Right: The Photo */} {/* Right: The Photo */}
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.8 }} initial={{ opacity: 0, scale: 0.8 }}
animate={{ animate={{
opacity: 1, opacity: 1,
scale: 1 scale: 1
}} }}
transition={{ transition={{
opacity: { duration: 1 }, opacity: { duration: 1 },
scale: { 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="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-[4rem] overflow-hidden border-[24px] border-white dark:border-stone-900 shadow-[0_50px_100px_-20px_rgba(0,0,0,0.4)]"> <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 /> <Image src="/images/me.jpg" alt="Dennis Konkol" fill className="object-cover" priority />
</div> </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"> <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-sm font-black tracking-tighter uppercase">dk<span className="text-red-500">0</span>.dev</span> <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> </div>
</motion.div> </motion.div>

View File

@@ -47,14 +47,14 @@ const Projects = () => {
}, []); }, []);
return ( 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="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> <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> Selected Work<span className="text-liquid-mint">.</span>
</h2> </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. Projects that pushed my boundaries.
</p> </p>
</div> </div>
@@ -63,7 +63,7 @@ const Projects = () => {
</Link> </Link>
</div> </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 ? ( {loading ? (
Array.from({ length: 2 }).map((_, i) => ( Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="space-y-6"> <div key={i} className="space-y-6">
@@ -85,7 +85,7 @@ const Projects = () => {
> >
<Link href={`/${locale}/projects/${project.slug}`} className="block"> <Link href={`/${locale}/projects/${project.slug}`} className="block">
{/* Image Card */} {/* 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 ? ( {project.imageUrl ? (
<Image <Image
src={project.imageUrl} src={project.imageUrl}
@@ -105,14 +105,14 @@ const Projects = () => {
{/* Text Content */} {/* Text Content */}
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <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} {project.title}
</h3> </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} {project.description}
</p> </p>
</div> </div>
<div className="hidden md:flex gap-2"> <div className="hidden sm:flex gap-2">
{project.tags.slice(0, 2).map(tag => ( {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"> <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} {tag}

View File

@@ -17,41 +17,41 @@ export default function NotFound() {
if (!mounted) return null; if (!mounted) return null;
return ( 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="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 */} {/* Main Error Card */}
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} 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>
<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"> <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 404
</div> </div>
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-400">Error Report</span> <span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-400">Error Report</span>
</div> </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> Page not <br/>Found<span className="text-liquid-mint">.</span>
</h1> </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. The content you are looking for has been moved, deleted, or never existed.
</p> </p>
</div> </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 <Link
href="/" 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 Return Home
</Link> </Link>
<button <button
onClick={() => router.back()} 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 Go Back
</button> </button>
@@ -59,22 +59,22 @@ export default function NotFound() {
</motion.div> </motion.div>
{/* Sidebar Cards */} {/* 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 */} {/* Search/Explore Projects */}
<motion.div <motion.div
initial={{ opacity: 0, x: 20 }} initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }} 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"> <div className="relative z-10">
<Search className="text-liquid-mint mb-6" size={32} /> <Search className="text-liquid-mint mb-4 sm:mb-6" size={28} />
<h3 className="text-2xl font-black uppercase tracking-tighter mb-2">Explore Work</h3> <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> <p className="text-stone-400 text-sm font-medium">Maybe what you need is in my project archive?</p>
</div> </div>
<Link <Link
href="/projects" 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} /> View Projects <ArrowLeft className="rotate-180" size={14} />
</Link> </Link>
@@ -86,11 +86,11 @@ export default function NotFound() {
initial={{ opacity: 0, x: 20 }} initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }} 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"> <div className="relative z-10">
<Terminal className="text-liquid-purple mb-6" size={32} /> <Terminal className="text-liquid-purple mb-4 sm:mb-6" size={28} />
<h3 className="text-2xl font-black uppercase tracking-tighter mb-2 text-stone-900 dark:text-stone-50">Technical</h3> <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> <p className="text-stone-500 dark:text-stone-400 text-sm font-medium">Check out my collection of code snippets and notes.</p>
</div> </div>
<Link <Link

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-require-imports */ /* 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: * Why:
* - In real deployments you want schema changes applied automatically per deploy. * - In real deployments you want schema changes applied automatically per deploy.
@@ -8,8 +8,11 @@
* *
* Controls: * Controls:
* - Set `SKIP_PRISMA_MIGRATE=true` to skip migrations (emergency / debugging). * - 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 { spawnSync } = require("node:child_process");
const { createConnection } = require("node:net");
const fs = require("node:fs"); const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
@@ -24,45 +27,120 @@ function run(cmd, args, opts = {}) {
throw res.error; throw res.error;
} }
if (typeof res.status === "number" && res.status !== 0) { if (typeof res.status === "number" && res.status !== 0) {
// propagate exit code
process.exit(res.status); process.exit(res.status);
} }
} }
const skip = String(process.env.SKIP_PRISMA_MIGRATE || "").toLowerCase() === "true"; /**
if (!skip) { * Wait for a TCP port to be reachable.
const autoBaseline = * Parses DATABASE_URL to extract host and port.
String(process.env.PRISMA_AUTO_BASELINE || "").toLowerCase() === "true"; */
function waitForDb() {
// Avoid relying on `npx` resolution in minimal runtimes. const dbUrl = process.env.DATABASE_URL;
// We copy `node_modules/prisma` into the runtime image. if (!dbUrl) {
if (autoBaseline) { console.log("[startup] No DATABASE_URL set, skipping DB wait.");
try { return Promise.resolve();
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.
}
} }
run("node", ["node_modules/prisma/build/index.js", "migrate", "deploy"]);
} else { let host, port;
console.log("SKIP_PRISMA_MIGRATE=true -> skipping prisma migrate deploy"); 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();