db connect
This commit is contained in:
15
Dockerfile
15
Dockerfile
@@ -6,7 +6,7 @@ FROM base AS deps
|
|||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies based on the preferred package manager
|
# Install dependencies
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm ci --legacy-peer-deps
|
RUN npm ci --legacy-peer-deps
|
||||||
|
|
||||||
@@ -17,8 +17,10 @@ WORKDIR /app
|
|||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Prisma generate step - CRITICAL for standalone mode
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Environment variables must be present at build time for Next.js
|
# Environment variables must be present at build time for Next.js
|
||||||
# Coolify will provide these, but we can set defaults
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
@@ -40,16 +42,21 @@ RUN mkdir .next
|
|||||||
RUN chown nextjs:nodejs .next
|
RUN chown nextjs:nodejs .next
|
||||||
|
|
||||||
# Automatically leverage output traces to reduce image size
|
# Automatically leverage output traces to reduce image size
|
||||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# We might need the prisma schema for runtime tasks if used,
|
||||||
|
# but for standalone, the client is already bundled.
|
||||||
|
# However, adding it doesn't hurt for 'db push' tasks in Coolify.
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
# set hostname to localhost
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
# Note: In Coolify, you can run 'npx prisma db push' as a post-deployment script
|
||||||
|
# or change CMD to a wrapper script that runs db push then starts the server.
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
102
app/HomePageClient.tsx
Normal file
102
app/HomePageClient.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import ProjectSlider from '@/components/ProjectSlider'
|
||||||
|
|
||||||
|
export default function HomePageClient({ initialProjects }: { initialProjects: any[] }) {
|
||||||
|
const [isRevealed, setIsRevealed] = useState(false)
|
||||||
|
const [isAtCorners, setIsAtCorners] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const reveal = setTimeout(() => setIsRevealed(true), 500)
|
||||||
|
const move = setTimeout(() => setIsAtCorners(true), 2500)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(reveal)
|
||||||
|
clearTimeout(move)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const transition = { duration: 1.8, ease: [0.76, 0, 0.24, 1] as const }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative h-screen w-full bg-white overflow-hidden">
|
||||||
|
|
||||||
|
<div className="absolute inset-0 z-50 pointer-events-none">
|
||||||
|
<div className="relative w-full h-full">
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
top: isAtCorners ? "100%" : "50%",
|
||||||
|
left: isAtCorners ? "0%" : "50%",
|
||||||
|
x: isAtCorners ? "0%" : "-50%",
|
||||||
|
y: isAtCorners ? "-100%" : "-100%",
|
||||||
|
}}
|
||||||
|
transition={transition}
|
||||||
|
className="absolute overflow-hidden"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: "100%" }}
|
||||||
|
animate={{ y: isRevealed ? "0%" : "100%" }}
|
||||||
|
transition={transition}
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
<motion.h1
|
||||||
|
animate={{ opacity: isAtCorners ? 0 : 1 }}
|
||||||
|
transition={{ duration: 0.8, ease: "easeInOut" }}
|
||||||
|
className="text-[10vw] md:text-[8vw] font-bebas leading-[0.75] text-black tracking-tighter whitespace-nowrap"
|
||||||
|
>
|
||||||
|
AYÇA NUR TURHAN
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
<motion.h1
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: isAtCorners ? 1 : 0 }}
|
||||||
|
transition={{ duration: 0.8, ease: "easeInOut" }}
|
||||||
|
className="absolute inset-0 text-[10vw] md:text-[8vw] font-bebas leading-[0.75] text-black tracking-tighter whitespace-nowrap"
|
||||||
|
>
|
||||||
|
A.N.T
|
||||||
|
</motion.h1>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
top: isAtCorners ? "100%" : "50%",
|
||||||
|
left: isAtCorners ? "100%" : "50%",
|
||||||
|
x: isAtCorners ? "-100%" : "-50%",
|
||||||
|
y: isAtCorners ? "-100%" : "0%",
|
||||||
|
}}
|
||||||
|
transition={transition}
|
||||||
|
className="absolute overflow-hidden"
|
||||||
|
>
|
||||||
|
<motion.h1
|
||||||
|
initial={{ y: "100%" }}
|
||||||
|
animate={{ y: isRevealed ? "0%" : "100%" }}
|
||||||
|
transition={{ ...transition, delay: 0.1 }}
|
||||||
|
className="text-[10vw] md:text-[8vw] font-bebas leading-[0.75] text-black tracking-tighter whitespace-nowrap"
|
||||||
|
>
|
||||||
|
ARCHITECTURE
|
||||||
|
</motion.h1>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isAtCorners && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.6, duration: 1.2, ease: "easeOut" }}
|
||||||
|
className="h-full flex flex-col justify-center z-10"
|
||||||
|
>
|
||||||
|
<ProjectSlider projects={initialProjects} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -201,20 +201,38 @@ export default function AboutPage() {
|
|||||||
function ServiceCard({ service, index, scrollYProgress }: { service: any, index: number, scrollYProgress: any }) {
|
function ServiceCard({ service, index, scrollYProgress }: { service: any, index: number, scrollYProgress: any }) {
|
||||||
const initialOffsets = [0, 200, 400]
|
const initialOffsets = [0, 200, 400]
|
||||||
const ranges: [number, number][] = [
|
const ranges: [number, number][] = [
|
||||||
[0, 0.01],
|
[0, 0.40],
|
||||||
[0, 0.50],
|
[0.10, 0.60],
|
||||||
[0.15, 0.85],
|
[0.20, 0.80],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const x = useTransform(
|
||||||
|
scrollYProgress,
|
||||||
|
ranges[index],
|
||||||
|
[index === 0 ? -300 : index === 2 ? 300 : 0, 0]
|
||||||
|
)
|
||||||
|
|
||||||
const y = useTransform(
|
const y = useTransform(
|
||||||
scrollYProgress,
|
scrollYProgress,
|
||||||
ranges[index],
|
ranges[index],
|
||||||
[initialOffsets[index], 0]
|
[index === 1 ? 400 : 200, 0]
|
||||||
|
)
|
||||||
|
|
||||||
|
const opacity = useTransform(
|
||||||
|
scrollYProgress,
|
||||||
|
ranges[index],
|
||||||
|
[0, 1]
|
||||||
|
)
|
||||||
|
|
||||||
|
const scale = useTransform(
|
||||||
|
scrollYProgress,
|
||||||
|
ranges[index],
|
||||||
|
[0.8, 1]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
style={{ y }}
|
style={{ x, y, opacity, scale }}
|
||||||
className="relative w-full h-full bg-white text-black flex flex-col rounded-2xl overflow-hidden shadow-2xl"
|
className="relative w-full h-full bg-white text-black flex flex-col rounded-2xl overflow-hidden shadow-2xl"
|
||||||
>
|
>
|
||||||
<div className="flex-1 p-10 lg:p-14 overflow-hidden">
|
<div className="flex-1 p-10 lg:p-14 overflow-hidden">
|
||||||
|
|||||||
29
app/admin/api/login/route.ts
Normal file
29
app/admin/api/login/route.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { username, password } = await request.json();
|
||||||
|
|
||||||
|
const envUser = process.env.ADMIN_USER;
|
||||||
|
const envPass = process.env.ADMIN_PASS;
|
||||||
|
|
||||||
|
if (username === envUser && password === envPass) {
|
||||||
|
// Set the session cookie
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set('admin_session', 'authenticated', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 60 * 24, // 24 hours
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Login successful' }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Invalid credentials' }, { status: 401 });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
97
app/admin/layout.tsx
Normal file
97
app/admin/layout.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
if (pathname === '/admin/login') {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
document.cookie = "admin_session=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||||
|
router.push('/admin/login');
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ name: 'Projeler', href: '/admin/projects' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#050505] text-white flex font-sans selection:bg-cyan-500/30">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<motion.aside
|
||||||
|
initial={{ x: -100, opacity: 0 }}
|
||||||
|
animate={{ x: 0, opacity: 1 }}
|
||||||
|
className="w-64 border-r border-white/10 bg-black/40 backdrop-blur-xl p-6 flex flex-col gap-8 sticky top-0 h-screen z-20"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 px-2">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-tr from-cyan-500 to-blue-600 shadow-lg shadow-cyan-500/20 flex items-center justify-center font-bold text-lg">A</div>
|
||||||
|
<span className="text-xl font-bold tracking-tight">AYCANUR <span className="text-cyan-400 text-[10px] font-mono ml-1 uppercase">Admin</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-col gap-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group
|
||||||
|
${isActive
|
||||||
|
? 'bg-gradient-to-r from-cyan-500/10 to-transparent text-cyan-400 border-l-2 border-cyan-500'
|
||||||
|
: 'text-gray-400 hover:text-white hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full transition-all duration-300
|
||||||
|
${isActive ? 'bg-cyan-400 scale-110 shadow-[0_0_8px_rgba(34,211,238,0.8)]' : 'bg-transparent'}`}
|
||||||
|
/>
|
||||||
|
<span className="font-medium">{item.name}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 text-gray-500 hover:text-rose-400 hover:bg-rose-500/5 mt-4 group"
|
||||||
|
>
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-transparent group-hover:bg-rose-400" />
|
||||||
|
<span className="font-medium">Çıkış Yap</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-auto bg-gradient-to-br from-white/5 to-transparent p-4 rounded-2xl border border-white/5">
|
||||||
|
<p className="text-[10px] text-gray-500 mb-2 font-mono uppercase tracking-widest">Sistem Durumu</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
<p className="text-xs text-emerald-400">Tüm sistemler aktif</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.aside>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<div className="fixed top-0 left-0 w-full h-full pointer-events-none overflow-hidden -z-10">
|
||||||
|
<div className="absolute top-[-10%] right-[-5%] w-[40%] h-[40%] bg-cyan-500/10 rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-10%] left-[-5%] w-[30%] h-[30%] bg-purple-500/10 rounded-full blur-[120px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main className="min-h-screen p-8 overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style jsx global>{`
|
||||||
|
::-webkit-scrollbar { width: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 10px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
app/admin/login/page.tsx
Normal file
109
app/admin/login/page.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/admin/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
router.push('/admin/projects');
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
setError('Geçersiz kullanıcı adı veya şifre.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Bir hata oluştu. Lütfen tekrar deneyin.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[#050505] flex items-center justify-center p-6 overflow-hidden relative">
|
||||||
|
<div className="absolute top-[-20%] left-[-10%] w-[60%] h-[60%] bg-cyan-500/10 rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-20%] right-[-10%] w-[50%] h-[50%] bg-purple-500/10 rounded-full blur-[120px]" />
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="w-full max-w-md relative z-10"
|
||||||
|
>
|
||||||
|
<div className="bg-white/5 backdrop-blur-2xl border border-white/10 p-10 rounded-[40px] shadow-2xl">
|
||||||
|
<div className="flex flex-col items-center mb-10 text-center">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-gradient-to-tr from-cyan-500 to-blue-600 flex items-center justify-center font-bold text-3xl shadow-lg shadow-cyan-500/20 mb-6">A</div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-white mb-2">Hoş Geldiniz</h1>
|
||||||
|
<p className="text-gray-500 text-sm uppercase tracking-widest font-mono">Yönetim Paneli Girişi</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleLogin} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-[0.2em] text-gray-500 ml-2">Kullanıcı Adı</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-2xl px-6 py-4 text-white placeholder:text-gray-700 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 transition-all font-medium"
|
||||||
|
placeholder="isminiz..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-[0.2em] text-gray-500 ml-2">Şifre</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-2xl px-6 py-4 text-white placeholder:text-gray-700 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 transition-all font-medium"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
className="bg-rose-500/10 border border-rose-500/20 p-4 rounded-xl text-rose-400 text-xs text-center font-medium"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
disabled={isLoading}
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-cyan-500 hover:bg-cyan-400 text-black font-extrabold py-4 rounded-2xl transition-all shadow-lg shadow-cyan-500/20 active:scale-[0.98] uppercase tracking-widest text-sm flex items-center justify-center gap-3 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="w-5 h-5 border-2 border-black/30 border-t-black rounded-full animate-spin" />
|
||||||
|
) : 'Giriş Yap'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-10 flex justify-center">
|
||||||
|
<p className="text-[10px] text-gray-600 font-mono tracking-widest uppercase">SECURED BY ANT-ARCHITECT</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/admin/page.tsx
Normal file
5
app/admin/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
redirect('/admin/projects');
|
||||||
|
}
|
||||||
298
app/admin/projects/ProjectsClient.tsx
Normal file
298
app/admin/projects/ProjectsClient.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { createProject, deleteProject, updateProject } from './actions';
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
year: string;
|
||||||
|
location: string;
|
||||||
|
image: string;
|
||||||
|
gallery: string[];
|
||||||
|
category: string | null;
|
||||||
|
description: string | null;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectsClient({ initialProjects }: { initialProjects: Project[] }) {
|
||||||
|
const [projects, setProjects] = useState<Project[]>(initialProjects);
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||||
|
const [currentCover, setCurrentCover] = useState<string | null>(null);
|
||||||
|
const [currentGallery, setCurrentGallery] = useState<string[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
const filteredProjects = projects.filter(p =>
|
||||||
|
p.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
p.location.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingProject) {
|
||||||
|
setCurrentCover(editingProject.image || null);
|
||||||
|
setCurrentGallery(editingProject.gallery || []);
|
||||||
|
} else {
|
||||||
|
setCurrentCover(null);
|
||||||
|
setCurrentGallery([]);
|
||||||
|
}
|
||||||
|
}, [editingProject]);
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (confirm('Emin misiniz? Bu işlem projeyi kalıcı olarak silecektir.')) {
|
||||||
|
const prevProjects = [...projects];
|
||||||
|
setProjects(projects.filter(p => p.id !== id));
|
||||||
|
try {
|
||||||
|
await deleteProject(id);
|
||||||
|
} catch (err) {
|
||||||
|
setProjects(prevProjects);
|
||||||
|
alert('Proje silinemedi');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeGalleryImage = (url: string) => {
|
||||||
|
setCurrentGallery(prev => prev.filter(img => img !== url));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCoverImage = () => {
|
||||||
|
setCurrentCover(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
formData.append('existingCover', currentCover || '');
|
||||||
|
formData.append('keepGallery', JSON.stringify(currentGallery));
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingProject) {
|
||||||
|
await updateProject(editingProject.id, formData);
|
||||||
|
} else {
|
||||||
|
await createProject(formData);
|
||||||
|
}
|
||||||
|
setIsAdding(false);
|
||||||
|
setEditingProject(null);
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Kaydedilemedi. Bağlantınızı veya dosya boyutlarını kontrol edin.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (project: Project) => {
|
||||||
|
setEditingProject(project);
|
||||||
|
setIsAdding(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 mb-12">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-1">Proje Yönetimi</h1>
|
||||||
|
<p className="text-gray-500 text-sm font-mono uppercase tracking-widest">Veritabanı Bağlı // {projects.length} Toplam Kayıt</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 w-full md:w-auto">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Proje ara..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="flex-1 md:w-64 bg-white/5 border border-white/10 rounded-2xl px-6 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500/50 transition-all backdrop-blur-md"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingProject(null); setIsAdding(true); }}
|
||||||
|
className="bg-cyan-500 hover:bg-cyan-400 text-black font-bold py-3 px-8 rounded-2xl transition-all shadow-lg shadow-cyan-500/20 active:scale-95 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Yeni Proje Ekle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
<AnimatePresence mode='popLayout'>
|
||||||
|
{filteredProjects.map((project) => (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
key={project.id}
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
className="bg-white/5 border border-white/10 rounded-[32px] overflow-hidden group hover:border-cyan-500/30 transition-all flex flex-col h-full"
|
||||||
|
>
|
||||||
|
<div className="aspect-[16/10] relative overflow-hidden bg-black">
|
||||||
|
{project.image ? (
|
||||||
|
<Image
|
||||||
|
src={project.image}
|
||||||
|
alt={project.title}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 768px) 100vw, 25vw"
|
||||||
|
className="object-cover transition-transform duration-700 group-hover:scale-110 grayscale group-hover:grayscale-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-gray-700 font-mono text-[10px] tracking-widest opacity-20">GÖRSEL_YOK</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-60" />
|
||||||
|
<div className="absolute top-4 right-4 bg-black/60 backdrop-blur-md px-3 py-1 rounded-full text-[10px] font-bold text-cyan-400 border border-white/10">
|
||||||
|
{project.category}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 flex-1 flex flex-col">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<h3 className="font-bold text-lg leading-tight group-hover:text-cyan-400 transition-colors uppercase tracking-tight">{project.title}</h3>
|
||||||
|
<span className="text-[10px] font-mono text-gray-500 mt-1">{project.year}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-500 mb-6 flex-1 italic font-mono uppercase tracking-widest">{project.location}</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 mt-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(project)}
|
||||||
|
className="py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-xs font-bold transition-colors"
|
||||||
|
>
|
||||||
|
Düzenle
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(project.id)}
|
||||||
|
className="py-2.5 rounded-xl bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-xs font-bold transition-colors"
|
||||||
|
>
|
||||||
|
Sil
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isAdding && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-6">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||||
|
onClick={() => !isLoading && (setIsAdding(false), setEditingProject(null))}
|
||||||
|
className="absolute inset-0 bg-black/90 backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0, y: 20 }} animate={{ scale: 1, opacity: 1, y: 0 }} exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||||
|
className="bg-[#0a0a0a] border border-white/10 rounded-[40px] p-10 w-full max-w-3xl relative z-10 shadow-2xl max-h-[95vh] overflow-y-auto no-scrollbar"
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-bold mb-8 flex items-center gap-3">
|
||||||
|
<span className="w-1.5 h-6 bg-cyan-500 rounded-full"></span>
|
||||||
|
{editingProject ? 'Projeyi Düzenle' : 'Yeni Proje Ekle'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-500 ml-1">Proje Adı</label>
|
||||||
|
<input name="title" required defaultValue={editingProject?.title} type="text" className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 transition-all" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-500 ml-1">Yapım Yılı</label>
|
||||||
|
<input name="year" required defaultValue={editingProject?.year} type="text" className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 transition-all" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-500 ml-1">Konum</label>
|
||||||
|
<input name="location" required defaultValue={editingProject?.location} type="text" className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 transition-all" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-500 ml-1">Kategori</label>
|
||||||
|
<select name="category" required defaultValue={editingProject?.category || 'Konut'} className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 transition-all appearance-none">
|
||||||
|
<option value="Konut" className="bg-black">Konut</option>
|
||||||
|
<option value="Ticari" className="bg-black">Ticari</option>
|
||||||
|
<option value="Kültürel" className="bg-black">Kültürel</option>
|
||||||
|
<option value="Restorasyon" className="bg-black">Restorasyon</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-500 ml-1">Proje Açıklaması</label>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
defaultValue={editingProject?.description || ''}
|
||||||
|
rows={4}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 transition-all resize-none"
|
||||||
|
placeholder="Proje detaylarını buraya yazın..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 bg-white/3 rounded-[32px] border border-white/5 space-y-6">
|
||||||
|
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-widest">Medya Dosyaları</h3>
|
||||||
|
|
||||||
|
{currentCover && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center pr-2">
|
||||||
|
<label className="text-[9px] font-bold uppercase text-gray-600">Mevcut Kapak</label>
|
||||||
|
<button type="button" onClick={removeCoverImage} className="text-[9px] font-bold text-rose-500 hover:text-rose-400">KAPAĞI KALDIR</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-40 aspect-video rounded-xl overflow-hidden border border-white/10 group/cover">
|
||||||
|
<Image src={currentCover} alt="Preview" fill sizes="160px" className="object-cover" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
|
||||||
|
{currentCover ? 'Kapağı Değiştir' : 'Kapak Resmi Yükle'}
|
||||||
|
</label>
|
||||||
|
<input name="image" required={!currentCover} type="file" accept="image/*" className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs focus:ring-1 focus:ring-cyan-500 transition-all" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentGallery.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-[9px] font-bold uppercase text-gray-600">Mevcut Galeri ({currentGallery.length})</label>
|
||||||
|
<div className="grid grid-cols-4 md:grid-cols-6 gap-3">
|
||||||
|
{currentGallery.map((url, i) => (
|
||||||
|
<div key={i} className="relative aspect-square rounded-lg overflow-hidden border border-white/10 group/img">
|
||||||
|
<Image src={url} alt="Gallery item" fill sizes="80px" className="object-cover transition-transform group-hover/img:scale-110" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeGalleryImage(url)}
|
||||||
|
className="absolute inset-0 bg-rose-500/60 opacity-0 group-hover/img:opacity-100 flex items-center justify-center transition-opacity"
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-bold text-white">SİL</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
|
||||||
|
Yeni Galeri Resimleri Ekle
|
||||||
|
</label>
|
||||||
|
<input name="gallery" type="file" accept="image/*" multiple className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs focus:ring-1 focus:ring-cyan-500 transition-all" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-6 flex gap-4">
|
||||||
|
<button type="button" disabled={isLoading} onClick={() => { setIsAdding(false); setEditingProject(null); }} className="flex-1 py-4 rounded-3xl bg-white/5 hover:bg-white/10 font-bold transition-all disabled:opacity-50">İptal</button>
|
||||||
|
<button type="submit" disabled={isLoading} className="flex-2 py-4 rounded-3xl bg-gradient-to-r from-cyan-500 to-blue-600 text-black font-extrabold tracking-widest uppercase text-sm transition-all shadow-xl shadow-cyan-500/20 active:scale-95 disabled:opacity-50 flex items-center justify-center gap-2">
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<div className="w-4 h-4 border-2 border-black/30 border-t-black rounded-full animate-spin" />
|
||||||
|
Aktarılıyor...
|
||||||
|
</>
|
||||||
|
) : (editingProject ? 'Değişiklikleri Uygula' : 'Yayına Al')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
app/admin/projects/actions.ts
Normal file
122
app/admin/projects/actions.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { uploadBuffer } from "@/lib/cloudinary";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function getProjects() {
|
||||||
|
return await prisma.project.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProject(formData: FormData) {
|
||||||
|
const title = formData.get("title") as string;
|
||||||
|
const year = formData.get("year") as string;
|
||||||
|
const location = formData.get("location") as string;
|
||||||
|
const category = formData.get("category") as string;
|
||||||
|
const description = formData.get("description") as string;
|
||||||
|
|
||||||
|
const mainImageFile = formData.get("image") as File;
|
||||||
|
const galleryFiles = formData.getAll("gallery") as File[];
|
||||||
|
|
||||||
|
const slug = title.toLowerCase().replace(/ /g, "-").replace(/[^\w-]+/g, "");
|
||||||
|
|
||||||
|
let imageUrl = "";
|
||||||
|
let galleryUrls: string[] = [];
|
||||||
|
|
||||||
|
if (mainImageFile && mainImageFile.size > 0) {
|
||||||
|
const buffer = Buffer.from(await mainImageFile.arrayBuffer());
|
||||||
|
imageUrl = await uploadBuffer(buffer, slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of galleryFiles) {
|
||||||
|
if (file && file.size > 0) {
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const url = await uploadBuffer(buffer, slug);
|
||||||
|
galleryUrls.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
year,
|
||||||
|
location,
|
||||||
|
category,
|
||||||
|
description,
|
||||||
|
slug,
|
||||||
|
image: imageUrl,
|
||||||
|
gallery: galleryUrls
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/projects");
|
||||||
|
revalidatePath("/projects");
|
||||||
|
revalidatePath("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProject(id: number, formData: FormData) {
|
||||||
|
const title = formData.get("title") as string;
|
||||||
|
const year = formData.get("year") as string;
|
||||||
|
const location = formData.get("location") as string;
|
||||||
|
const category = formData.get("category") as string;
|
||||||
|
const description = formData.get("description") as string;
|
||||||
|
const keepGalleryJson = formData.get("keepGallery") as string;
|
||||||
|
|
||||||
|
const mainImageFile = formData.get("image") as File;
|
||||||
|
const galleryFiles = formData.getAll("gallery") as File[];
|
||||||
|
|
||||||
|
const existingProject = await prisma.project.findUnique({ where: { id } });
|
||||||
|
if (!existingProject) throw new Error("Project not found");
|
||||||
|
|
||||||
|
const slug = title.toLowerCase().replace(/ /g, "-").replace(/[^\w-]+/g, "");
|
||||||
|
|
||||||
|
const existingCover = formData.get("existingCover") as string;
|
||||||
|
let imageUrl = existingCover || ""; // Use existing if provided, else empty
|
||||||
|
|
||||||
|
// Parse existing gallery images we want to keep
|
||||||
|
let galleryUrls: string[] = keepGalleryJson ? JSON.parse(keepGalleryJson) : [...existingProject.gallery];
|
||||||
|
|
||||||
|
// Update Main Image if new file uploaded
|
||||||
|
if (mainImageFile && mainImageFile.size > 0) {
|
||||||
|
const buffer = Buffer.from(await mainImageFile.arrayBuffer());
|
||||||
|
imageUrl = await uploadBuffer(buffer, slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add New Gallery Images if any
|
||||||
|
for (const file of galleryFiles) {
|
||||||
|
if (file && file.size > 0) {
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const url = await uploadBuffer(buffer, slug);
|
||||||
|
galleryUrls.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.project.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
year,
|
||||||
|
location,
|
||||||
|
category,
|
||||||
|
description,
|
||||||
|
slug,
|
||||||
|
image: imageUrl,
|
||||||
|
gallery: galleryUrls
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/projects");
|
||||||
|
revalidatePath("/projects");
|
||||||
|
revalidatePath("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProject(id: number) {
|
||||||
|
await prisma.project.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/projects");
|
||||||
|
revalidatePath("/projects");
|
||||||
|
revalidatePath("/");
|
||||||
|
}
|
||||||
13
app/admin/projects/page.tsx
Normal file
13
app/admin/projects/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { getProjects } from './actions';
|
||||||
|
import ProjectsClient from './ProjectsClient';
|
||||||
|
|
||||||
|
export default async function ProjectsManagementPage() {
|
||||||
|
const initialProjects = await getProjects();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-[1400px] mx-auto">
|
||||||
|
<ProjectsClient initialProjects={initialProjects} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
app/page.tsx
118
app/page.tsx
@@ -1,108 +1,18 @@
|
|||||||
'use client'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import HomePageClient from './HomePageClient'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
export default async function Home() {
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
const projects = await prisma.project.findMany({
|
||||||
import ProjectSlider from '@/components/ProjectSlider'
|
orderBy: { createdAt: 'desc' },
|
||||||
import { projects } from '@/data/projects'
|
take: 10 // Limit for the slider
|
||||||
|
})
|
||||||
|
|
||||||
export default function Home() {
|
// Serialize projects for the client
|
||||||
const [isRevealed, setIsRevealed] = useState(false)
|
const serializedProjects = projects.map(p => ({
|
||||||
const [isAtCorners, setIsAtCorners] = useState(false)
|
...p,
|
||||||
|
createdAt: p.createdAt.toISOString(),
|
||||||
|
updatedAt: p.updatedAt.toISOString(),
|
||||||
|
}))
|
||||||
|
|
||||||
useEffect(() => {
|
return <HomePageClient initialProjects={serializedProjects} />
|
||||||
const reveal = setTimeout(() => setIsRevealed(true), 500)
|
|
||||||
const move = setTimeout(() => setIsAtCorners(true), 2500)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(reveal)
|
|
||||||
clearTimeout(move)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const transition = { duration: 1.8, ease: [0.76, 0, 0.24, 1] as const }
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="relative h-screen w-full bg-white overflow-hidden">
|
|
||||||
|
|
||||||
<div className="absolute inset-0 z-50 pointer-events-none">
|
|
||||||
<div className="relative w-full h-full">
|
|
||||||
|
|
||||||
{/* Main Container for the dynamic text */}
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
top: isAtCorners ? "100%" : "50%",
|
|
||||||
left: isAtCorners ? "0%" : "50%",
|
|
||||||
x: isAtCorners ? "0%" : "-50%",
|
|
||||||
y: isAtCorners ? "-100%" : "-100%",
|
|
||||||
}}
|
|
||||||
transition={transition}
|
|
||||||
className="absolute overflow-hidden"
|
|
||||||
>
|
|
||||||
{/* The Text Reveal & Cross-fade */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ y: "100%" }}
|
|
||||||
animate={{ y: isRevealed ? "0%" : "100%" }}
|
|
||||||
transition={transition}
|
|
||||||
className="relative"
|
|
||||||
>
|
|
||||||
{/* AYÇA NUR TURHAN */}
|
|
||||||
<motion.h1
|
|
||||||
animate={{ opacity: isAtCorners ? 0 : 1 }}
|
|
||||||
transition={{ duration: 0.8, ease: "easeInOut" }}
|
|
||||||
className="text-[10vw] md:text-[8vw] font-bebas leading-[0.75] text-black tracking-tighter whitespace-nowrap"
|
|
||||||
>
|
|
||||||
AYÇA NUR TURHAN
|
|
||||||
</motion.h1>
|
|
||||||
|
|
||||||
{/* A.N.T (Cross-fading in) */}
|
|
||||||
<motion.h1
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: isAtCorners ? 1 : 0 }}
|
|
||||||
transition={{ duration: 0.8, ease: "easeInOut" }}
|
|
||||||
className="absolute inset-0 text-[10vw] md:text-[8vw] font-bebas leading-[0.75] text-black tracking-tighter whitespace-nowrap"
|
|
||||||
>
|
|
||||||
A.N.T
|
|
||||||
</motion.h1>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* ARCHITECTURE (Remains constant) */}
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
top: isAtCorners ? "100%" : "50%",
|
|
||||||
left: isAtCorners ? "100%" : "50%",
|
|
||||||
x: isAtCorners ? "-100%" : "-50%",
|
|
||||||
y: isAtCorners ? "-100%" : "0%",
|
|
||||||
}}
|
|
||||||
transition={transition}
|
|
||||||
className="absolute overflow-hidden"
|
|
||||||
>
|
|
||||||
<motion.h1
|
|
||||||
initial={{ y: "100%" }}
|
|
||||||
animate={{ y: isRevealed ? "0%" : "100%" }}
|
|
||||||
transition={{ ...transition, delay: 0.1 }}
|
|
||||||
className="text-[10vw] md:text-[8vw] font-bebas leading-[0.75] text-black tracking-tighter whitespace-nowrap"
|
|
||||||
>
|
|
||||||
ARCHITECTURE
|
|
||||||
</motion.h1>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{isAtCorners && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ delay: 0.6, duration: 1.2, ease: "easeOut" }}
|
|
||||||
className="h-full flex flex-col justify-center z-10"
|
|
||||||
>
|
|
||||||
<ProjectSlider projects={projects} />
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
88
app/projects/ProjectsPageClient.tsx
Normal file
88
app/projects/ProjectsPageClient.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion, useScroll, useTransform } from 'framer-motion'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
export default function ProjectsPageClient({ initialProjects }: { initialProjects: any[] }) {
|
||||||
|
const { scrollY } = useScroll()
|
||||||
|
const [isAtBottom, setIsAtBottom] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
const windowHeight = window.innerHeight
|
||||||
|
const documentHeight = document.documentElement.scrollHeight
|
||||||
|
const scrollPosition = window.scrollY + windowHeight
|
||||||
|
setIsAtBottom(scrollPosition > documentHeight - 100)
|
||||||
|
}
|
||||||
|
window.addEventListener('scroll', handleScroll)
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const cargoFontSize = useTransform(scrollY, [0, 300], ["10vw", "4vw"])
|
||||||
|
const archFontSize = useTransform(scrollY, [0, 300], ["8vw", "3vw"])
|
||||||
|
const bottomPadding = useTransform(scrollY, [0, 300], ["2.5rem", "1rem"])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative min-h-screen bg-white pt-32 pb-60 px-6 md:px-10">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
|
||||||
|
{initialProjects.map((project, idx) => (
|
||||||
|
<Link key={project.slug} href={`/projects/${project.slug}`}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: idx * 0.05 }}
|
||||||
|
className="group cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-[4/3] overflow-hidden rounded-[1px] bg-zinc-100">
|
||||||
|
<Image
|
||||||
|
src={project.image}
|
||||||
|
alt={project.title}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||||
|
className="object-cover grayscale group-hover:grayscale-0 transition-all duration-700 ease-in-out scale-105 group-hover:scale-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-col space-y-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||||
|
<span className="text-[10px] font-bold text-black/60 uppercase tracking-wider">
|
||||||
|
{project.year} — {project.location}
|
||||||
|
</span>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-[10px] font-bold text-black uppercase tracking-widest">
|
||||||
|
{project.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm">→</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
animate={{ opacity: isAtBottom ? 0 : 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="fixed bottom-0 left-0 w-full pointer-events-none z-50 overflow-hidden pt-10"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
style={{ padding: bottomPadding }}
|
||||||
|
className="flex justify-between items-end"
|
||||||
|
>
|
||||||
|
<motion.h1
|
||||||
|
style={{ fontSize: cargoFontSize }}
|
||||||
|
className="font-bebas leading-[0.8] text-black tracking-tighter"
|
||||||
|
>
|
||||||
|
A.N.T
|
||||||
|
</motion.h1>
|
||||||
|
<motion.h1
|
||||||
|
style={{ fontSize: archFontSize }}
|
||||||
|
className="font-bebas leading-[0.8] text-black tracking-tighter"
|
||||||
|
>
|
||||||
|
ARCHITECTURE
|
||||||
|
</motion.h1>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
108
app/projects/[slug]/ProjectDetailClient.tsx
Normal file
108
app/projects/[slug]/ProjectDetailClient.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export default function ProjectDetailClient({ project }: { project: any }) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white selection:bg-black selection:text-white">
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="fixed top-0 left-0 w-full z-50 p-6 md:p-10 flex justify-between items-center mix-blend-difference text-white pointer-events-none">
|
||||||
|
<Link href="/" className="pointer-events-auto font-bebas text-2xl tracking-tighter hover:opacity-70 transition-opacity">
|
||||||
|
A.N.T
|
||||||
|
</Link>
|
||||||
|
<Link href="/projects" className="pointer-events-auto text-[10px] font-bold uppercase tracking-[0.2em] hover:opacity-70 transition-opacity">
|
||||||
|
Tüm Projeler
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Hero Section */}
|
||||||
|
<div className="flex flex-col md:flex-row min-h-screen">
|
||||||
|
<div className="w-full md:w-1/3 pt-32 pb-10 px-6 md:px-10 flex flex-col justify-between">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.8 }}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-bold text-black/40 uppercase tracking-[0.3em] mb-4">
|
||||||
|
{project.year} — {project.location}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl md:text-6xl font-bebas text-black leading-none tracking-tighter uppercase mb-6">
|
||||||
|
{project.title}
|
||||||
|
</h1>
|
||||||
|
<div className="h-[1px] w-12 bg-black mb-6" />
|
||||||
|
<p className="text-sm text-black/60 leading-relaxed max-w-sm">
|
||||||
|
{project.description || `Bu proje, modern mimari prensipleri ve fonksiyonel tasarım anlayışıyla hayata geçirilmiştir. Fethiye merkezli A.N.T Architecture vizyonuyla şekillenen bu yapı, çevresel dokuyla uyumlu bir estetik sunar.`}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="mt-12">
|
||||||
|
<div className="text-[10px] font-bold text-black uppercase tracking-[0.2em] mb-2 opacity-40">Kategori</div>
|
||||||
|
<div className="text-sm font-bold text-black uppercase tracking-widest">{project.category || 'Architecture'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full md:w-2/3 h-[70vh] md:h-screen relative overflow-hidden bg-zinc-100">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 1.1, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ duration: 1.5, ease: [0.76, 0, 0.24, 1] }}
|
||||||
|
className="w-full h-full"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={project.image}
|
||||||
|
alt={project.title}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 768px) 100vw, 80vw"
|
||||||
|
priority
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gallery Section */}
|
||||||
|
{project.gallery && project.gallery.length > 0 && (
|
||||||
|
<section className="py-20 md:py-40 px-6 md:px-10 bg-[#f9f9f9]">
|
||||||
|
<div className="max-w-[1400px] mx-auto">
|
||||||
|
<div className="mb-20">
|
||||||
|
<h2 className="text-[10px] font-bold text-black/30 uppercase tracking-[0.5em] mb-4">PROJE GÖRSELLERİ</h2>
|
||||||
|
<div className="h-[1px] w-full bg-black/5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="columns-1 md:columns-2 gap-10 space-y-10">
|
||||||
|
{project.gallery.map((img: string, i: number) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-100px" }}
|
||||||
|
transition={{ duration: 0.8, delay: i * 0.1 }}
|
||||||
|
className="relative group overflow-hidden bg-white"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={img}
|
||||||
|
alt={`${project.title} Gallery ${i}`}
|
||||||
|
width={1200}
|
||||||
|
height={800}
|
||||||
|
className="w-full h-auto object-cover grayscale group-hover:grayscale-0 transition-all duration-1000"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Back Link Footer */}
|
||||||
|
<footer className="py-20 flex flex-col items-center justify-center border-t border-black/5">
|
||||||
|
<Link href="/projects" className="group flex flex-col items-center gap-4">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-[0.5em] text-black/40 group-hover:text-black transition-colors">SIRADAKİ PROJE</span>
|
||||||
|
<div className="w-px h-12 bg-black/10 group-hover:h-20 transition-all duration-700" />
|
||||||
|
</Link>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,95 +1,28 @@
|
|||||||
'use client'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import { projects } from '@/data/projects'
|
|
||||||
import { motion } from 'framer-motion'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
|
import ProjectDetailClient from './ProjectDetailClient'
|
||||||
|
|
||||||
export default function ProjectDetailPage() {
|
interface Props {
|
||||||
const params = useParams()
|
params: Promise<{ slug: string }>
|
||||||
const slug = params.slug as string
|
}
|
||||||
|
|
||||||
|
export default async function ProjectDetailPage({ params }: Props) {
|
||||||
|
const { slug } = await params
|
||||||
|
|
||||||
const project = projects.find(p => p.slug === slug)
|
const project = await prisma.project.findUnique({
|
||||||
|
where: { slug }
|
||||||
|
})
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Serialize dates and gallery
|
||||||
<main className="min-h-screen bg-white">
|
const serializedProject = {
|
||||||
{/* Navigation */}
|
...project,
|
||||||
<nav className="fixed top-0 left-0 w-full z-50 p-6 md:p-10 flex justify-between items-center mix-blend-difference text-white pointer-events-none">
|
createdAt: project.createdAt.toISOString(),
|
||||||
<Link href="/" className="pointer-events-auto font-bebas text-2xl tracking-tighter hover:opacity-70 transition-opacity">
|
updatedAt: project.updatedAt.toISOString(),
|
||||||
A.N.T
|
}
|
||||||
</Link>
|
|
||||||
<Link href="/projects" className="pointer-events-auto text-[10px] font-bold uppercase tracking-[0.2em] hover:opacity-70 transition-opacity">
|
|
||||||
Tüm Projeler
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row min-h-screen">
|
return <ProjectDetailClient project={serializedProject} />
|
||||||
{/* Left Side: Info */}
|
|
||||||
<div className="w-full md:w-1/3 pt-32 pb-10 px-6 md:px-10 flex flex-col justify-between">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.8 }}
|
|
||||||
>
|
|
||||||
<div className="text-[10px] font-bold text-black/40 uppercase tracking-[0.3em] mb-4">
|
|
||||||
{project.year} — {project.location}
|
|
||||||
</div>
|
|
||||||
<h1 className="text-4xl md:text-6xl font-bebas text-black leading-none tracking-tighter uppercase mb-6">
|
|
||||||
{project.title}
|
|
||||||
</h1>
|
|
||||||
<div className="h-[1px] w-12 bg-black mb-6" />
|
|
||||||
<p className="text-sm text-black/60 leading-relaxed max-w-sm">
|
|
||||||
Bu proje, modern mimari prensipleri ve fonksiyonel tasarım anlayışıyla hayata geçirilmiştir.
|
|
||||||
Detaylar ve uygulama süreçleri hakkında daha fazla bilgi için bizimle iletişime geçebilirsiniz.
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ delay: 0.4, duration: 0.8 }}
|
|
||||||
className="hidden md:block"
|
|
||||||
>
|
|
||||||
<div className="text-[10px] font-bold text-black uppercase tracking-[0.2em]">
|
|
||||||
Kategori: {project.category || 'Belirtilmemiş'}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Side: Image */}
|
|
||||||
<div className="w-full md:w-2/3 h-[70vh] md:h-screen relative">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 1.1 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 1.2, ease: [0.76, 0, 0.24, 1] }}
|
|
||||||
className="w-full h-full"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={project.image}
|
|
||||||
alt={project.title}
|
|
||||||
fill
|
|
||||||
priority
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Subtle Overlay */}
|
|
||||||
<div className="absolute inset-0 bg-black/5 pointer-events-none" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Footer Info */}
|
|
||||||
<div className="md:hidden p-6 border-t border-black/5">
|
|
||||||
<div className="text-[10px] font-bold text-black uppercase tracking-[0.2em]">
|
|
||||||
Kategori: {project.category || 'Belirtilmemiş'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +1,17 @@
|
|||||||
'use client'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import ProjectsPageClient from './ProjectsPageClient'
|
||||||
|
|
||||||
import { motion, useScroll, useTransform } from 'framer-motion'
|
export default async function ProjectsPage() {
|
||||||
import Image from 'next/image'
|
const projects = await prisma.project.findMany({
|
||||||
import Link from 'next/link'
|
orderBy: { createdAt: 'desc' }
|
||||||
import { projects } from '@/data/projects'
|
})
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
// Normalize data for client (Prisma returns Dates, Client expects standard objects if needed)
|
||||||
const { scrollY } = useScroll()
|
const serializedProjects = projects.map(p => ({
|
||||||
const [isAtBottom, setIsAtBottom] = useState(false)
|
...p,
|
||||||
|
createdAt: p.createdAt.toISOString(),
|
||||||
|
updatedAt: p.updatedAt.toISOString(),
|
||||||
|
}))
|
||||||
|
|
||||||
useEffect(() => {
|
return <ProjectsPageClient initialProjects={serializedProjects} />
|
||||||
const handleScroll = () => {
|
|
||||||
const windowHeight = window.innerHeight
|
|
||||||
const documentHeight = document.documentElement.scrollHeight
|
|
||||||
const scrollPosition = window.scrollY + windowHeight
|
|
||||||
setIsAtBottom(scrollPosition > documentHeight - 100)
|
|
||||||
}
|
|
||||||
window.addEventListener('scroll', handleScroll)
|
|
||||||
return () => window.removeEventListener('scroll', handleScroll)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const cargoFontSize = useTransform(scrollY, [0, 300], ["10vw", "4vw"])
|
|
||||||
const archFontSize = useTransform(scrollY, [0, 300], ["8vw", "3vw"])
|
|
||||||
const bottomPadding = useTransform(scrollY, [0, 300], ["2.5rem", "1rem"])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="relative min-h-screen bg-white pt-32 pb-60 px-6 md:px-10">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
|
|
||||||
{projects.map((project, idx) => (
|
|
||||||
<Link key={project.slug} href={`/projects/${project.slug}`}>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.6, delay: idx * 0.05 }}
|
|
||||||
className="group cursor-pointer"
|
|
||||||
>
|
|
||||||
<div className="relative aspect-[4/3] overflow-hidden rounded-[1px] bg-zinc-100">
|
|
||||||
<Image
|
|
||||||
src={project.image}
|
|
||||||
alt={project.title}
|
|
||||||
fill
|
|
||||||
className="object-cover grayscale group-hover:grayscale-0 transition-all duration-700 ease-in-out scale-105 group-hover:scale-100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex flex-col space-y-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
|
||||||
<span className="text-[10px] font-bold text-black/60 uppercase tracking-wider">
|
|
||||||
{project.year} — {project.location}
|
|
||||||
</span>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-[10px] font-bold text-black uppercase tracking-widest">
|
|
||||||
{project.title}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm">→</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
animate={{ opacity: isAtBottom ? 0 : 1 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
className="fixed bottom-0 left-0 w-full pointer-events-none z-50 overflow-hidden pt-10"
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
style={{ padding: bottomPadding }}
|
|
||||||
className="flex justify-between items-end"
|
|
||||||
>
|
|
||||||
<motion.h1
|
|
||||||
style={{ fontSize: cargoFontSize }}
|
|
||||||
className="font-bebas leading-[0.8] text-black tracking-tighter"
|
|
||||||
>
|
|
||||||
A.N.T
|
|
||||||
</motion.h1>
|
|
||||||
<motion.h1
|
|
||||||
style={{ fontSize: archFontSize }}
|
|
||||||
className="font-bebas leading-[0.8] text-black tracking-tighter"
|
|
||||||
>
|
|
||||||
ARCHITECTURE
|
|
||||||
</motion.h1>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ import Footer from "@/components/Footer";
|
|||||||
|
|
||||||
export default function LayoutContent({ children }: { children: React.ReactNode }) {
|
export default function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const isAdmin = pathname?.startsWith('/admin');
|
||||||
const isHome = pathname === "/";
|
const isHome = pathname === "/";
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
return <div className="flex-1">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|||||||
31
lib/cloudinary.ts
Normal file
31
lib/cloudinary.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { v2 as cloudinary } from 'cloudinary';
|
||||||
|
|
||||||
|
cloudinary.config({
|
||||||
|
cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
|
||||||
|
api_key: process.env.NEXT_PUBLIC_CLOUDINARY_API_KEY,
|
||||||
|
api_secret: process.env.NEXT_PUBLIC_CLOUDINARY_API_SECRET,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads a buffer to Cloudinary into a specified project subfolder.
|
||||||
|
* @param buffer - Image buffer
|
||||||
|
* @param projectSlug - The slug of the project to create a subfolder for
|
||||||
|
*/
|
||||||
|
export async function uploadBuffer(buffer: Buffer, projectSlug: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const uploadStream = cloudinary.uploader.upload_stream(
|
||||||
|
{
|
||||||
|
folder: `aycanur-projects/${projectSlug}`,
|
||||||
|
use_filename: true,
|
||||||
|
unique_filename: true
|
||||||
|
},
|
||||||
|
(error, result) => {
|
||||||
|
if (result) resolve(result.secure_url);
|
||||||
|
else reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
uploadStream.end(buffer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default cloudinary;
|
||||||
20
lib/prisma.ts
Normal file
20
lib/prisma.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Pool } from 'pg';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const globalForPrisma = global as unknown as { prisma: PrismaClient };
|
||||||
|
|
||||||
|
// Robustly get connection string with fallback
|
||||||
|
const connectionString = process.env.DATABASE_URL || "postgres://postgres:P9cIY8Ji1iSXOCRs9q6WbOo5xeXCdzyQjYoQ511Zmq1RY8WHLU9YKBGyjDpJ02sa@65.109.236.58:6482/postgres";
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ||
|
||||||
|
new PrismaClient({
|
||||||
|
adapter,
|
||||||
|
log: ['query'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||||
28
middleware.ts
Normal file
28
middleware.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import type { NextRequest } from 'next/server'
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl
|
||||||
|
|
||||||
|
// Check if the path is under /admin
|
||||||
|
if (pathname.startsWith('/admin')) {
|
||||||
|
// Allow access to login page
|
||||||
|
if (pathname === '/admin/login' || pathname.startsWith('/admin/api')) {
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for admin session cookie
|
||||||
|
const adminSession = request.cookies.get('admin_session')?.value
|
||||||
|
|
||||||
|
if (!adminSession || adminSession !== 'authenticated') {
|
||||||
|
return NextResponse.redirect(new URL('/admin/login', request.url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match only admin paths
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/admin/:path*'],
|
||||||
|
}
|
||||||
@@ -2,12 +2,22 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
serverExternalPackages: ['@prisma/client', 'pg'],
|
||||||
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: '10mb',
|
||||||
|
},
|
||||||
|
},
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname: 'images.unsplash.com',
|
hostname: 'images.unsplash.com',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'res.cloudinary.com',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
1334
package-lock.json
generated
1334
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,18 +9,25 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/adapter-pg": "^7.7.0",
|
||||||
|
"@prisma/client": "^7.7.0",
|
||||||
|
"cloudinary": "^2.9.0",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"next": "16.2.3",
|
"next": "16.2.3",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@prisma/config": "^7.7.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.3",
|
"eslint-config-next": "16.2.3",
|
||||||
|
"prisma": "^7.7.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
11
prisma.config.ts
Normal file
11
prisma.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from '@prisma/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prisma 7 Direct Configuration
|
||||||
|
* Hardcoding URL to bypass CLI environment resolution issues.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
datasource: {
|
||||||
|
url: "postgres://postgres:P9cIY8Ji1iSXOCRs9q6WbOo5xeXCdzyQjYoQ511Zmq1RY8WHLU9YKBGyjDpJ02sa@65.109.236.58:6482/postgres",
|
||||||
|
},
|
||||||
|
});
|
||||||
28
prisma/schema.prisma
Normal file
28
prisma/schema.prisma
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
model Project {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
slug String @unique
|
||||||
|
title String
|
||||||
|
year String
|
||||||
|
location String
|
||||||
|
image String
|
||||||
|
gallery String[]
|
||||||
|
category String?
|
||||||
|
description String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Admin {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
username String @unique
|
||||||
|
password String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
98
prisma/seed.js
Normal file
98
prisma/seed.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
const { PrismaPg } = require('@prisma/adapter-pg');
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
|
||||||
|
const connectionString = "postgres://postgres:P9cIY8Ji1iSXOCRs9q6WbOo5xeXCdzyQjYoQ511Zmq1RY8WHLU9YKBGyjDpJ02sa@65.109.236.58:6482/postgres";
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
const projects = [
|
||||||
|
{
|
||||||
|
slug: 'jdhm-genel-merkez',
|
||||||
|
year: '2018',
|
||||||
|
location: 'SAINT-AUGUSTIN-DE-DESMAURES',
|
||||||
|
title: 'JDHM – GENEL MERKEZ',
|
||||||
|
image: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?q=80&w=2070&auto=format&fit=crop',
|
||||||
|
category: 'Ticari'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'sale',
|
||||||
|
year: '2019',
|
||||||
|
location: 'MONTREAL, QC',
|
||||||
|
title: 'ŞALE',
|
||||||
|
image: 'https://images.unsplash.com/photo-1518780664697-55e3ad937233?q=80&w=2070&auto=format&fit=crop',
|
||||||
|
category: 'Konut'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'modern-muze',
|
||||||
|
year: '2020',
|
||||||
|
location: 'QUEBEC CITY, QC',
|
||||||
|
title: 'MODERN MÜZE',
|
||||||
|
image: 'https://images.unsplash.com/photo-1511818966892-d7d671e672a2?q=80&w=2070&auto=format&fit=crop',
|
||||||
|
category: 'Kültürel'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'orman-evi',
|
||||||
|
year: '2022',
|
||||||
|
location: 'VANCOUVER, BC',
|
||||||
|
title: 'ORMAN EVİ',
|
||||||
|
image: 'https://images.unsplash.com/photo-1500382017468-9049fed747ef?q=80&w=2070&auto=format&fit=crop',
|
||||||
|
category: 'Konut'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'kocina',
|
||||||
|
year: '2023',
|
||||||
|
location: 'SAINTE-FOY, QC',
|
||||||
|
title: 'KOCINA',
|
||||||
|
image: 'https://images.unsplash.com/photo-1487958449943-2429e8be8625?q=80&w=2070&auto=format&fit=crop',
|
||||||
|
category: 'Ticari'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'yamac-evi',
|
||||||
|
year: '2022',
|
||||||
|
location: 'MONT-TREMBLANT, QC',
|
||||||
|
title: 'YAMAÇ EVİ',
|
||||||
|
image: 'https://images.unsplash.com/photo-1497366216548-37526070297c?q=80&w=2070&auto=format&fit=crop',
|
||||||
|
category: 'Konut'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'makusham-studyo',
|
||||||
|
year: '2021',
|
||||||
|
location: 'LÉVIS, QC',
|
||||||
|
title: 'MAKUSHAM STÜDYO',
|
||||||
|
image: 'https://images.unsplash.com/photo-1431540015161-0bf868a2d407?q=80&w=2070&auto=format&fit=crop',
|
||||||
|
category: 'Kültürel'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'must-societe',
|
||||||
|
year: '2023',
|
||||||
|
location: 'BROSSARD, QC',
|
||||||
|
title: 'MUST SOCIÉTÉ',
|
||||||
|
image: 'https://images.unsplash.com/photo-1504384308090-c894fdcc538d?q=80&w=2070&auto=format&fit=crop',
|
||||||
|
category: 'Ticari'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Seeding projects...');
|
||||||
|
for (const project of projects) {
|
||||||
|
await prisma.project.upsert({
|
||||||
|
where: { slug: project.slug },
|
||||||
|
update: {},
|
||||||
|
create: project,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log('Seeding finished.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user