feat: integrate Cloudinary image upload, Instagram Feed, and fix Next.js warnings

This commit is contained in:
2026-06-09 21:28:45 +03:00
parent 2b3392dcb1
commit a4ad9344a0
52 changed files with 4300 additions and 263 deletions
+2
View File
@@ -39,3 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
/app/generated/prisma
+1 -1
View File
@@ -37,7 +37,7 @@ export default async function LocaleLayout({
return (
<html lang={locale} data-scroll-behavior="smooth" className={`${marcellus.variable}`}>
<body className="bg-sand text-midnight font-body antialiased relative">
<body suppressHydrationWarning className="bg-sand text-midnight font-body antialiased relative">
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
+33 -6
View File
@@ -9,6 +9,14 @@ import Gallery from '@/components/Gallery';
import Contact from '@/components/Contact';
import Footer from '@/components/Footer';
import WhatsAppButton from '@/components/WhatsAppButton';
import WelcomePopup from '@/components/WelcomePopup';
import { getEvents } from '@/app/actions/events';
import { getGalleryImages } from '@/app/actions/gallery';
import { getContactSettings } from '@/app/actions/contact';
import { getBeachSettings } from '@/app/actions/beach';
import { getAboutSettings } from '@/app/actions/about';
import { getInstagramPosts } from '@/app/actions/instagram';
import InstagramFeed from '@/components/InstagramFeed';
export default async function Page({
params
@@ -16,20 +24,39 @@ export default async function Page({
params: Promise<{ locale: string }>
}) {
const { locale } = await params;
const eventsResult = await getEvents();
const dbEvents = eventsResult.success ? eventsResult.data : [];
const galleryResult = await getGalleryImages();
const dbGallery = galleryResult.success ? galleryResult.data : [];
const contactResult = await getContactSettings();
const dbContact = contactResult.success ? contactResult.data : null;
const beachResult = await getBeachSettings();
const dbBeach = beachResult.success ? beachResult.data : null;
const aboutResult = await getAboutSettings();
const dbAbout = aboutResult.success ? aboutResult.data : null;
const instaResult = await getInstagramPosts();
const instaPosts = instaResult.success ? instaResult.data : [];
return (
<main className="min-h-screen bg-cream selection:bg-turquoise selection:text-white">
<Navbar locale={locale} />
<Hero />
<About />
<Accommodation />
<Beach />
<About dbAbout={dbAbout} />
<Beach dbBeach={dbBeach} />
<Dining />
<Events />
<Gallery />
<Contact />
<Events dbEvents={dbEvents} />
<Gallery dbGallery={dbGallery} />
<InstagramFeed posts={instaPosts} />
<Contact dbContact={dbContact} />
<Footer />
<WhatsAppButton />
<WelcomePopup />
</main>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function getAboutSettings() {
try {
let settings = await prisma.aboutSettings.findUnique({
where: { id: "default" }
});
if (!settings) {
settings = await prisma.aboutSettings.create({
data: {
id: "default",
title: "Sonsuz Mavilikte Bir Kaçış",
description: "Kozmos Beach & More, denizin esintisiyle doğanın ritmini bir araya getiren eşsiz bir deneyim sunuyor. Gündüz güneşin ve berrak denizin tadını çıkarırken, gün batımıyla birlikte DJ performansları ve özel kokteyller eşliğinde unutulmaz anılar biriktirin.",
card1Title: "Kusursuz Sahil",
card1Desc: "İncecik altın kumu ve turkuaz sularıyla kendinizi tamamen yenileyin.",
card2Title: "Gastronomi",
card2Desc: "Dünya mutfağından özenle seçilmiş lezzetler ve imza kokteyller.",
card3Title: "Canlı Eğlence",
card3Desc: "Her akşam gün batımında başlayan benzersiz DJ performansları.",
image: "https://images.unsplash.com/photo-1544148103-0773bf10d330?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
}
});
}
return { success: true, data: settings };
} catch (error) {
console.error("Error fetching about settings:", error);
return { success: false, error: "Hakkımızda ayarları alınırken bir hata oluştu." };
}
}
export async function updateAboutSettings(formData: FormData) {
try {
const title = formData.get("title") as string;
const description = formData.get("description") as string;
const card1Title = formData.get("card1Title") as string;
const card1Desc = formData.get("card1Desc") as string;
const card2Title = formData.get("card2Title") as string;
const card2Desc = formData.get("card2Desc") as string;
const card3Title = formData.get("card3Title") as string;
const card3Desc = formData.get("card3Desc") as string;
const image = formData.get("image") as string;
if (!title || !description || !card1Title || !card1Desc || !card2Title || !card2Desc || !card3Title || !card3Desc || !image) {
return { success: false, error: "Tüm alanların doldurulması zorunludur." };
}
await prisma.aboutSettings.upsert({
where: { id: "default" },
update: { title, description, card1Title, card1Desc, card2Title, card2Desc, card3Title, card3Desc, image },
create: { id: "default", title, description, card1Title, card1Desc, card2Title, card2Desc, card3Title, card3Desc, image }
});
revalidatePath("/admin/about");
revalidatePath("/");
revalidatePath("/tr");
revalidatePath("/en");
return { success: true };
} catch (error) {
console.error("Error updating about settings:", error);
return { success: false, error: "Hakkımızda ayarları güncellenirken bir hata oluştu." };
}
}
+103
View File
@@ -0,0 +1,103 @@
"use server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import { revalidatePath } from "next/cache";
export async function getAdmins() {
try {
const admins = await prisma.admin.findMany({
select: {
id: true,
name: true,
email: true,
createdAt: true,
updatedAt: true,
},
orderBy: { createdAt: 'desc' }
});
return { success: true, data: admins };
} catch (error) {
console.error("Error fetching admins:", error);
return { success: false, error: "Adminleri çekerken bir hata oluştu." };
}
}
export async function createAdmin(formData: FormData) {
try {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const password = formData.get("password") as string;
if (!name || !email || !password) {
return { success: false, error: "Lütfen tüm alanları doldurun." };
}
const existingAdmin = await prisma.admin.findUnique({
where: { email },
});
if (existingAdmin) {
return { success: false, error: "Bu e-posta adresi zaten kullanımda." };
}
const hashedPassword = await bcrypt.hash(password, 10);
await prisma.admin.create({
data: {
name,
email,
password: hashedPassword,
},
});
revalidatePath("/admin/admins");
return { success: true };
} catch (error) {
console.error("Error creating admin:", error);
return { success: false, error: "Admin oluşturulurken bir hata oluştu." };
}
}
export async function updateAdmin(id: string, formData: FormData) {
try {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const password = formData.get("password") as string;
if (!name || !email) {
return { success: false, error: "İsim ve e-posta zorunludur." };
}
const dataToUpdate: any = { name, email };
if (password) {
dataToUpdate.password = await bcrypt.hash(password, 10);
}
await prisma.admin.update({
where: { id },
data: dataToUpdate,
});
revalidatePath("/admin/admins");
return { success: true };
} catch (error) {
console.error("Error updating admin:", error);
return { success: false, error: "Admin güncellenirken bir hata oluştu." };
}
}
export async function deleteAdmin(id: string) {
try {
await prisma.admin.delete({
where: { id },
});
revalidatePath("/admin/admins");
return { success: true };
} catch (error) {
console.error("Error deleting admin:", error);
return { success: false, error: "Admin silinirken bir hata oluştu." };
}
}
+44
View File
@@ -0,0 +1,44 @@
"use server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import { signToken, setAuthCookie, removeAuthCookie } from "@/lib/auth";
import { redirect } from "next/navigation";
export async function login(formData: FormData) {
try {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
if (!email || !password) {
return { success: false, error: "Lütfen email ve şifre giriniz." };
}
const admin = await prisma.admin.findUnique({
where: { email },
});
if (!admin) {
return { success: false, error: "Geçersiz e-posta veya şifre." };
}
const isMatch = await bcrypt.compare(password, admin.password);
if (!isMatch) {
return { success: false, error: "Geçersiz e-posta veya şifre." };
}
// Başarılı giriş, token oluştur ve cookie'ye kaydet
const token = await signToken({ id: admin.id, email: admin.email, name: admin.name });
await setAuthCookie(token);
return { success: true };
} catch (error) {
console.error("Login error:", error);
return { success: false, error: "Giriş yapılırken beklenmeyen bir hata oluştu." };
}
}
export async function logout() {
await removeAuthCookie();
redirect("/admin/login");
}
+63
View File
@@ -0,0 +1,63 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function getBeachSettings() {
try {
let settings = await prisma.beachSettings.findUnique({
where: { id: "default" }
});
if (!settings) {
settings = await prisma.beachSettings.create({
data: {
id: "default",
title: "Ayrıcalıklı Bir Gevşeme Alanı",
description: "Tertemiz sularla buluşan özel plajımız, kendinizi tamamen yenileyebilmeniz için rahatlığın merkezinde. Altın kumların ve berrak denizin tadını çıkarırken, dünya standartlarındaki hizmetimizle her anınızın özel hissettirmesini sağlıyoruz.",
reservationInfo: "VIP cabana ve ön sıra şezlonglar yoğun talep görmektedir. Yeriniz garantilemek için en az bir gün önceden bizimle iletişime geçmenizi öneririz.",
image1: "https://images.unsplash.com/photo-1544148103-0773bf10d330?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
image2: "https://images.unsplash.com/photo-1519046904884-53103b34b206?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
image3: "https://images.unsplash.com/photo-1499793983690-e29da59ef1c2?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
image4: "https://images.unsplash.com/photo-1520250497591-112f2f40a3f4?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
}
});
}
return { success: true, data: settings };
} catch (error) {
console.error("Error fetching beach settings:", error);
return { success: false, error: "Plaj ayarları alınırken bir hata oluştu." };
}
}
export async function updateBeachSettings(formData: FormData) {
try {
const title = formData.get("title") as string;
const description = formData.get("description") as string;
const reservationInfo = formData.get("reservationInfo") as string;
const image1 = formData.get("image1") as string;
const image2 = formData.get("image2") as string;
const image3 = formData.get("image3") as string;
const image4 = formData.get("image4") as string;
if (!title || !description || !reservationInfo || !image1 || !image2 || !image3 || !image4) {
return { success: false, error: "Tüm alanların doldurulması zorunludur." };
}
await prisma.beachSettings.upsert({
where: { id: "default" },
update: { title, description, reservationInfo, image1, image2, image3, image4 },
create: { id: "default", title, description, reservationInfo, image1, image2, image3, image4 }
});
revalidatePath("/admin/beach");
revalidatePath("/");
revalidatePath("/tr");
revalidatePath("/en");
return { success: true };
} catch (error) {
console.error("Error updating beach settings:", error);
return { success: false, error: "Plaj ayarları güncellenirken bir hata oluştu." };
}
}
+57
View File
@@ -0,0 +1,57 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function getContactSettings() {
try {
let settings = await prisma.contactSettings.findUnique({
where: { id: "default" }
});
if (!settings) {
settings = await prisma.contactSettings.create({
data: {
id: "default",
address: "Akyaka, Gökova Körfezi, Muğla",
phone: "905000000000",
instagram: "https://www.instagram.com/kozmosbeach/",
mapUrl: "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d539.7850236154288!2d28.2165012!3d37.0413013527448!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x14bfa1006d98f019%3A0x85a41319cb3b8be!2sKozmos%20Beach%20%26%20More!5e1!3m2!1sen!2str!4v1781027409075!5m2!1sen!2str"
}
});
}
return { success: true, data: settings };
} catch (error) {
console.error("Error fetching contact settings:", error);
return { success: false, error: "İletişim ayarları alınırken bir hata oluştu." };
}
}
export async function updateContactSettings(formData: FormData) {
try {
const address = formData.get("address") as string;
const phone = formData.get("phone") as string;
const instagram = formData.get("instagram") as string;
const mapUrl = formData.get("mapUrl") as string;
if (!address || !phone || !instagram || !mapUrl) {
return { success: false, error: "Tüm alanların doldurulması zorunludur." };
}
await prisma.contactSettings.upsert({
where: { id: "default" },
update: { address, phone, instagram, mapUrl },
create: { id: "default", address, phone, instagram, mapUrl }
});
revalidatePath("/admin/contact");
revalidatePath("/");
revalidatePath("/tr");
revalidatePath("/en");
return { success: true };
} catch (error) {
console.error("Error updating contact settings:", error);
return { success: false, error: "İletişim ayarları güncellenirken bir hata oluştu." };
}
}
+104
View File
@@ -0,0 +1,104 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function getEvents() {
try {
const events = await prisma.event.findMany({
orderBy: { createdAt: 'desc' }
});
return { success: true, data: events };
} catch (error) {
console.error("Error fetching events:", error);
return { success: false, error: "Etkinlikleri çekerken bir hata oluştu." };
}
}
export async function createEvent(formData: FormData) {
try {
const title = formData.get("title") as string;
const time = formData.get("time") as string;
const tag = formData.get("tag") as string;
const iconType = formData.get("iconType") as string || "music";
const colorTheme = formData.get("colorTheme") as string || "cyan";
const isFeatured = formData.get("isFeatured") === "on";
if (!title || !time || !tag) {
return { success: false, error: "Başlık, zaman ve etiket alanları zorunludur." };
}
await prisma.event.create({
data: {
title,
time,
tag,
iconType,
colorTheme,
isFeatured,
},
});
revalidatePath("/admin/events");
revalidatePath("/");
revalidatePath("/tr");
revalidatePath("/en");
return { success: true };
} catch (error) {
console.error("Error creating event:", error);
return { success: false, error: "Etkinlik oluşturulurken bir hata oluştu." };
}
}
export async function updateEvent(id: string, formData: FormData) {
try {
const title = formData.get("title") as string;
const time = formData.get("time") as string;
const tag = formData.get("tag") as string;
const iconType = formData.get("iconType") as string || "music";
const colorTheme = formData.get("colorTheme") as string || "cyan";
const isFeatured = formData.get("isFeatured") === "on";
if (!title || !time || !tag) {
return { success: false, error: "Başlık, zaman ve etiket alanları zorunludur." };
}
await prisma.event.update({
where: { id },
data: {
title,
time,
tag,
iconType,
colorTheme,
isFeatured,
},
});
revalidatePath("/admin/events");
revalidatePath("/");
revalidatePath("/tr");
revalidatePath("/en");
return { success: true };
} catch (error) {
console.error("Error updating event:", error);
return { success: false, error: "Etkinlik güncellenirken bir hata oluştu." };
}
}
export async function deleteEvent(id: string) {
try {
await prisma.event.delete({
where: { id },
});
revalidatePath("/admin/events");
revalidatePath("/");
revalidatePath("/tr");
revalidatePath("/en");
return { success: true };
} catch (error) {
console.error("Error deleting event:", error);
return { success: false, error: "Etkinlik silinirken bir hata oluştu." };
}
}
+97
View File
@@ -0,0 +1,97 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function getGalleryImages() {
try {
const images = await prisma.galleryImage.findMany({
orderBy: [
{ order: 'asc' },
{ createdAt: 'desc' }
]
});
return { success: true, data: images };
} catch (error) {
console.error("Error fetching gallery images:", error);
return { success: false, error: "Galeri görsellerini çekerken bir hata oluştu." };
}
}
export async function createGalleryImage(formData: FormData) {
try {
const src = formData.get("src") as string;
const alt = formData.get("alt") as string || "";
const orderStr = formData.get("order") as string;
const order = orderStr ? parseInt(orderStr, 10) : 0;
if (!src) {
return { success: false, error: "Görsel URL'si zorunludur." };
}
await prisma.galleryImage.create({
data: {
src,
alt,
order,
},
});
revalidatePath("/admin/gallery");
revalidatePath("/");
revalidatePath("/tr");
revalidatePath("/en");
return { success: true };
} catch (error) {
console.error("Error creating gallery image:", error);
return { success: false, error: "Galeri görseli eklenirken bir hata oluştu." };
}
}
export async function updateGalleryImage(id: string, formData: FormData) {
try {
const src = formData.get("src") as string;
const alt = formData.get("alt") as string || "";
const orderStr = formData.get("order") as string;
const order = orderStr ? parseInt(orderStr, 10) : 0;
if (!src) {
return { success: false, error: "Görsel URL'si zorunludur." };
}
await prisma.galleryImage.update({
where: { id },
data: {
src,
alt,
order,
},
});
revalidatePath("/admin/gallery");
revalidatePath("/");
revalidatePath("/tr");
revalidatePath("/en");
return { success: true };
} catch (error) {
console.error("Error updating gallery image:", error);
return { success: false, error: "Galeri görseli güncellenirken bir hata oluştu." };
}
}
export async function deleteGalleryImage(id: string) {
try {
await prisma.galleryImage.delete({
where: { id },
});
revalidatePath("/admin/gallery");
revalidatePath("/");
revalidatePath("/tr");
revalidatePath("/en");
return { success: true };
} catch (error) {
console.error("Error deleting gallery image:", error);
return { success: false, error: "Galeri görseli silinirken bir hata oluştu." };
}
}
+68
View File
@@ -0,0 +1,68 @@
"use server";
export async function getInstagramPosts() {
try {
const response = await fetch('https://instagram120.p.rapidapi.com/api/instagram/posts?v=1', {
method: 'POST',
headers: {
'x-rapidapi-key': process.env.RAPIDAPI_KEY || '762dc41c1dmshc120ecf29aa240ap11dc66jsn87f410b870f5',
'x-rapidapi-host': 'instagram120.p.rapidapi.com',
'Content-Type': 'application/json'
},
body: JSON.stringify({ username: 'kozmosbeach', maxId: '' }),
// CRITICAL ADIM: Veriyi 24 saat boyunca önbellekte tut (Saniye cinsinden: 24 * 60 * 60)
next: { revalidate: 86400 }
});
if (!response.ok) {
throw new Error("Instagram API request failed");
}
const data = await response.json();
// The RapidAPI endpoint usually returns an array of posts or { data: { items: [...] } }
// Based on standard Instagram120 response, it returns { result: { edges: [{node: {}}] } }
let posts = [];
if (data && data.result && data.result.edges && Array.isArray(data.result.edges)) {
posts = data.result.edges.map((edge: any) => edge.node);
} else if (data && Array.isArray(data)) {
posts = data;
} else if (data && data.data && Array.isArray(data.data)) {
posts = data.data;
} else if (data && data.items && Array.isArray(data.items)) {
posts = data.items;
}
// Map to a cleaner format and take first 4
const formattedPosts = posts.slice(0, 4).map((post: any) => {
// Instagram API has multiple variants for post schema
const id = post.id || post.pk;
const code = post.code;
// Try to find the highest res image
let imageUrl = "";
if (post.image_versions2 && post.image_versions2.candidates) {
imageUrl = post.image_versions2.candidates[0].url;
} else if (post.carousel_media && post.carousel_media.length > 0) {
imageUrl = post.carousel_media[0].image_versions2.candidates[0].url;
} else if (post.display_url) {
imageUrl = post.display_url;
}
const caption = post.caption?.text || post.caption || "";
const url = code ? `https://instagram.com/p/${code}` : `https://instagram.com/kozmosbeach`;
return {
id,
imageUrl,
url,
caption
};
});
return { success: true, data: formattedPosts };
} catch (error) {
console.error("Error fetching Instagram posts:", error);
return { success: false, error: "Instagram gönderileri alınamadı." };
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Marcellus } from 'next/font/google';
import '@/app/globals.css';
const marcellus = Marcellus({
subsets: ['latin'],
variable: '--font-marcellus',
weight: ['400'],
});
export const metadata = {
title: 'Kozmos Admin - Giriş Yap',
description: 'Kozmos Beach & More - Yönetim Paneli Girişi',
};
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="tr" className={`${marcellus.variable}`}>
<body className="bg-gray-50 text-gray-900 font-body antialiased min-h-screen">
{children}
</body>
</html>
);
}
+13
View File
@@ -0,0 +1,13 @@
import LoginClient from "@/components/admin/LoginClient";
import { getAuthCookie } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function LoginPage() {
const admin = await getAuthCookie();
if (admin) {
redirect("/admin");
}
return <LoginClient />;
}
+22
View File
@@ -0,0 +1,22 @@
import { getAboutSettings } from "@/app/actions/about";
import AboutClient from "@/components/admin/AboutClient";
export const dynamic = "force-dynamic";
export default async function AboutPage() {
const result = await getAboutSettings();
const settings = result.success && result.data ? result.data : {
title: "Sonsuz Mavilikte Bir Kaçış",
description: "Kozmos Beach & More, denizin esintisiyle doğanın ritmini bir araya getiren eşsiz bir deneyim sunuyor. Gündüz güneşin ve berrak denizin tadını çıkarırken, gün batımıyla birlikte DJ performansları ve özel kokteyller eşliğinde unutulmaz anılar biriktirin.",
card1Title: "Kusursuz Sahil",
card1Desc: "İncecik altın kumu ve turkuaz sularıyla kendinizi tamamen yenileyin.",
card2Title: "Gastronomi",
card2Desc: "Dünya mutfağından özenle seçilmiş lezzetler ve imza kokteyller.",
card3Title: "Canlı Eğlence",
card3Desc: "Her akşam gün batımında başlayan benzersiz DJ performansları.",
image: "https://images.unsplash.com/photo-1544148103-0773bf10d330?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
};
return <AboutClient settings={settings as any} />;
}
+12
View File
@@ -0,0 +1,12 @@
import { getAdmins } from "@/app/actions/admin";
import AdminsClient from "@/components/admin/AdminsClient";
// Vercel / Next.js için dinamik rendering (cache'lemeyi kapatmak için)
export const dynamic = "force-dynamic";
export default async function AdminsPage() {
const result = await getAdmins();
const admins = result.success && result.data ? result.data : [];
return <AdminsClient initialAdmins={admins} />;
}
+21
View File
@@ -0,0 +1,21 @@
import { getBeachSettings } from "@/app/actions/beach";
import BeachClient from "@/components/admin/BeachClient";
export const dynamic = "force-dynamic";
export default async function BeachPage() {
const result = await getBeachSettings();
// result.data will always be populated if success because we upsert "default"
const settings = result.success && result.data ? result.data : {
title: "Ayrıcalıklı Bir Gevşeme Alanı",
description: "Tertemiz sularla buluşan özel plajımız, kendinizi tamamen yenileyebilmeniz için rahatlığın merkezinde. Altın kumların ve berrak denizin tadını çıkarırken, dünya standartlarındaki hizmetimizle her anınızın özel hissettirmesini sağlıyoruz.",
reservationInfo: "VIP cabana ve ön sıra şezlonglar yoğun talep görmektedir. Yeriniz garantilemek için en az bir gün önceden bizimle iletişime geçmenizi öneririz.",
image1: "https://images.unsplash.com/photo-1544148103-0773bf10d330?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
image2: "https://images.unsplash.com/photo-1519046904884-53103b34b206?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
image3: "https://images.unsplash.com/photo-1499793983690-e29da59ef1c2?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
image4: "https://images.unsplash.com/photo-1520250497591-112f2f40a3f4?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
};
return <BeachClient settings={settings as any} />;
}
+18
View File
@@ -0,0 +1,18 @@
import { getContactSettings } from "@/app/actions/contact";
import ContactClient from "@/components/admin/ContactClient";
export const dynamic = "force-dynamic";
export default async function ContactPage() {
const result = await getContactSettings();
// result.data will always be populated if success because we upsert "default"
const settings = result.success && result.data ? result.data : {
address: "Akyaka, Gökova Körfezi, Muğla",
phone: "905000000000",
instagram: "https://www.instagram.com/kozmosbeach/",
mapUrl: "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d12754.717654763133!2d28.3144!3d36.9507!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x14bf76ccf312ba37%3A0xcdaaa97b102b4bb7!2sAkyaka%2C%20Ula%2FMu%C4%9Fla!5e0!3m2!1str!2str!4v1700000000000!5m2!1str!2str"
};
return <ContactClient settings={settings as any} />;
}
+18
View File
@@ -0,0 +1,18 @@
import { getEvents } from "@/app/actions/events";
import EventsClient from "@/components/admin/EventsClient";
export const dynamic = "force-dynamic";
export default async function EventsPage() {
const result = await getEvents();
const events = result.success && result.data ? result.data : [];
// Data parsing for Client Component
const parsedEvents = events.map(e => ({
...e,
createdAt: e.createdAt.toISOString(),
updatedAt: e.updatedAt.toISOString(),
}));
return <EventsClient initialEvents={parsedEvents as any} />;
}
+18
View File
@@ -0,0 +1,18 @@
import { getGalleryImages } from "@/app/actions/gallery";
import GalleryClient from "@/components/admin/GalleryClient";
export const dynamic = "force-dynamic";
export default async function GalleryPage() {
const result = await getGalleryImages();
const images = result.success && result.data ? result.data : [];
// Data parsing for Client Component
const parsedImages = images.map(img => ({
...img,
createdAt: img.createdAt.toISOString(),
updatedAt: img.updatedAt.toISOString(),
}));
return <GalleryClient initialImages={parsedImages as any} />;
}
+124
View File
@@ -0,0 +1,124 @@
import { Marcellus } from 'next/font/google';
import '@/app/globals.css';
import Link from 'next/link';
import { Users, LayoutDashboard, LogOut } from 'lucide-react';
const marcellus = Marcellus({
subsets: ['latin'],
variable: '--font-marcellus',
weight: ['400'],
});
export const metadata = {
title: 'Kozmos Admin',
description: 'Kozmos Beach & More - Yönetim Paneli',
};
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="tr" className={`${marcellus.variable}`}>
<body className="bg-gray-50 text-gray-900 font-body antialiased flex min-h-screen">
{/* Sidebar */}
<aside className="w-64 bg-charcoal text-cream flex flex-col shadow-2xl">
{/* Header */}
<div className="p-6 border-b border-gray-700 flex items-center justify-center">
<img src="/logo.png" alt="Kozmos Logo" className="h-12 brightness-0 invert opacity-90 object-contain" />
</div>
<nav className="flex-1 p-4 space-y-2">
<Link
href="/admin"
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-800 transition-colors"
>
<LayoutDashboard className="w-5 h-5" />
<span>Dashboard</span>
</Link>
<Link
href="/admin/admins"
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-800 transition-colors"
>
<Users className="w-5 h-5" />
<span>Admin Yönetimi</span>
</Link>
<Link
href="/admin/events"
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-800 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
<span>Etkinlik Yönetimi</span>
</Link>
<Link
href="/admin/about"
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-800 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
<span>Hakkımızda Yönetimi</span>
</Link>
<Link
href="/admin/beach"
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-800 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M12 2v20"></path><path d="M2 12h20"></path></svg>
<span>Plaj Yönetimi</span>
</Link>
<Link
href="/admin/gallery"
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-800 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>
<span>Galeri Yönetimi</span>
</Link>
<Link
href="/admin/contact"
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-800 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3"></circle></svg>
<span>İletişim & Konum</span>
</Link>
</nav>
<div className="p-4 border-t border-gray-700 space-y-2">
<a
href="/"
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-800 transition-colors text-gray-400 hover:text-white"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
<span>Siteye Git</span>
</a>
<form action={async () => {
"use server";
const { logout } = await import("@/app/actions/auth");
await logout();
}}>
<button
type="submit"
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-red-900/50 transition-colors text-red-400 hover:text-red-300"
>
<LogOut className="w-5 h-5" />
<span>Çıkış Yap</span>
</button>
</form>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 overflow-y-auto">
{children}
</main>
</body>
</html>
);
}
+17
View File
@@ -0,0 +1,17 @@
export default function AdminDashboard() {
return (
<div className="p-8">
<h1 className="text-3xl font-serif text-gray-900 mb-8">Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
<h3 className="text-gray-500 text-sm font-medium mb-2">Sisteme Hoş Geldiniz</h3>
<p className="text-2xl font-bold text-gray-900">Kozmos Yönetim Paneli</p>
<p className="text-sm text-gray-500 mt-4">
Sol menüden "Admin Yönetimi" kısmına giderek yeni yönetici ekleyebilir veya silebilirsiniz.
</p>
</div>
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { v2 as cloudinary } from "cloudinary";
import { NextResponse } from "next/server";
cloudinary.config({
cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
});
export async function POST(request: Request) {
const body = await request.json();
const { paramsToSign } = body;
const signature = cloudinary.utils.api_sign_request(
paramsToSign,
process.env.CLOUDINARY_API_SECRET as string
);
return NextResponse.json({ signature });
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+5
View File
@@ -19,6 +19,7 @@
--color-turquoise: #00BFA5;
--color-forest: #0E1B30;
--color-dark-brown: #080D18;
--color-charcoal: #111827;
--font-heading: var(--font-marcellus);
--font-body: var(--font-marcellus);
@@ -38,6 +39,10 @@ html {
scroll-behavior: smooth;
}
html[data-scroll-behavior="smooth"] {
scroll-behavior: smooth;
}
/* ── CSS custom property for animated border ──── */
@property --border-angle {
syntax: '<angle>';
+34 -8
View File
@@ -11,9 +11,34 @@ const features = [
{ icon: Music, titleKey: 'card3_title', descKey: 'card3_desc', color: 'text-coral', bg: 'bg-coral/10' },
];
export default function About() {
export default function About({ dbAbout }: { dbAbout?: any }) {
const t = useTranslations('About');
const titleVal = dbAbout?.title || t('title');
const descVal = dbAbout?.description || t('description');
const imgUrl = dbAbout?.image || "https://picsum.photos/800/1100?random=2";
const featuresList = [
{
icon: Waves,
title: dbAbout?.card1Title || t('card1_title'),
desc: dbAbout?.card1Desc || t('card1_desc'),
color: 'text-aqua', bg: 'bg-aqua/10'
},
{
icon: Leaf,
title: dbAbout?.card2Title || t('card2_title'),
desc: dbAbout?.card2Desc || t('card2_desc'),
color: 'text-amber', bg: 'bg-amber/10'
},
{
icon: Music,
title: dbAbout?.card3Title || t('card3_title'),
desc: dbAbout?.card3Desc || t('card3_desc'),
color: 'text-coral', bg: 'bg-coral/10'
},
];
return (
<section id="about" className="py-28 bg-sand text-midnight relative">
<div className="container mx-auto px-6 lg:px-12">
@@ -32,15 +57,15 @@ export default function About() {
Beach &amp; More
</span>
<h2 className="font-heading text-5xl md:text-6xl font-black leading-tight text-midnight mb-6">
{t('title')}
{titleVal}
</h2>
<p className="text-lg leading-relaxed text-midnight/60">{t('description')}</p>
<p className="text-lg leading-relaxed text-midnight/60 whitespace-pre-wrap">{descVal}</p>
</div>
<div className="space-y-2">
{features.map(({ icon: Icon, titleKey, descKey, color, bg }, i) => (
{featuresList.map(({ icon: Icon, title, desc, color, bg }, i) => (
<motion.div
key={titleKey}
key={title}
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
@@ -51,8 +76,8 @@ export default function About() {
<Icon size={20} />
</div>
<div>
<h3 className="font-heading text-lg font-bold mb-0.5">{t(titleKey)}</h3>
<p className="text-midnight/55 text-sm leading-relaxed">{t(descKey)}</p>
<h3 className="font-heading text-lg font-bold mb-0.5">{title}</h3>
<p className="text-midnight/55 text-sm leading-relaxed">{desc}</p>
</div>
</motion.div>
))}
@@ -74,9 +99,10 @@ export default function About() {
/>
<div className="relative h-[560px] w-full rounded-3xl overflow-hidden shadow-2xl">
<Image
src="https://picsum.photos/800/1100?random=2"
src={imgUrl}
alt="Kozmos atmosferi"
fill
priority
sizes="(max-width: 1024px) 100vw, 50vw"
className="object-cover"
/>
+24 -14
View File
@@ -5,9 +5,18 @@ import { motion } from 'framer-motion';
import Image from 'next/image';
import { Sun } from 'lucide-react';
export default function Beach() {
export default function Beach({ dbBeach }: { dbBeach?: any }) {
const t = useTranslations('Beach');
const titleVal = dbBeach?.title || t('title');
const descVal = dbBeach?.description || t('description');
const resInfoVal = dbBeach?.reservationInfo || t('reservation_info');
const img1 = dbBeach?.image1 || "https://picsum.photos/400/600?random=10";
const img2 = dbBeach?.image2 || "https://picsum.photos/400/400?random=11";
const img3 = dbBeach?.image3 || "https://picsum.photos/400/400?random=12";
const img4 = dbBeach?.image4 || "https://picsum.photos/400/600?random=13";
const openWhatsApp = () => {
const phone = process.env.NEXT_PUBLIC_WHATSAPP_NUMBER || '905000000000';
window.open(
@@ -37,19 +46,20 @@ export default function Beach() {
>
<div className="relative h-64 rounded-2xl overflow-hidden shadow-md">
<Image
src="https://picsum.photos/400/600?random=10"
alt="Plaj"
src={img1}
alt="Plaj 1"
fill
sizes="(max-width: 1024px) 50vw, 25vw"
priority
sizes="(max-width: 768px) 100vw, 50vw"
className="object-cover hover:scale-105 transition-transform duration-700"
/>
</div>
<div className="relative h-48 rounded-2xl overflow-hidden shadow-md">
<Image
src="https://picsum.photos/400/400?random=11"
alt="Şezlong"
src={img2}
alt="Plaj 2"
fill
sizes="(max-width: 1024px) 50vw, 25vw"
sizes="(max-width: 768px) 100vw, 50vw"
className="object-cover hover:scale-105 transition-transform duration-700"
/>
</div>
@@ -64,16 +74,16 @@ export default function Beach() {
>
<div className="relative h-48 rounded-2xl overflow-hidden shadow-md">
<Image
src="https://picsum.photos/400/400?random=12"
alt="Deniz"
src={img3}
alt="Plaj 3"
fill
sizes="(max-width: 1024px) 50vw, 25vw"
sizes="(max-width: 768px) 100vw, 50vw"
className="object-cover hover:scale-105 transition-transform duration-700"
/>
</div>
<div className="relative h-64 rounded-2xl overflow-hidden shadow-md">
<Image
src="https://picsum.photos/400/600?random=13"
src={img4}
alt="Koy"
fill
sizes="(max-width: 1024px) 50vw, 25vw"
@@ -97,10 +107,10 @@ export default function Beach() {
</div>
<h2 className="font-heading text-5xl md:text-6xl font-black text-midnight leading-tight">
{t('title')}
{titleVal}
</h2>
<p className="text-lg text-midnight/60 leading-relaxed">{t('description')}</p>
<p className="text-lg text-midnight/60 leading-relaxed whitespace-pre-wrap">{descVal}</p>
{/* Stats */}
<div className="grid grid-cols-3 gap-4 py-6 border-y border-sandy">
@@ -114,7 +124,7 @@ export default function Beach() {
{/* Info & CTA */}
<div className="p-6 rounded-2xl bg-sandy/50 border border-sandy">
<p className="text-midnight/70 text-sm font-medium mb-5">{t('reservation_info')}</p>
<p className="text-midnight/70 text-sm font-medium mb-5">{resInfoVal}</p>
<button
onClick={openWhatsApp}
className="px-8 py-3.5 rounded-full font-black text-sm text-midnight transition-all duration-300 hover:scale-105 hover:shadow-lg"
+13 -7
View File
@@ -6,13 +6,19 @@ import { MapPin, Phone, Send } from 'lucide-react';
import { Instagram } from '@/components/InstagramIcon';
import { useState } from 'react';
export default function Contact() {
export default function Contact({ dbContact }: { dbContact?: any }) {
const t = useTranslations('Contact');
const [formData, setFormData] = useState({ name: '', date: '', guests: '', message: '' });
// Fallback to env or translations if dbContact is empty
const phoneVal = dbContact?.phone || process.env.NEXT_PUBLIC_WHATSAPP_NUMBER || '905000000000';
const addressVal = dbContact?.address || t('address');
const instagramVal = dbContact?.instagram || 'https://www.instagram.com/kozmosbeach/';
const mapUrlVal = dbContact?.mapUrl || "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d12754.717654763133!2d28.3144!3d36.9507!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x14bf76ccf312ba37%3A0xcdaaa97b102b4bb7!2sAkyaka%2C%20Ula%2FMu%C4%9Fla!5e0!3m2!1str!2str!4v1700000000000!5m2!1str!2str";
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const phone = process.env.NEXT_PUBLIC_WHATSAPP_NUMBER || '905000000000';
const phone = phoneVal.replace(/[^0-9]/g, '');
const text = `*Yeni Rezervasyon Talebi*%0A%0A*Ad Soyad:* ${formData.name}%0A*Tarih:* ${formData.date}%0A*Kişi Sayısı:* ${formData.guests}%0A*Mesaj:* ${formData.message}`;
window.open(`https://wa.me/${phone}?text=${text}`, '_blank');
};
@@ -25,22 +31,22 @@ export default function Contact() {
{
Icon: MapPin,
label: 'Adres',
value: t('address'),
value: addressVal,
color: 'text-coral',
bg: 'bg-coral/10',
},
{
Icon: Phone,
label: 'Telefon / WhatsApp',
value: `+${process.env.NEXT_PUBLIC_WHATSAPP_NUMBER || '90 500 000 00 00'}`,
value: `+${phoneVal}`,
color: 'text-aqua',
bg: 'bg-aqua/10',
},
{
Icon: Instagram,
label: 'Instagram',
value: '@kozmosbeach',
href: 'https://www.instagram.com/kozmosbeach/',
value: instagramVal.replace('https://www.instagram.com/', '@').replace('/', ''),
href: instagramVal,
color: 'text-amber',
bg: 'bg-amber/10',
},
@@ -183,7 +189,7 @@ export default function Contact() {
className="min-h-[420px] lg:min-h-[600px] w-full rounded-3xl overflow-hidden shadow-2xl"
>
<iframe
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d12754.717654763133!2d28.3144!3d36.9507!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x14bf76ccf312ba37%3A0xcdaaa97b102b4bb7!2sAkyaka%2C%20Ula%2FMu%C4%9Fla!5e0!3m2!1str!2str!4v1700000000000!5m2!1str!2str"
src={mapUrlVal}
width="100%"
height="100%"
style={{ border: 0 }}
+188 -155
View File
@@ -2,7 +2,7 @@
import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion';
import { Calendar, Music, Disc3, CalendarDays, Zap } from 'lucide-react';
import { Calendar, Music, Disc3, CalendarDays, Zap, GlassWater } from 'lucide-react';
/* ── static data ───────────────────────────────── */
@@ -13,6 +13,17 @@ const ACTIVE_DAYS: Record<number, string> = {
5: '#00E5FF',
};
/* ── types ─────────────────────────────────────── */
type EventItem = {
id: string;
title: string;
time: string;
tag: string;
iconType: string;
colorTheme: string;
isFeatured: boolean;
};
/* ── sub-components ────────────────────────────── */
function VinylDisc() {
@@ -92,9 +103,72 @@ function WineGlassIcon() {
/* ── main component ────────────────────────────── */
export default function Events() {
export default function Events({ dbEvents = [] }: { dbEvents?: any[] }) {
const t = useTranslations('Events');
// Hardcoded fallback data if db is empty
const defaultEvents: EventItem[] = dbEvents.length > 0 ? dbEvents : [
{
id: "fallback-1",
title: t('cards.dj.title'),
time: t('cards.dj.time'),
tag: "EN POPÜLER",
iconType: "dj",
colorTheme: "cyan",
isFeatured: true,
},
{
id: "fallback-2",
title: t('cards.raki.title'),
time: t('cards.raki.time'),
tag: "PER",
iconType: "glass",
colorTheme: "coral",
isFeatured: false,
},
{
id: "fallback-3",
title: t('cards.live_music.title'),
time: t('cards.live_music.time'),
tag: "★ ÖZEL",
iconType: "music",
colorTheme: "amber",
isFeatured: false,
}
];
const featuredEvent = defaultEvents.find(e => e.isFeatured) || defaultEvents[0];
const secondaryEvents = defaultEvents.filter(e => e.id !== featuredEvent.id);
// Tema renklerini almak için yardımcı fonksiyon
const getThemeColor = (theme: string) => {
switch (theme) {
case 'cyan': return '#00E5FF';
case 'coral': return '#FF5A36';
case 'amber': return '#FFA827';
default: return '#00E5FF';
}
};
const getThemeColors = (theme: string) => {
switch (theme) {
case 'cyan': return { main: '#00E5FF', bg: '#00E5FF1A', borderSpin: 'border-spin-cyan', darkBg: '#0C1828' };
case 'coral': return { main: '#FF5A36', bg: '#FF5A361A', borderSpin: 'border-spin-coral', darkBg: '#0C0808', hoverBg: '#110a0a' };
case 'amber': return { main: '#FFA827', bg: '#FFA8271A', borderSpin: 'border-spin-amber', darkBg: '#0C0A04', hoverBg: '#110d04' };
default: return { main: '#00E5FF', bg: '#00E5FF1A', borderSpin: 'border-spin-cyan', darkBg: '#0C1828', hoverBg: '#0C1828' };
}
};
// İkon bileşenini render etme yardımcı fonksiyonu
const renderIcon = (type: string, size: number = 22) => {
switch(type) {
case 'dj': return <Disc3 size={size} />;
case 'glass': return <WineGlassIcon />;
case 'music': return <Music size={size} />;
default: return <Music size={size} />;
}
};
return (
<section
id="events"
@@ -192,167 +266,126 @@ export default function Events() {
})}
</motion.div>
{/* ── Featured Event: DJ Nights ────────────── */}
<motion.div
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.7 }}
className="border-spin-cyan rounded-3xl p-px overflow-hidden"
>
<div
className="rounded-3xl p-8 md:p-10 flex flex-col md:flex-row items-center gap-8 overflow-hidden relative"
style={{ background: '#0C1828' }}
>
{/* Glow center */}
<div
className="absolute inset-0 pointer-events-none"
style={{
background:
'radial-gradient(ellipse at 80% 50%, rgba(0,229,255,0.08) 0%, transparent 60%)',
}}
/>
{/* ── Featured Event (Dinamic) ────────────── */}
{featuredEvent && (() => {
const c = getThemeColors(featuredEvent.colorTheme);
return (
<motion.div
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.7 }}
className={`${c.borderSpin} rounded-3xl p-px overflow-hidden`}
>
<div
className="rounded-3xl p-8 md:p-10 flex flex-col md:flex-row items-center gap-8 overflow-hidden relative"
style={{ background: c.darkBg }}
>
{/* Glow center */}
<div
className="absolute inset-0 pointer-events-none"
style={{
background: `radial-gradient(ellipse at 80% 50%, ${c.main}14 0%, transparent 60%)`,
}}
/>
{/* Left: text */}
<div className="flex-1 relative z-10">
<div className="flex items-center gap-3 mb-5">
<span
className="text-[10px] font-black uppercase tracking-[0.35em] px-3 py-1.5 rounded-full"
style={{ background: '#00E5FF1A', color: '#00E5FF' }}
>
EN POPÜLER
</span>
<div className="flex items-center gap-1.5 text-white/40 text-xs">
<Calendar size={11} />
<span>{t('cards.dj.time')}</span>
{/* Left: text */}
<div className="flex-1 relative z-10">
<div className="flex items-center gap-3 mb-5">
<span
className="text-[10px] font-black uppercase tracking-[0.35em] px-3 py-1.5 rounded-full"
style={{ background: c.bg, color: c.main }}
>
{featuredEvent.tag}
</span>
<div className="flex items-center gap-1.5 text-white/40 text-xs">
<Calendar size={11} />
<span>{featuredEvent.time}</span>
</div>
</div>
<h3 className="font-heading text-4xl md:text-5xl font-black mb-3 text-white leading-tight">
{featuredEvent.title}
</h3>
<div className="flex items-center gap-3 mb-7">
<div style={{ color: c.main }}>
{renderIcon(featuredEvent.iconType, 18)}
</div>
<span className="text-white/50 text-sm">Open-air · Beach Stage</span>
</div>
<EqBars color={c.main} />
</div>
{/* Right: vinyl */}
<div className="relative z-10 flex-shrink-0">
<VinylDisc />
</div>
</div>
</motion.div>
);
})()}
<h3 className="font-heading text-4xl md:text-5xl font-black mb-3 text-white leading-tight">
{t('cards.dj.title')}
</h3>
{/* ── Secondary events: 2-col grid (Dinamic) ─────────── */}
{secondaryEvents.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{secondaryEvents.map((evt, i) => {
const c = getThemeColors(evt.colorTheme);
return (
<motion.div
key={evt.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.1 * (i + 1) }}
className={`${c.borderSpin} rounded-3xl p-px group cursor-pointer`}
>
<div
className="rounded-3xl p-7 h-full flex flex-col gap-5 relative overflow-hidden transition-colors duration-300"
style={{ background: c.darkBg }}
>
<div
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-400 pointer-events-none"
style={{
background: `radial-gradient(ellipse at 20% 50%, ${c.main}1A 0%, transparent 65%)`,
}}
/>
<div className="flex items-center gap-3 mb-7">
<Disc3 size={18} style={{ color: '#00E5FF' }} />
<span className="text-white/50 text-sm">Open-air · Beach Stage</span>
</div>
<div className="flex items-start justify-between">
<div
className="w-12 h-12 rounded-2xl flex items-center justify-center"
style={{ background: c.bg, color: c.main }}
>
{renderIcon(evt.iconType, 22)}
</div>
<span
className="text-[10px] font-black uppercase tracking-wider px-2.5 py-1 rounded-full"
style={{ background: c.bg, color: c.main }}
>
{evt.tag}
</span>
</div>
<EqBars color="#00E5FF" />
</div>
<div>
<h3 className="font-heading text-2xl font-black text-white mb-2">
{evt.title}
</h3>
<div className="flex items-center gap-1.5 text-sm text-white/40">
<Calendar size={12} />
<span>{evt.time}</span>
</div>
</div>
{/* Right: vinyl */}
<div className="relative z-10 flex-shrink-0">
<VinylDisc />
</div>
<div className="mt-auto opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<EqBars color={c.main} />
</div>
</div>
</motion.div>
);
})}
</div>
</motion.div>
{/* ── Secondary events: 2-col grid ─────────── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Rakı Geceleri */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.1 }}
className="border-spin-coral rounded-3xl p-px group cursor-pointer"
>
<div
className="rounded-3xl p-7 h-full flex flex-col gap-5 relative overflow-hidden transition-colors duration-300 group-hover:bg-[#110a0a]"
style={{ background: '#0C0808' }}
>
<div
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-400 pointer-events-none"
style={{
background:
'radial-gradient(ellipse at 20% 50%, rgba(255,90,54,0.1) 0%, transparent 65%)',
}}
/>
<div className="flex items-start justify-between">
<div
className="w-12 h-12 rounded-2xl flex items-center justify-center"
style={{ background: '#FF5A361A', color: '#FF5A36' }}
>
<WineGlassIcon />
</div>
<span
className="text-[10px] font-black uppercase tracking-wider px-2.5 py-1 rounded-full"
style={{ background: '#FF5A361A', color: '#FF5A36' }}
>
PER
</span>
</div>
<div>
<h3 className="font-heading text-2xl font-black text-white mb-2">
{t('cards.raki.title')}
</h3>
<div className="flex items-center gap-1.5 text-sm text-white/40">
<Calendar size={12} />
<span>{t('cards.raki.time')}</span>
</div>
</div>
<div className="mt-auto opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<EqBars color="#FF5A36" />
</div>
</div>
</motion.div>
{/* Canlı Müzik */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.2 }}
className="border-spin-amber rounded-3xl p-px group cursor-pointer"
>
<div
className="rounded-3xl p-7 h-full flex flex-col gap-5 relative overflow-hidden transition-colors duration-300 group-hover:bg-[#110d04]"
style={{ background: '#0C0A04' }}
>
<div
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-400 pointer-events-none"
style={{
background:
'radial-gradient(ellipse at 80% 50%, rgba(255,168,39,0.1) 0%, transparent 65%)',
}}
/>
<div className="flex items-start justify-between">
<div
className="w-12 h-12 rounded-2xl flex items-center justify-center"
style={{ background: '#FFA8271A', color: '#FFA827' }}
>
<Music size={22} />
</div>
<span
className="text-[10px] font-black uppercase tracking-wider px-2.5 py-1 rounded-full"
style={{ background: '#FFA8271A', color: '#FFA827' }}
>
ÖZEL
</span>
</div>
<div>
<h3 className="font-heading text-2xl font-black text-white mb-2">
{t('cards.live_music.title')}
</h3>
<div className="flex items-center gap-1.5 text-sm text-white/40">
<Calendar size={12} />
<span>{t('cards.live_music.time')}</span>
</div>
</div>
<div className="mt-auto opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<EqBars color="#FFA827" />
</div>
</div>
</motion.div>
</div>
)}
</div>
{/* Wave → sand */}
+10 -8
View File
@@ -3,6 +3,7 @@
import { useTranslations } from 'next-intl';
import { Instagram } from '@/components/InstagramIcon';
import { Link, usePathname } from '@/i18n/routing';
import Image from 'next/image';
export default function Footer() {
const t = useTranslations('Footer');
@@ -38,14 +39,15 @@ export default function Footer() {
<div className="flex flex-col md:flex-row justify-between items-start gap-12 mb-12">
{/* Brand */}
<div>
<Link href="/" className="inline-flex flex-col mb-3">
<span className="font-heading text-5xl font-black uppercase tracking-tighter gradient-text-warm">
Kozmos
</span>
<span className="text-[10px] tracking-[0.5em] font-body uppercase text-white/30 mt-0.5">
Beach &amp; More
</span>
<div className="relative w-40 h-24">
<Link href="/" className="inline-flex flex-col mb-4 transform transition-transform hover:scale-105">
<Image
src="/logo.png"
alt="Kozmos Logo"
fill
sizes="(max-width: 768px) 120px, 160px"
className="object-contain brightness-0 invert opacity-100 drop-shadow-lg"
/>
</Link>
<p className="text-white/35 text-sm mt-1">{t('tagline')}</p>
</div>
+4 -3
View File
@@ -6,11 +6,12 @@ import { Instagram } from '@/components/InstagramIcon';
import { useState, useEffect } from 'react';
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
export default function Gallery() {
export default function Gallery({ dbGallery = [] }: { dbGallery?: any[] }) {
const t = useTranslations('Gallery');
const images = Array.from({ length: 12 }).map((_, i) => ({
id: i,
// Fallback to placeholder images if database is empty
const images = dbGallery.length > 0 ? dbGallery : Array.from({ length: 12 }).map((_, i) => ({
id: `fallback-${i}`,
src: `https://picsum.photos/600/${400 + (i % 5) * 50}?random=${30 + i}`,
alt: `Gallery ${i + 1}`,
}));
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { motion } from "framer-motion";
import { Instagram } from "@/components/InstagramIcon";
type InstaPost = {
id: string;
imageUrl: string;
url: string;
caption: string;
};
export default function InstagramFeed({ posts }: { posts: InstaPost[] }) {
if (!posts || posts.length === 0) {
return null; // Eğer post yoksa veya API hata verdiyse gizle
}
return (
<section className="py-24 bg-charcoal text-white relative overflow-hidden">
<div className="container mx-auto px-6 lg:px-12">
<div className="flex flex-col items-center justify-center mb-16 text-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
className="flex items-center gap-4 mb-4"
>
<div className="p-4 bg-gradient-to-tr from-[#f09433] via-[#dc2743] to-[#bc1888] rounded-2xl shadow-lg">
<Instagram size={28} className="text-white" />
</div>
<h2 className="font-heading text-4xl md:text-5xl font-black">
@kozmosbeach
</h2>
</motion.div>
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.2 }}
className="text-white/60 text-lg max-w-xl"
>
Bizi Instagram'da takip edin, en yeni etkinliklerden ve plajımızdaki harika anlardan anında haberdar olun.
</motion.p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{posts.map((post, i) => (
<motion.a
href={post.url}
target="_blank"
rel="noopener noreferrer"
key={post.id || i}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.1 }}
className="group relative aspect-[4/5] rounded-2xl overflow-hidden bg-gray-900 border border-white/10 block"
>
{/* Instagram image is loaded via img instead of next/image to avoid domain config issues with FB/IG CDNs */}
{post.imageUrl ? (
<img
src={post.imageUrl}
alt={post.caption || "Instagram Post"}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-800">
<Instagram size={32} className="text-white/20" />
</div>
)}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center p-6">
<Instagram size={32} className="text-white mb-3" />
</div>
</motion.a>
))}
</div>
<div className="mt-16 text-center">
<a
href="https://instagram.com/kozmosbeach"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-8 py-4 bg-white text-charcoal rounded-xl font-bold hover:bg-turquoise hover:text-white transition-colors duration-300"
>
<Instagram size={20} />
Daha Fazlası İçin Takip Et
</a>
</div>
</div>
</section>
);
}
+21 -29
View File
@@ -2,6 +2,7 @@
import { useTranslations } from 'next-intl';
import { Link, usePathname, useRouter } from '@/i18n/routing';
import Image from 'next/image';
import { useState, useEffect } from 'react';
import { Menu, X } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
@@ -26,7 +27,6 @@ export default function Navbar({ locale }: { locale: string }) {
const navLinks = [
{ href: pathname === '/' ? '#about' : '/#about', label: t('about') },
{ href: '/accommodation', label: t('accommodation') },
{ href: pathname === '/' ? '#beach' : '/#beach', label: t('beach') },
{ href: pathname === '/' ? '#dining' : '/#dining', label: t('dining') },
{ href: pathname === '/' ? '#events' : '/#events', label: t('events') },
@@ -36,30 +36,25 @@ export default function Navbar({ locale }: { locale: string }) {
return (
<nav
className={`fixed top-0 left-0 w-full z-50 transition-all duration-300 ${
isScrolled
className={`fixed top-0 left-0 w-full z-50 transition-all duration-300 ${isScrolled
? 'bg-white/90 backdrop-blur-lg shadow-sm py-4 border-b border-sandy/40'
: 'bg-transparent py-6'
}`}
}`}
>
<div className="container mx-auto px-6 lg:px-12 flex justify-between items-center">
{/* Logo */}
<Link href="/" className="flex flex-col items-start">
<span
className={`font-heading text-2xl font-black uppercase tracking-tighter ${
isScrolled ? 'text-midnight' : 'gradient-text-warm'
<Link href="/" className="relative w-40 h-16 flex items-center transform transition-transform hover:scale-105">
<Image
src="/logo.png"
alt="Kozmos Logo"
fill
priority
sizes="(max-width: 768px) 160px, 160px"
className={`object-contain transition-all duration-300 ${
isScrolled ? 'filter-none' : 'brightness-0 invert drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]'
}`}
>
Kozmos
</span>
<span
className={`text-[10px] tracking-[0.4em] font-body uppercase ${
isScrolled ? 'text-coral' : 'text-white/40'
}`}
>
Beach &amp; More
</span>
/>
</Link>
{/* Desktop Nav */}
@@ -69,9 +64,8 @@ export default function Navbar({ locale }: { locale: string }) {
<Link
key={link.href}
href={link.href}
className={`text-sm font-medium tracking-wide transition-colors hover:text-coral ${
isScrolled ? 'text-midnight/70' : 'text-white/80'
}`}
className={`text-sm font-medium tracking-wide transition-colors hover:text-coral ${isScrolled ? 'text-midnight/70' : 'text-white/80'
}`}
>
{link.label}
</Link>
@@ -79,21 +73,19 @@ export default function Navbar({ locale }: { locale: string }) {
</div>
<div
className={`flex items-center gap-3 border-l pl-5 ${
isScrolled ? 'border-sandy' : 'border-white/20'
}`}
className={`flex items-center gap-3 border-l pl-5 ${isScrolled ? 'border-sandy' : 'border-white/20'
}`}
>
{(['tr', 'en'] as const).map((l) => (
<button
key={l}
onClick={() => switchLocale(l)}
className={`text-[11px] font-black uppercase tracking-wider transition-colors ${
locale === l
className={`text-[11px] font-black uppercase tracking-wider transition-colors ${locale === l
? 'text-coral'
: isScrolled
? 'text-midnight/40'
: 'text-white/40'
}`}
? 'text-midnight/40'
: 'text-white/40'
}`}
>
{l}
</button>
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Utensils, Globe, X } from 'lucide-react';
import Link from 'next/link';
export default function WelcomePopup() {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
// Sadece ilk ziyarette göstermek için sessionStorage kullanımı:
const hasSeenPopup = sessionStorage.getItem('kozmos-welcome-popup');
if (!hasSeenPopup) {
// Sayfa yüklendikten kısa süre sonra açılsın
const timer = setTimeout(() => setIsOpen(true), 600);
return () => clearTimeout(timer);
}
}, []);
const handleClose = () => {
setIsOpen(false);
sessionStorage.setItem('kozmos-welcome-popup', 'true');
};
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] flex items-center justify-center bg-charcoal/80 backdrop-blur-md p-4"
>
<motion.div
initial={{ scale: 0.95, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.95, opacity: 0, y: 20 }}
className="bg-cream rounded-3xl shadow-2xl max-w-sm w-full p-8 relative overflow-hidden"
>
{/* Dekoratif Arka Plan */}
<div className="absolute -top-24 -right-24 w-48 h-48 bg-sand/30 rounded-full blur-3xl"></div>
<button
onClick={handleClose}
className="absolute top-4 right-4 text-charcoal/50 hover:text-charcoal transition-colors z-20"
aria-label="Kapat"
>
<X className="w-6 h-6" />
</button>
<div className="relative z-10 text-center space-y-6">
<div className="space-y-2">
<h2 className="text-3xl font-serif text-charcoal">Kozmos'a<br/>Hoş Geldiniz</h2>
<p className="text-charcoal/70">Nereye gitmek istersiniz?</p>
</div>
<div className="flex flex-col gap-4 pt-4">
{/* Menü Linki - QR menü için buradaki href değerini kendi menü linkinle değiştirebilirsin */}
<Link
href="/menu"
onClick={handleClose}
className="group relative flex items-center justify-center gap-3 w-full bg-turquoise text-white py-4 rounded-xl hover:bg-turquoise/90 transition-all font-medium overflow-hidden shadow-lg shadow-turquoise/20"
>
<div className="absolute inset-0 bg-white/20 translate-y-full group-hover:translate-y-0 transition-transform duration-300 ease-out"></div>
<Utensils className="w-5 h-5 relative z-10" />
<span className="relative z-10">Menüye Git</span>
</Link>
<button
onClick={handleClose}
className="group flex items-center justify-center gap-3 w-full bg-charcoal text-cream py-4 rounded-xl hover:bg-charcoal/90 transition-colors font-medium shadow-lg"
>
<Globe className="w-5 h-5" />
<span>Siteye Git</span>
</button>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
+187
View File
@@ -0,0 +1,187 @@
"use client";
import { useState } from "react";
import { Save } from "lucide-react";
import { updateAboutSettings } from "@/app/actions/about";
import ImageUpload from "@/components/admin/ImageUpload";
type AboutSettings = {
title: string;
description: string;
card1Title: string;
card1Desc: string;
card2Title: string;
card2Desc: string;
card3Title: string;
card3Desc: string;
image: string;
};
export default function AboutClient({ settings }: { settings: AboutSettings }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [image, setImage] = useState(settings.image);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);
setSuccessMessage(null);
const formData = new FormData(e.currentTarget);
const result = await updateAboutSettings(formData);
if (result.success) {
setSuccessMessage("Hakkımızda ayarları başarıyla kaydedildi.");
} else {
setError(result.error || "Bir hata oluştu");
}
setLoading(false);
};
return (
<div className="p-8 max-w-4xl">
<div className="mb-8">
<h1 className="text-3xl font-serif text-gray-900">Hakkımızda Yönetimi</h1>
<p className="text-gray-500 mt-2">
Site üzerindeki Hakkımızda bölümünün metinlerini ve ana görselini buradan değiştirebilirsiniz.
</p>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{error && (
<div className="bg-red-50 text-red-600 p-4 rounded-lg text-sm font-medium">
{error}
</div>
)}
{successMessage && (
<div className="bg-green-50 text-green-600 p-4 rounded-lg text-sm font-medium">
{successMessage}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Başlık</label>
<input
name="title"
defaultValue={settings.title}
required
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Açıklama (Ana Metin)</label>
<textarea
name="description"
defaultValue={settings.description}
required
rows={4}
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none resize-none"
/>
</div>
<div className="border-t border-gray-100 pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Kart 1 (Örn: Kusursuz Sahil)</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Başlık</label>
<input
name="card1Title"
defaultValue={settings.card1Title}
required
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Açıklama</label>
<input
name="card1Desc"
defaultValue={settings.card1Desc}
required
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none"
/>
</div>
</div>
</div>
<div className="border-t border-gray-100 pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Kart 2 (Örn: Gastronomi)</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Başlık</label>
<input
name="card2Title"
defaultValue={settings.card2Title}
required
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Açıklama</label>
<input
name="card2Desc"
defaultValue={settings.card2Desc}
required
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none"
/>
</div>
</div>
</div>
<div className="border-t border-gray-100 pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Kart 3 (Örn: Canlı Eğlence)</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Başlık</label>
<input
name="card3Title"
defaultValue={settings.card3Title}
required
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Açıklama</label>
<input
name="card3Desc"
defaultValue={settings.card3Desc}
required
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none"
/>
</div>
</div>
</div>
<div className="border-t border-gray-100 pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Sağ Bölüm Görseli</h3>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Görsel Yükle</label>
<ImageUpload
name="image"
value={image}
onChange={setImage}
label="Yeni Görsel Yükle"
/>
</div>
</div>
<div className="pt-4 flex justify-end border-t border-gray-100">
<button
type="submit"
disabled={loading}
className="bg-turquoise text-white px-8 py-2.5 rounded-lg hover:bg-turquoise/90 transition-colors disabled:opacity-50 flex items-center gap-2"
>
<Save size={18} />
{loading ? "Kaydediliyor..." : "Ayarları Kaydet"}
</button>
</div>
</form>
</div>
</div>
);
}
+207
View File
@@ -0,0 +1,207 @@
"use client";
import { useState } from "react";
import { Plus, Edit2, Trash2, X } from "lucide-react";
import { createAdmin, updateAdmin, deleteAdmin } from "@/app/actions/admin";
type Admin = {
id: string;
name: string;
email: string;
createdAt: Date;
};
export default function AdminsClient({ initialAdmins }: { initialAdmins: Admin[] }) {
const [admins, setAdmins] = useState<Admin[]>(initialAdmins);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingAdmin, setEditingAdmin] = useState<Admin | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const openModal = (admin?: Admin) => {
setEditingAdmin(admin || null);
setError(null);
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
setEditingAdmin(null);
setError(null);
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);
const formData = new FormData(e.currentTarget);
let result;
if (editingAdmin) {
result = await updateAdmin(editingAdmin.id, formData);
} else {
result = await createAdmin(formData);
}
if (result.success) {
// Reload sayfa verileri için basit bir çözüm
window.location.reload();
} else {
setError(result.error || "Bir hata oluştu");
setLoading(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Bu yöneticiyi silmek istediğinize emin misiniz?")) return;
setLoading(true);
const result = await deleteAdmin(id);
if (result.success) {
window.location.reload();
} else {
alert(result.error || "Silinirken hata oluştu");
setLoading(false);
}
};
return (
<div className="p-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-serif text-gray-900">Yöneticiler</h1>
<button
onClick={() => openModal()}
className="bg-turquoise text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-turquoise/90 transition-colors"
>
<Plus className="w-4 h-4" />
Yeni Ekle
</button>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-gray-50 border-b border-gray-100 text-sm text-gray-500">
<th className="p-4 font-medium">İsim</th>
<th className="p-4 font-medium">E-posta</th>
<th className="p-4 font-medium">Kayıt Tarihi</th>
<th className="p-4 font-medium text-right">İşlemler</th>
</tr>
</thead>
<tbody>
{admins.map((admin) => (
<tr key={admin.id} className="border-b border-gray-50 hover:bg-gray-50/50">
<td className="p-4 text-gray-900 font-medium">{admin.name}</td>
<td className="p-4 text-gray-600">{admin.email}</td>
<td className="p-4 text-gray-500 text-sm">
{new Date(admin.createdAt).toLocaleDateString("tr-TR")}
</td>
<td className="p-4 flex justify-end gap-2">
<button
onClick={() => openModal(admin)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Düzenle"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(admin.id)}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Sil"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
{admins.length === 0 && (
<tr>
<td colSpan={4} className="p-8 text-center text-gray-500">
Henüz bir yönetici bulunmuyor.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded-xl shadow-2xl max-w-md w-full p-6 relative">
<button
onClick={closeModal}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
>
<X className="w-5 h-5" />
</button>
<h2 className="text-xl font-bold text-gray-900 mb-6">
{editingAdmin ? "Yönetici Düzenle" : "Yeni Yönetici Ekle"}
</h2>
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">İsim Soyisim</label>
<input
type="text"
name="name"
defaultValue={editingAdmin?.name}
required
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">E-posta</label>
<input
type="email"
name="email"
defaultValue={editingAdmin?.email}
required
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Şifre {editingAdmin && <span className="text-gray-400 font-normal">(Değiştirmek istemiyorsanız boş bırakın)</span>}
</label>
<input
type="password"
name="password"
required={!editingAdmin}
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none"
/>
</div>
<div className="pt-4 flex justify-end gap-3">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
İptal
</button>
<button
type="submit"
disabled={loading}
className="bg-turquoise text-white px-6 py-2 rounded-lg hover:bg-turquoise/90 transition-colors disabled:opacity-50"
>
{loading ? "Kaydediliyor..." : "Kaydet"}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
"use client";
import { useState } from "react";
import { Save } from "lucide-react";
import { updateBeachSettings } from "@/app/actions/beach";
import ImageUpload from "@/components/admin/ImageUpload";
type BeachSettings = {
title: string;
description: string;
reservationInfo: string;
image1: string;
image2: string;
image3: string;
image4: string;
};
export default function BeachClient({ settings }: { settings: BeachSettings }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [image1, setImage1] = useState(settings.image1);
const [image2, setImage2] = useState(settings.image2);
const [image3, setImage3] = useState(settings.image3);
const [image4, setImage4] = useState(settings.image4);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);
setSuccessMessage(null);
const formData = new FormData(e.currentTarget);
const result = await updateBeachSettings(formData);
if (result.success) {
setSuccessMessage("Plaj ayarları başarıyla kaydedildi.");
} else {
setError(result.error || "Bir hata oluştu");
}
setLoading(false);
};
return (
<div className="p-8 max-w-4xl">
<div className="mb-8">
<h1 className="text-3xl font-serif text-gray-900">Plaj Yönetimi</h1>
<p className="text-gray-500 mt-2">
Site üzerindeki Plaj (Beach) bölümünün yazılarını ve görsellerini buradan değiştirebilirsiniz.
</p>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{error && (
<div className="bg-red-50 text-red-600 p-4 rounded-lg text-sm font-medium">
{error}
</div>
)}
{successMessage && (
<div className="bg-green-50 text-green-600 p-4 rounded-lg text-sm font-medium">
{successMessage}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Başlık</label>
<input
name="title"
defaultValue={settings.title}
required
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Açıklama (Ana Metin)</label>
<textarea
name="description"
defaultValue={settings.description}
required
rows={4}
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rezervasyon Bilgisi (Buton Üstü Not)</label>
<textarea
name="reservationInfo"
defaultValue={settings.reservationInfo}
required
rows={2}
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none resize-none"
/>
</div>
<div className="border-t border-gray-100 pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Plaj Görselleri (4 Adet)</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Görsel 1 (Sol Üst, Dikey)</label>
<ImageUpload
name="image1"
value={image1}
onChange={setImage1}
label="Görsel 1 Yükle"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Görsel 2 (Sol Alt, Kare)</label>
<ImageUpload
name="image2"
value={image2}
onChange={setImage2}
label="Görsel 2 Yükle"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Görsel 3 (Sağ Üst, Kare)</label>
<ImageUpload
name="image3"
value={image3}
onChange={setImage3}
label="Görsel 3 Yükle"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Görsel 4 (Sağ Alt, Dikey)</label>
<ImageUpload
name="image4"
value={image4}
onChange={setImage4}
label="Görsel 4 Yükle"
/>
</div>
</div>
</div>
<div className="pt-4 flex justify-end border-t border-gray-100">
<button
type="submit"
disabled={loading}
className="bg-turquoise text-white px-8 py-2.5 rounded-lg hover:bg-turquoise/90 transition-colors disabled:opacity-50 flex items-center gap-2"
>
<Save size={18} />
{loading ? "Kaydediliyor..." : "Ayarları Kaydet"}
</button>
</div>
</form>
</div>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
"use client";
import { useState } from "react";
import { Save } from "lucide-react";
import { updateContactSettings } from "@/app/actions/contact";
type ContactSettings = {
address: string;
phone: string;
instagram: string;
mapUrl: string;
};
export default function ContactClient({ settings }: { settings: ContactSettings }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);
setSuccessMessage(null);
const formData = new FormData(e.currentTarget);
const result = await updateContactSettings(formData);
if (result.success) {
setSuccessMessage("İletişim ayarları başarıyla kaydedildi.");
} else {
setError(result.error || "Bir hata oluştu");
}
setLoading(false);
};
return (
<div className="p-8 max-w-4xl">
<div className="mb-8">
<h1 className="text-3xl font-serif text-gray-900">İletişim & Konum</h1>
<p className="text-gray-500 mt-2">
Site üzerindeki iletişim bilgilerini ve Google Haritalar iframe adresini buradan güncelleyebilirsiniz.
</p>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{error && (
<div className="bg-red-50 text-red-600 p-4 rounded-lg text-sm font-medium">
{error}
</div>
)}
{successMessage && (
<div className="bg-green-50 text-green-600 p-4 rounded-lg text-sm font-medium">
{successMessage}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Adres</label>
<textarea
name="address"
defaultValue={settings.address}
placeholder="Örn: Akyaka Mah. Gökova..."
required
rows={3}
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none resize-none"
/>
</div>
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Telefon / WhatsApp (Ülke Koduyla)</label>
<input
type="text"
name="phone"
defaultValue={settings.phone}
placeholder="Örn: 905001234567"
required
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Instagram Linki</label>
<input
type="url"
name="instagram"
defaultValue={settings.instagram}
placeholder="https://instagram.com/..."
required
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none"
/>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Google Maps Embed URL (src)</label>
<textarea
name="mapUrl"
defaultValue={settings.mapUrl}
placeholder="https://www.google.com/maps/embed?pb=..."
required
rows={4}
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none font-mono text-sm"
/>
<p className="text-xs text-gray-500 mt-2">
Google Haritalar'dan "Harita Yerleştir" seçeneğini seçip içindeki <strong>src="..."</strong> kısmındaki URL'yi buraya yapıştırın.
</p>
</div>
<div className="pt-4 flex justify-end border-t border-gray-100">
<button
type="submit"
disabled={loading}
className="bg-turquoise text-white px-8 py-2.5 rounded-lg hover:bg-turquoise/90 transition-colors disabled:opacity-50 flex items-center gap-2"
>
<Save size={18} />
{loading ? "Kaydediliyor..." : "Ayarları Kaydet"}
</button>
</div>
</form>
</div>
</div>
);
}
+256
View File
@@ -0,0 +1,256 @@
"use client";
import { useState } from "react";
import { Plus, Edit2, Trash2, X, Star } from "lucide-react";
import { createEvent, updateEvent, deleteEvent } from "@/app/actions/events";
type Event = {
id: string;
title: string;
time: string;
tag: string;
iconType: string;
colorTheme: string;
isFeatured: boolean;
};
export default function EventsClient({ initialEvents }: { initialEvents: Event[] }) {
const [events] = useState<Event[]>(initialEvents);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const openModal = (event?: Event) => {
setEditingEvent(event || null);
setError(null);
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
setEditingEvent(null);
setError(null);
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);
const formData = new FormData(e.currentTarget);
let result;
if (editingEvent) {
result = await updateEvent(editingEvent.id, formData);
} else {
result = await createEvent(formData);
}
if (result.success) {
window.location.reload();
} else {
setError(result.error || "Bir hata oluştu");
setLoading(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Bu etkinliği silmek istediğinize emin misiniz?")) return;
setLoading(true);
const result = await deleteEvent(id);
if (result.success) {
window.location.reload();
} else {
alert(result.error || "Silinirken hata oluştu");
setLoading(false);
}
};
return (
<div className="p-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-serif text-gray-900">Etkinlikler</h1>
<button
onClick={() => openModal()}
className="bg-turquoise text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-turquoise/90 transition-colors"
>
<Plus className="w-4 h-4" />
Yeni Ekle
</button>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-gray-50 border-b border-gray-100 text-sm text-gray-500">
<th className="p-4 font-medium w-12">Öne Çıkan</th>
<th className="p-4 font-medium">Başlık</th>
<th className="p-4 font-medium">Zaman</th>
<th className="p-4 font-medium">Etiket</th>
<th className="p-4 font-medium text-right">İşlemler</th>
</tr>
</thead>
<tbody>
{events.map((event) => (
<tr key={event.id} className="border-b border-gray-50 hover:bg-gray-50/50">
<td className="p-4 text-center">
{event.isFeatured ? <Star className="w-5 h-5 text-yellow-400 fill-current" /> : ""}
</td>
<td className="p-4 text-gray-900 font-medium">{event.title}</td>
<td className="p-4 text-gray-600">{event.time}</td>
<td className="p-4 text-gray-500 text-sm">
<span className="px-2 py-1 bg-gray-100 rounded text-xs font-bold">{event.tag}</span>
</td>
<td className="p-4 flex justify-end gap-2">
<button
onClick={() => openModal(event)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Düzenle"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(event.id)}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Sil"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
{events.length === 0 && (
<tr>
<td colSpan={5} className="p-8 text-center text-gray-500">
Henüz bir etkinlik bulunmuyor.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded-xl shadow-2xl max-w-lg w-full p-6 relative max-h-[90vh] overflow-y-auto">
<button
onClick={closeModal}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
>
<X className="w-5 h-5" />
</button>
<h2 className="text-xl font-bold text-gray-900 mb-6">
{editingEvent ? "Etkinlik Düzenle" : "Yeni Etkinlik Ekle"}
</h2>
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Başlık</label>
<input
type="text"
name="title"
defaultValue={editingEvent?.title}
placeholder="Örn: DJ Gecesi"
required
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Zaman</label>
<input
type="text"
name="time"
defaultValue={editingEvent?.time}
placeholder="Örn: Cuma & Cumartesi, 22:00"
required
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Etiket (Gün/Kısa Bilgi)</label>
<input
type="text"
name="tag"
defaultValue={editingEvent?.tag}
placeholder="Örn: CUM veya ÖZEL"
required
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">İkon Tipi</label>
<select
name="iconType"
defaultValue={editingEvent?.iconType || "music"}
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-turquoise outline-none"
>
<option value="music">Müzik Notası</option>
<option value="dj">DJ Diski (Plak)</option>
<option value="glass">Kadeh (Rakı vb.)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Renk Teması</label>
<select
name="colorTheme"
defaultValue={editingEvent?.colorTheme || "cyan"}
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-turquoise outline-none"
>
<option value="cyan">Mavi (Cyan)</option>
<option value="coral">Mercan (Coral)</option>
<option value="amber">Turuncu (Amber)</option>
</select>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<input
type="checkbox"
name="isFeatured"
id="isFeatured"
defaultChecked={editingEvent?.isFeatured}
className="w-5 h-5 text-turquoise focus:ring-turquoise border-gray-300 rounded"
/>
<label htmlFor="isFeatured" className="text-sm font-medium text-gray-700">
Öne Çıkan Etkinlik (Ana sayfada en büyük kart olarak gösterilir)
</label>
</div>
<div className="pt-4 flex justify-end gap-3 border-t border-gray-100 mt-4">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
İptal
</button>
<button
type="submit"
disabled={loading}
className="bg-turquoise text-white px-6 py-2 rounded-lg hover:bg-turquoise/90 transition-colors disabled:opacity-50"
>
{loading ? "Kaydediliyor..." : "Kaydet"}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+215
View File
@@ -0,0 +1,215 @@
"use client";
import { useState } from "react";
import { Plus, Edit2, Trash2, X, Image as ImageIcon } from "lucide-react";
import { createGalleryImage, updateGalleryImage, deleteGalleryImage } from "@/app/actions/gallery";
import ImageUpload from "@/components/admin/ImageUpload";
type GalleryImage = {
id: string;
src: string;
alt: string | null;
order: number;
};
export default function GalleryClient({ initialImages }: { initialImages: GalleryImage[] }) {
const [images] = useState<GalleryImage[]>(initialImages);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingImage, setEditingImage] = useState<GalleryImage | null>(null);
const [editingImageSrc, setEditingImageSrc] = useState<string>("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const openModal = (image?: GalleryImage) => {
setEditingImage(image || null);
setEditingImageSrc(image?.src || "");
setError(null);
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
setEditingImage(null);
setEditingImageSrc("");
setError(null);
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);
const formData = new FormData(e.currentTarget);
let result;
if (editingImage) {
result = await updateGalleryImage(editingImage.id, formData);
} else {
result = await createGalleryImage(formData);
}
if (result.success) {
window.location.reload();
} else {
setError(result.error || "Bir hata oluştu");
setLoading(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Bu görseli silmek istediğinize emin misiniz?")) return;
setLoading(true);
const result = await deleteGalleryImage(id);
if (result.success) {
window.location.reload();
} else {
alert(result.error || "Silinirken hata oluştu");
setLoading(false);
}
};
return (
<div className="p-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-serif text-gray-900">Galeri Görselleri</h1>
<button
onClick={() => openModal()}
className="bg-turquoise text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-turquoise/90 transition-colors"
>
<Plus className="w-4 h-4" />
Yeni Görsel Ekle
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{images.map((image) => (
<div key={image.id} className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden group relative">
<div className="aspect-video w-full bg-gray-100 relative overflow-hidden">
{image.src ? (
<img
src={image.src}
alt={image.alt || "Galeri görseli"}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
<ImageIcon size={48} />
</div>
)}
</div>
<div className="p-4">
<div className="text-sm font-medium text-gray-900 truncate" title={image.alt || ""}>
{image.alt || <span className="text-gray-400 italic">Başlıksız</span>}
</div>
<div className="text-xs text-gray-500 mt-1">
Sıra: {image.order}
</div>
</div>
<div className="absolute top-2 right-2 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity bg-white/90 backdrop-blur-sm p-1.5 rounded-lg shadow-sm">
<button
onClick={() => openModal(image)}
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="Düzenle"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(image.id)}
className="p-1.5 text-red-600 hover:bg-red-50 rounded transition-colors"
title="Sil"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
{images.length === 0 && (
<div className="col-span-full p-12 text-center text-gray-500 bg-white rounded-xl border border-dashed border-gray-200">
<ImageIcon className="w-12 h-12 mx-auto text-gray-300 mb-3" />
<p>Henüz galeriye görsel eklenmemiş.</p>
</div>
)}
</div>
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-white rounded-xl shadow-2xl max-w-lg w-full p-6 relative">
<button
onClick={closeModal}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
>
<X className="w-5 h-5" />
</button>
<h2 className="text-xl font-bold text-gray-900 mb-6">
{editingImage ? "Görseli Düzenle" : "Yeni Görsel Ekle"}
</h2>
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Görsel Yükle</label>
<ImageUpload
name="src"
value={editingImageSrc}
onChange={setEditingImageSrc}
label="Galeri Görseli Yükle"
/>
<p className="text-xs text-gray-500 mt-1">Görsel bilgisayarınızdan veya kameradan seçilip yüklenebilir.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Açıklama / Alt Metin</label>
<input
type="text"
name="alt"
defaultValue={editingImage?.alt || ""}
placeholder="Opsiyonel açıklama..."
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Sıralama (Opsiyonel)</label>
<input
type="number"
name="order"
defaultValue={editingImage?.order || 0}
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none"
/>
<p className="text-xs text-gray-500 mt-1">Küçük numaralar ilk sırada gösterilir.</p>
</div>
<div className="pt-4 flex justify-end gap-3 border-t border-gray-100 mt-4">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
İptal
</button>
<button
type="submit"
disabled={loading}
className="bg-turquoise text-white px-6 py-2 rounded-lg hover:bg-turquoise/90 transition-colors disabled:opacity-50"
>
{loading ? "Kaydediliyor..." : "Kaydet"}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { CldUploadWidget } from "next-cloudinary";
import { ImagePlus, X } from "lucide-react";
import Image from "next/image";
interface ImageUploadProps {
value: string;
onChange: (value: string) => void;
folder?: string;
label?: string;
name?: string; // Add name prop for hidden input
}
export default function ImageUpload({ value, onChange, folder = "kozmosbeach", label = "Görsel Yükle", name }: ImageUploadProps) {
const onUpload = (result: any) => {
onChange(result.info.secure_url);
};
return (
<div className="w-full">
{name && <input type="hidden" name={name} value={value} />}
{value ? (
<div className="relative w-full aspect-video rounded-xl overflow-hidden border border-gray-200">
<Image
fill
sizes="(max-width: 768px) 100vw, 33vw"
className="object-cover"
alt="Upload"
src={value}
/>
<div className="absolute top-2 right-2 flex gap-2">
<button
type="button"
onClick={() => onChange("")}
className="p-1.5 bg-red-500 text-white rounded-full hover:bg-red-600 transition shadow-sm"
>
<X size={16} />
</button>
</div>
</div>
) : (
<CldUploadWidget
signatureEndpoint="/api/cloudinary"
options={{
folder: folder,
maxFiles: 1,
sources: ["local", "url", "camera"],
}}
onSuccess={onUpload}
>
{({ open }) => {
const onClick = (e: React.MouseEvent) => {
e.preventDefault();
open();
};
return (
<button
type="button"
onClick={onClick}
className="w-full h-32 border-2 border-dashed border-gray-300 rounded-xl flex flex-col items-center justify-center gap-2 text-gray-500 hover:border-turquoise hover:text-turquoise transition-colors bg-gray-50 hover:bg-turquoise/5"
>
<ImagePlus size={24} />
<span className="text-sm font-medium">{label}</span>
</button>
);
}}
</CldUploadWidget>
)}
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import { login } from "@/app/actions/auth";
export default function LoginClient() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);
const formData = new FormData(e.currentTarget);
const result = await login(formData);
if (result.success) {
window.location.href = "/admin"; // Başarılı giriş
} else {
setError(result.error || "Giriş başarısız.");
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden">
<div className="bg-charcoal p-8 text-center flex flex-col items-center">
<img src="/logo.png" alt="Kozmos Logo" className="h-16 mb-2 brightness-0 invert opacity-90 object-contain" />
<p className="text-cream/70 text-sm mt-1">Yönetim Paneline Giriş Yapın</p>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-6">
{error && (
<div className="bg-red-50 text-red-600 p-4 rounded-xl text-sm font-medium text-center">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">E-posta Adresi</label>
<input
type="email"
name="email"
required
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none transition-all"
placeholder="admin@kozmosbeach.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Şifre</label>
<input
type="password"
name="password"
required
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-turquoise focus:border-transparent outline-none transition-all"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3.5 bg-turquoise text-white rounded-xl font-medium hover:bg-turquoise/90 focus:ring-4 focus:ring-turquoise/20 transition-all disabled:opacity-70 mt-2"
>
{loading ? "Giriş Yapılıyor..." : "Giriş Yap"}
</button>
</form>
<div className="p-4 bg-gray-50 border-t border-gray-100 text-center">
<a href="/" className="text-sm text-gray-500 hover:text-turquoise transition-colors">
Siteye Geri Dön
</a>
</div>
</div>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';
const secretKey = process.env.JWT_SECRET || 'fallback-secret-for-development-only-do-not-use-in-prod';
const key = new TextEncoder().encode(secretKey);
export async function signToken(payload: any) {
return await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('1d') // 1 günlük oturum süresi
.sign(key);
}
export async function verifyToken(token: string) {
try {
const { payload } = await jwtVerify(token, key);
return payload;
} catch (error) {
return null;
}
}
export async function setAuthCookie(token: string) {
const cookieStore = await cookies();
cookieStore.set('admin_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 1 day in seconds
path: '/',
});
}
export async function removeAuthCookie() {
const cookieStore = await cookies();
cookieStore.delete('admin_token');
}
export async function getAuthCookie() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
return token ? await verifyToken(token) : null;
}
+18
View File
@@ -0,0 +1,18 @@
import { PrismaClient } from '@/app/generated/prisma/client';
import { Pool } from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
const connectionString = process.env.DATABASE_URL;
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool);
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({ adapter });
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
-17
View File
@@ -1,17 +0,0 @@
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
// A list of all locales that are supported
locales: ['tr', 'en'],
// Used when no locale matches
defaultLocale: 'tr',
localePrefix: 'as-needed'
});
export const config = {
// Match all pathnames except for
// - … if they start with `/api`, `/_next` or `/_vercel`
// - … the ones containing a dot (e.g. `favicon.ico`)
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
};
+8
View File
@@ -11,6 +11,14 @@ const nextConfig: NextConfig = {
protocol: 'https',
hostname: 'picsum.photos',
},
{
protocol: 'https',
hostname: 'images.unsplash.com',
},
{
protocol: 'https',
hostname: 'res.cloudinary.com',
},
],
},
};
+1350 -15
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -9,22 +9,33 @@
"lint": "eslint"
},
"dependencies": {
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3",
"cloudinary": "^2.10.0",
"clsx": "^2.1.1",
"framer-motion": "^12.40.0",
"jose": "^6.2.3",
"lucide-react": "^1.17.0",
"next": "16.2.7",
"next-cloudinary": "^6.17.5",
"next-intl": "^4.13.0",
"pg": "^8.21.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.4.2",
"eslint": "^9",
"eslint-config-next": "16.2.7",
"prisma": "^7.8.0",
"tailwindcss": "^4",
"typescript": "^5"
}
+14
View File
@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});
+78
View File
@@ -0,0 +1,78 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Get a free hosted Postgres database in seconds: `npx create-db`
generator client {
provider = "prisma-client"
output = "../app/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model Admin {
id String @id @default(cuid())
name String
email String @unique
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Event {
id String @id @default(cuid())
title String
time String
tag String
iconType String @default("music")
colorTheme String @default("cyan")
isFeatured Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model GalleryImage {
id String @id @default(cuid())
src String // Image URL
alt String? // Alt text or title
order Int @default(0) // Ordering
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ContactSettings {
id String @id @default("default")
address String @db.Text
phone String
instagram String
mapUrl String @db.Text
updatedAt DateTime @updatedAt
}
model BeachSettings {
id String @id @default("default")
title String
description String @db.Text
reservationInfo String @db.Text
image1 String
image2 String
image3 String
image4 String
updatedAt DateTime @updatedAt
}
model AboutSettings {
id String @id @default("default")
title String
description String @db.Text
card1Title String
card1Desc String @db.Text
card2Title String
card2Desc String @db.Text
card3Title String
card3Desc String @db.Text
image String
updatedAt DateTime @updatedAt
}
+46
View File
@@ -0,0 +1,46 @@
import createMiddleware from 'next-intl/middleware';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
// Create next-intl middleware
const intlMiddleware = createMiddleware({
locales: ['tr', 'en'],
defaultLocale: 'tr',
localePrefix: 'as-needed'
});
export default async function middleware(req: NextRequest) {
// Check if it's an admin route
if (req.nextUrl.pathname.startsWith('/admin')) {
// Allow access to login page
if (req.nextUrl.pathname === '/admin/login') {
return NextResponse.next();
}
const token = req.cookies.get('admin_token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/admin/login', req.url));
}
try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET || 'fallback-secret-for-development-only-do-not-use-in-prod');
await jwtVerify(token, secret);
return NextResponse.next();
} catch (err) {
// Invalid token
return NextResponse.redirect(new URL('/admin/login', req.url));
}
}
// Delegate non-admin routes to next-intl
return intlMiddleware(req);
}
export const config = {
// Match all pathnames except for
// - … if they start with `/api`, `/_next`, `/_vercel`
// - … the ones containing a dot (e.g. `favicon.ico`)
// Notice we removed 'admin' from exclusion list so it passes through our custom middleware
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB