feat: setup database-driven admin user management
This commit is contained in:
@@ -4,6 +4,21 @@ import { ArrowRight, Clock, Users } from "lucide-react";
|
||||
import ScrollReveal from "@/components/ScrollReveal";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { buildMeta } from "@/lib/metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
|
||||
const { lang } = await params;
|
||||
return buildMeta(lang, "/aktiviteler", {
|
||||
title: "Aktiviteler | Kite Beach Akyaka",
|
||||
description: "Kitesurf, yoga, SUP ve bisiklet kiralama. Akyaka'nın doğasında IKO sertifikalı eğitmenlerle unutulmaz deneyimler.",
|
||||
}, {
|
||||
title: "Activities | Kite Beach Akyaka",
|
||||
description: "Kitesurf, yoga, SUP and bicycle rental. Unforgettable experiences in Akyaka with IKO certified instructors.",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function AktivitelerPage({ params }: { params: Promise<{ lang: string }> }) {
|
||||
const { lang } = await params;
|
||||
|
||||
@@ -4,11 +4,21 @@ import { ArrowRight, Calendar, Clock, MapPin, Music } from "lucide-react";
|
||||
import ScrollReveal from "@/components/ScrollReveal";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { buildMeta } from "@/lib/metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata = {
|
||||
title: "Etkinlikler | Kite Beach Akyaka",
|
||||
description: "Mahmut Orhan konserinden Kitesurf festivallerine, Akyaka'daki en özel etkinlikler ve gün batımı partileri.",
|
||||
};
|
||||
export const revalidate = 3600;
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
|
||||
const { lang } = await params;
|
||||
return buildMeta(lang, "/etkinlikler", {
|
||||
title: "Etkinlikler | Kite Beach Akyaka",
|
||||
description: "Mahmut Orhan konserinden Kitesurf festivallerine, Akyaka'daki en özel etkinlikler ve gün batımı partileri.",
|
||||
}, {
|
||||
title: "Events | Kite Beach Akyaka",
|
||||
description: "From Mahmut Orhan concerts to Kitesurf festivals, the most special events and sunset parties in Akyaka.",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function EventsPage({ params }: { params: Promise<{ lang: string }> }) {
|
||||
const { lang } = await params;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { buildMeta } from "@/lib/metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
|
||||
const { lang } = await params;
|
||||
return buildMeta(lang, "/galeri", {
|
||||
title: "Galeri | Kite Beach Akyaka",
|
||||
description: "Kite Beach Akyaka'nın fotoğraf galerisini keşfedin. Odalar, aktiviteler, restoran ve Akyaka'nın eşsiz doğasından kareler.",
|
||||
}, {
|
||||
title: "Gallery | Kite Beach Akyaka",
|
||||
description: "Explore the photo gallery of Kite Beach Akyaka. Rooms, activities, restaurant and moments from the unique nature of Akyaka.",
|
||||
});
|
||||
}
|
||||
|
||||
export default function GaleriLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,6 +1,21 @@
|
||||
import Image from "next/image";
|
||||
import { Leaf, ChefHat, Award } from "lucide-react";
|
||||
import ScrollReveal from "@/components/ScrollReveal";
|
||||
import { buildMeta } from "@/lib/metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
|
||||
const { lang } = await params;
|
||||
return buildMeta(lang, "/hakkimizda", {
|
||||
title: "Hakkımızda | Kite Beach Akyaka",
|
||||
description: "10+ yıllık deneyim, 500+ mutlu misafir. Azmak nehri kıyısında doğa, kitesurf tutku ve yerel lezzetlerin buluşma noktası.",
|
||||
}, {
|
||||
title: "About Us | Kite Beach Akyaka",
|
||||
description: "10+ years of experience, 500+ happy guests. Where nature, kitesurf passion and local flavors meet on the banks of Azmak river.",
|
||||
});
|
||||
}
|
||||
|
||||
const values = [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { MapPin, Phone, Mail, Clock } from "lucide-react";
|
||||
import ScrollReveal from "@/components/ScrollReveal";
|
||||
import { buildMeta } from "@/lib/metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
|
||||
const { lang } = await params;
|
||||
return buildMeta(lang, "/iletisim", {
|
||||
title: "İletişim & Rezervasyon | Kite Beach Akyaka",
|
||||
description: "Kite Beach Akyaka ile iletişime geçin. Muğla Akyaka'da kitesurf dersi, konaklama ve etkinlik rezervasyonu için bize yazın.",
|
||||
}, {
|
||||
title: "Contact & Reservation | Kite Beach Akyaka",
|
||||
description: "Contact Kite Beach Akyaka. Write to us for kitesurf lessons, accommodation and event reservations in Akyaka, Muğla.",
|
||||
});
|
||||
}
|
||||
|
||||
export default function IletisimPage() {
|
||||
return (
|
||||
|
||||
+94
-4
@@ -1,4 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter, Playfair_Display } from "next/font/google";
|
||||
import "../globals.css";
|
||||
import Navbar from "@/components/Navbar";
|
||||
@@ -8,22 +8,106 @@ import WhatsAppButton from "@/components/WhatsAppButton";
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const playfair = Playfair_Display({
|
||||
variable: "--font-playfair",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Kite Beach Akyaka | Kitesurf & Butik Otel",
|
||||
description: "Nehrin huzuru, denizin enerjisi — tek bir noktada. Kite Beach Akyaka butik otel ve kitesurf merkezine hoş geldiniz.",
|
||||
const baseUrl = "https://kitebeachakyaka.com.tr";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
themeColor: "#0D3D50",
|
||||
};
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: { params: Promise<{ lang: string }> }
|
||||
): Promise<Metadata> {
|
||||
const { lang } = await params;
|
||||
const isEn = lang === "en";
|
||||
return {
|
||||
title: isEn
|
||||
? "Kite Beach Akyaka | Kitesurf & Boutique Hotel"
|
||||
: "Kite Beach Akyaka | Kitesurf & Butik Otel",
|
||||
description: isEn
|
||||
? "Tranquility of the river, energy of the sea — in one spot. Welcome to Kite Beach Akyaka boutique hotel and kitesurf center."
|
||||
: "Nehrin huzuru, denizin enerjisi — tek bir noktada. Kite Beach Akyaka butik otel ve kitesurf merkezine hoş geldiniz.",
|
||||
metadataBase: new URL(baseUrl),
|
||||
openGraph: {
|
||||
locale: isEn ? "en_US" : "tr_TR",
|
||||
alternateLocale: isEn ? "tr_TR" : "en_US",
|
||||
siteName: "Kite Beach Akyaka",
|
||||
type: "website",
|
||||
url: `${baseUrl}/${lang}`,
|
||||
images: [{ url: `${baseUrl}/og-image.jpg`, width: 1200, height: 630, alt: "Kite Beach Akyaka" }],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
images: [`${baseUrl}/og-image.jpg`],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return [{ lang: 'tr' }, { lang: 'en' }]
|
||||
}
|
||||
|
||||
function buildJsonLd(lang: string) {
|
||||
const isEn = lang === "en";
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "LodgingBusiness",
|
||||
"@id": `${baseUrl}/#business`,
|
||||
name: "Kite Beach Akyaka",
|
||||
url: baseUrl,
|
||||
logo: `${baseUrl}/logo.png`,
|
||||
image: `${baseUrl}/og-image.jpg`,
|
||||
description: isEn
|
||||
? "Boutique hotel and kitesurf center on the banks of Azmak River in Akyaka, Muğla."
|
||||
: "Akyaka, Muğla'da Azmak Nehri kıyısında butik otel ve kitesurf merkezi.",
|
||||
telephone: "+905330816181",
|
||||
email: "info@kitebeachakyaka.com.tr",
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
streetAddress: "Akyaka Mahallesi, Akçapınar Mevkii",
|
||||
addressLocality: "Akyaka",
|
||||
addressRegion: "Muğla",
|
||||
addressCountry: "TR",
|
||||
},
|
||||
geo: {
|
||||
"@type": "GeoCoordinates",
|
||||
latitude: 37.0543,
|
||||
longitude: 28.3286,
|
||||
},
|
||||
sameAs: [
|
||||
"https://instagram.com/kitebeachakyaka",
|
||||
],
|
||||
amenityFeature: [
|
||||
{ "@type": "LocationFeatureSpecification", name: "Kitesurf School", value: true },
|
||||
{ "@type": "LocationFeatureSpecification", name: "Restaurant & Bar", value: true },
|
||||
{ "@type": "LocationFeatureSpecification", name: "Yoga Platform", value: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": `${baseUrl}/#website`,
|
||||
url: baseUrl,
|
||||
name: "Kite Beach Akyaka",
|
||||
publisher: { "@id": `${baseUrl}/#business` },
|
||||
inLanguage: [isEn ? "en" : "tr"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
params,
|
||||
@@ -39,6 +123,12 @@ export default async function RootLayout({
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-full flex flex-col bg-background text-foreground" suppressHydrationWarning>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(buildJsonLd(lang)).replace(/</g, "\\u003c"),
|
||||
}}
|
||||
/>
|
||||
<Navbar />
|
||||
<main className="flex-grow">{children}</main>
|
||||
<Footer />
|
||||
|
||||
+52
-14
@@ -1,9 +1,21 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import * as Icons from "lucide-react";
|
||||
import { ArrowRight, Star } from "lucide-react";
|
||||
import {
|
||||
ArrowRight, Star, Wind, Anchor, Utensils, BedDouble, Sun,
|
||||
Waves, Bike, Coffee, Music, MapPin, Camera, Heart, Compass,
|
||||
Fish, Mountain, Dumbbell, Leaf, Shield, Umbrella, Flame,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import ScrollReveal from "@/components/ScrollReveal";
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Wind, Anchor, Utensils, BedDouble, Sun, Star, Waves, Bike,
|
||||
Coffee, Music, MapPin, Camera, Heart, Compass, Fish, Mountain,
|
||||
Dumbbell, Leaf, Shield, Umbrella, Flame,
|
||||
};
|
||||
|
||||
import HeroVideo from "@/components/HeroVideo";
|
||||
|
||||
const defaultFeatures = [
|
||||
@@ -64,13 +76,32 @@ const testimonials = [
|
||||
|
||||
import { getDictionary, Locale } from "./dictionaries";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { buildMeta } from "@/lib/metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: { params: Promise<{ lang: string }> }
|
||||
): Promise<Metadata> {
|
||||
const { lang } = await params;
|
||||
return buildMeta(lang, "", {
|
||||
title: "Kite Beach Akyaka | Kitesurf & Butik Otel",
|
||||
description: "Nehrin huzuru, denizin enerjisi — tek bir noktada. Akyaka'nın kalbinde kitesurf ve butik otel deneyimi.",
|
||||
}, {
|
||||
title: "Kite Beach Akyaka | Kitesurf & Boutique Hotel",
|
||||
description: "Tranquility of the river, energy of the sea — in one spot. Kitesurf and boutique hotel experience in the heart of Akyaka.",
|
||||
});
|
||||
}
|
||||
|
||||
async function getInstagramData() {
|
||||
if (!process.env.RAPIDAPI_KEY) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const url = 'https://instagram120.p.rapidapi.com/api/instagram/posts';
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-rapidapi-key': process.env.RAPIDAPI_KEY || '',
|
||||
'x-rapidapi-key': process.env.RAPIDAPI_KEY,
|
||||
'x-rapidapi-host': 'instagram120.p.rapidapi.com',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
@@ -83,11 +114,14 @@ async function getInstagramData() {
|
||||
|
||||
try {
|
||||
const res = await fetch(url, options);
|
||||
if (!res.ok) throw new Error('Instagram fetch failed');
|
||||
if (!res.ok) {
|
||||
console.warn('Instagram API error or missing limits. Status:', res.status);
|
||||
return [];
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.result?.edges?.slice(0, 4).map((edge: any) => edge.node) || [];
|
||||
} catch (err) {
|
||||
console.error("Instagram fetch error:", err);
|
||||
console.warn("Instagram fetch error:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -96,7 +130,15 @@ export default async function Home({ params }: { params: Promise<{ lang: string
|
||||
const { lang } = await params;
|
||||
const dict = await getDictionary(lang as Locale);
|
||||
|
||||
const heroDb = await prisma.heroSection.findUnique({ where: { lang } });
|
||||
const [heroDb, activities, events, dbFeatures, aboutDb, instagramPosts] = await Promise.all([
|
||||
prisma.heroSection.findUnique({ where: { lang } }),
|
||||
prisma.activity.findMany({ where: { lang }, orderBy: { order: "asc" } }),
|
||||
prisma.event.findMany({ where: { lang }, orderBy: { date: "asc" } }),
|
||||
prisma.feature.findMany({ where: { lang }, orderBy: { order: "asc" } }),
|
||||
prisma.aboutSection.findUnique({ where: { lang } }),
|
||||
getInstagramData(),
|
||||
]);
|
||||
|
||||
const heroData = heroDb || {
|
||||
location: dict.home.location,
|
||||
welcome: dict.home.welcome,
|
||||
@@ -107,11 +149,6 @@ export default async function Home({ params }: { params: Promise<{ lang: string
|
||||
scroll: dict.home.scroll,
|
||||
};
|
||||
|
||||
const activities = await prisma.activity.findMany({ where: { lang }, orderBy: { order: "asc" } });
|
||||
const events = await prisma.event.findMany({ where: { lang }, orderBy: { date: "asc" } });
|
||||
const dbFeatures = await prisma.feature.findMany({ where: { lang }, orderBy: { order: "asc" } });
|
||||
|
||||
const aboutDb = await prisma.aboutSection.findUnique({ where: { lang } });
|
||||
const aboutData = aboutDb || {
|
||||
badge: lang === "en" ? "Our Story" : "Hikayemiz",
|
||||
titleLine1: lang === "en" ? "In the" : "Doğanın",
|
||||
@@ -133,7 +170,6 @@ export default async function Home({ params }: { params: Promise<{ lang: string
|
||||
};
|
||||
|
||||
const displayFeatures = dbFeatures.length > 0 ? dbFeatures : defaultFeatures;
|
||||
const instagramPosts = await getInstagramData();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
@@ -205,7 +241,7 @@ export default async function Home({ params }: { params: Promise<{ lang: string
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 divide-x divide-white/8">
|
||||
{displayFeatures.map(({ icon, label, sub }, i) => {
|
||||
const IconComponent = (Icons as any)[icon as string] || Icons.Star;
|
||||
const IconComponent = ICON_MAP[icon as string] || Star;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
@@ -391,6 +427,7 @@ export default async function Home({ params }: { params: Promise<{ lang: string
|
||||
src={act.image}
|
||||
alt={act.title}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-700"
|
||||
/>
|
||||
<div className={`absolute inset-0 bg-gradient-to-t ${i === 0 ? "from-dark/90 via-dark/20" : "from-dark/80"} to-transparent`} />
|
||||
@@ -453,6 +490,7 @@ export default async function Home({ params }: { params: Promise<{ lang: string
|
||||
src={ev.image}
|
||||
alt={ev.title}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-700"
|
||||
/>
|
||||
</div>
|
||||
@@ -522,7 +560,7 @@ export default async function Home({ params }: { params: Promise<{ lang: string
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-dark/60 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col items-center justify-center text-white p-6 text-center">
|
||||
<Icons.Camera className="w-8 h-8 mb-3" />
|
||||
<Camera className="w-8 h-8 mb-3" />
|
||||
<p className="text-xs line-clamp-3">
|
||||
{post.caption?.text}
|
||||
</p>
|
||||
|
||||
@@ -2,6 +2,21 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Coffee, Wine, UtensilsCrossed, Clock } from "lucide-react";
|
||||
import ScrollReveal from "@/components/ScrollReveal";
|
||||
import { buildMeta } from "@/lib/metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
|
||||
const { lang } = await params;
|
||||
return buildMeta(lang, "/restoran", {
|
||||
title: "Restoran & Bar | Kite Beach Akyaka",
|
||||
description: "Ege mutfağının taze lezzetleri, gün batımı kokteyller ve Azmak nehri manzarası. Serpme kahvaltı, Ege mezeleri ve akşam yemekleri.",
|
||||
}, {
|
||||
title: "Restaurant & Bar | Kite Beach Akyaka",
|
||||
description: "Fresh flavors of Aegean cuisine, sunset cocktails and views of the Azmak river. Breakfast spread, Aegean meze and dinner.",
|
||||
});
|
||||
}
|
||||
|
||||
const menuCategories = [
|
||||
{
|
||||
|
||||
+52
-16
@@ -2,29 +2,65 @@
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import prisma from "@/lib/prisma";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
export type ActionState = { error: string } | null;
|
||||
|
||||
export async function login(prevState: ActionState, formData: FormData): Promise<ActionState> {
|
||||
const username = formData.get("username");
|
||||
const password = formData.get("password");
|
||||
const username = formData.get("username") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
if (
|
||||
username === process.env.ADMIN_USERNAME &&
|
||||
password === process.env.ADMIN_PASSWORD
|
||||
) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("admin_session", "true", {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 24 * 7, // 1 week
|
||||
path: "/",
|
||||
});
|
||||
} else {
|
||||
return { error: "Geçersiz kullanıcı adı veya şifre" };
|
||||
if (!username || !password) {
|
||||
return { error: "Kullanıcı adı ve şifre gereklidir" };
|
||||
}
|
||||
|
||||
redirect("/admin");
|
||||
// Fallback to initial .env credentials if no users exist
|
||||
const userCount = await prisma.user.count();
|
||||
if (userCount === 0) {
|
||||
if (
|
||||
username === process.env.ADMIN_USERNAME &&
|
||||
password === process.env.ADMIN_PASSWORD
|
||||
) {
|
||||
// Create the first admin user
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
await setSessionCookie();
|
||||
redirect("/admin");
|
||||
} else {
|
||||
return { error: "Geçersiz kullanıcı adı veya şifre" };
|
||||
}
|
||||
} else {
|
||||
// Normal DB login
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
});
|
||||
|
||||
if (user && await bcrypt.compare(password, user.password)) {
|
||||
await setSessionCookie();
|
||||
redirect("/admin");
|
||||
} else {
|
||||
return { error: "Geçersiz kullanıcı adı veya şifre" };
|
||||
}
|
||||
}
|
||||
|
||||
// This redirect is inside if blocks but typescript requires a return here.
|
||||
return { error: "Bilinmeyen bir hata oluştu" };
|
||||
}
|
||||
|
||||
async function setSessionCookie() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("admin_session", "true", {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 24 * 7, // 1 week
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"use server";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export type UserActionState = {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
} | null;
|
||||
|
||||
export async function getUsers() {
|
||||
return await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function createUser(prevState: UserActionState, formData: FormData): Promise<UserActionState> {
|
||||
const username = formData.get("username") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
if (!username || !password) {
|
||||
return { success: false, message: "Kullanıcı adı ve şifre gereklidir." };
|
||||
}
|
||||
|
||||
try {
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { username }
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return { success: false, message: "Bu kullanıcı adı zaten kullanılıyor." };
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
password: hashedPassword,
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePath("/admin/users");
|
||||
return { success: true, message: "Kullanıcı başarıyla oluşturuldu." };
|
||||
} catch (error) {
|
||||
return { success: false, message: "Kullanıcı oluşturulurken bir hata oluştu." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUser(prevState: UserActionState, formData: FormData): Promise<UserActionState> {
|
||||
const idStr = formData.get("id") as string;
|
||||
const username = formData.get("username") as string;
|
||||
const password = formData.get("password") as string; // Optional during update
|
||||
|
||||
if (!idStr || !username) {
|
||||
return { success: false, message: "Kullanıcı adı gereklidir." };
|
||||
}
|
||||
|
||||
const id = parseInt(idStr, 10);
|
||||
|
||||
try {
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { username }
|
||||
});
|
||||
|
||||
if (existingUser && existingUser.id !== id) {
|
||||
return { success: false, message: "Bu kullanıcı adı zaten kullanılıyor." };
|
||||
}
|
||||
|
||||
const dataToUpdate: any = { username };
|
||||
|
||||
if (password) {
|
||||
dataToUpdate.password = await bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: dataToUpdate,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/users");
|
||||
return { success: true, message: "Kullanıcı başarıyla güncellendi." };
|
||||
} catch (error) {
|
||||
return { success: false, message: "Kullanıcı güncellenirken bir hata oluştu." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUser(id: number) {
|
||||
try {
|
||||
const userCount = await prisma.user.count();
|
||||
|
||||
if (userCount <= 1) {
|
||||
return { success: false, message: "Sistemde en az bir yönetici bulunmalıdır. Bu kullanıcıyı silemezsiniz." };
|
||||
}
|
||||
|
||||
await prisma.user.delete({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
revalidatePath("/admin/users");
|
||||
return { success: true, message: "Kullanıcı başarıyla silindi." };
|
||||
} catch (error) {
|
||||
return { success: false, message: "Kullanıcı silinirken bir hata oluştu." };
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { LayoutDashboard, Settings, Image as ImageIcon, Compass, CalendarDays, Star, Info } from "lucide-react";
|
||||
import { LayoutDashboard, Settings, Image as ImageIcon, Compass, CalendarDays, Star, Info, Users } from "lucide-react";
|
||||
|
||||
export default function SidebarNav() {
|
||||
const pathname = usePathname();
|
||||
@@ -15,6 +15,7 @@ export default function SidebarNav() {
|
||||
{ name: "Özellikler", href: "/admin/features", icon: Star },
|
||||
{ name: "Aktiviteler", href: "/admin/activities", icon: Compass },
|
||||
{ name: "Etkinlikler", href: "/admin/events", icon: CalendarDays },
|
||||
{ name: "Kullanıcılar", href: "/admin/users", icon: Users },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useActionState, useEffect } from "react";
|
||||
import { createUser, updateUser, deleteUser, UserActionState } from "@/app/actions/users";
|
||||
import { User, UserPlus, Edit2, Trash2, X, Save, ShieldAlert } from "lucide-react";
|
||||
|
||||
type UserType = {
|
||||
id: number;
|
||||
username: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export default function UsersClient({ initialUsers }: { initialUsers: UserType[] }) {
|
||||
const [users, setUsers] = useState<UserType[]>(initialUsers);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<UserType | null>(null);
|
||||
|
||||
// Update state when initialUsers change
|
||||
useEffect(() => {
|
||||
setUsers(initialUsers);
|
||||
}, [initialUsers]);
|
||||
|
||||
const [createState, createAction, isCreating] = useActionState<UserActionState, FormData>(
|
||||
createUser,
|
||||
null
|
||||
);
|
||||
|
||||
const [updateState, updateAction, isUpdating] = useActionState<UserActionState, FormData>(
|
||||
updateUser,
|
||||
null
|
||||
);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (confirm("Bu kullanıcıyı silmek istediğinize emin misiniz?")) {
|
||||
const result = await deleteUser(id);
|
||||
if (result?.success) {
|
||||
setUsers(users.filter(u => u.id !== id));
|
||||
} else {
|
||||
alert(result?.message || "Bir hata oluştu.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditClick = (user: UserType) => {
|
||||
setEditingUser(user);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleAddNew = () => {
|
||||
setEditingUser(null);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setEditingUser(null);
|
||||
};
|
||||
|
||||
// Check state to close modal on success
|
||||
useEffect(() => {
|
||||
if (createState?.success || updateState?.success) {
|
||||
setIsEditing(false);
|
||||
setEditingUser(null);
|
||||
}
|
||||
}, [createState, updateState]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* List Section */}
|
||||
{!isEditing && (
|
||||
<div className="bg-white rounded-2xl p-6 shadow-[0_8px_30px_rgb(0,0,0,0.04)] border border-primary/10 relative overflow-hidden group">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-bold text-dark flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-accent" /> Yönetici Hesapları
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleAddNew}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent/90 text-white rounded-xl transition-all font-medium text-sm shadow-md"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" /> Yeni Kullanıcı
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(createState?.message || updateState?.message) && (
|
||||
<div className={`p-4 mb-6 rounded-xl text-sm ${createState?.success || updateState?.success ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>
|
||||
{createState?.message || updateState?.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{users.map((user) => (
|
||||
<div key={user.id} className="flex items-center justify-between p-4 rounded-xl border border-primary/5 hover:border-primary/20 transition-all bg-gray-50/50">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-dark">{user.username}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
Oluşturulma: {new Date(user.createdAt).toLocaleDateString('tr-TR')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEditClick(user)}
|
||||
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(user.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Sil"
|
||||
disabled={users.length <= 1}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<p className="text-center text-gray-500 py-4">Kullanıcı bulunamadı.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Section */}
|
||||
{isEditing && (
|
||||
<div className="bg-white rounded-2xl p-6 shadow-[0_8px_30px_rgb(0,0,0,0.04)] border border-primary/10 relative overflow-hidden">
|
||||
<div className="flex justify-between items-center mb-6 border-b border-primary/10 pb-4">
|
||||
<h2 className="text-xl font-bold text-dark flex items-center gap-2">
|
||||
{editingUser ? <Edit2 className="w-5 h-5 text-accent" /> : <UserPlus className="w-5 h-5 text-accent" />}
|
||||
{editingUser ? "Kullanıcıyı Düzenle" : "Yeni Kullanıcı Ekle"}
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form action={editingUser ? updateAction : createAction} className="space-y-4">
|
||||
{editingUser && <input type="hidden" name="id" value={editingUser.id} />}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-dark">Kullanıcı Adı</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
defaultValue={editingUser?.username || ""}
|
||||
required
|
||||
className="w-full px-4 py-2 bg-gray-50 border border-primary/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent/50 text-dark"
|
||||
placeholder="Örn: admin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-dark">Şifre</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required={!editingUser}
|
||||
className="w-full px-4 py-2 bg-gray-50 border border-primary/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent/50 text-dark"
|
||||
placeholder={editingUser ? "Değiştirmek için yeni şifre girin (İsteğe bağlı)" : "Güçlü bir şifre girin"}
|
||||
/>
|
||||
{editingUser && (
|
||||
<p className="text-xs text-gray-500 mt-1 flex items-center gap-1">
|
||||
<ShieldAlert className="w-3 h-3" /> Şifreyi değiştirmek istemiyorsanız boş bırakın.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(createState?.message || updateState?.message) && (
|
||||
<div className={`p-4 rounded-xl text-sm ${createState?.success || updateState?.success ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>
|
||||
{createState?.message || updateState?.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-primary/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="px-6 py-2 border border-primary/20 text-dark hover:bg-gray-50 rounded-xl transition-colors"
|
||||
>
|
||||
İptal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isCreating || isUpdating}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-6 py-2 bg-accent hover:bg-accent/90 text-white rounded-xl transition-all font-medium disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{isCreating || isUpdating ? "Kaydediliyor..." : "Kaydet"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getUsers } from "@/app/actions/users";
|
||||
import UsersClient from "./UsersClient";
|
||||
|
||||
export default async function UsersPage() {
|
||||
const users = await getUsers();
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full">
|
||||
<h1 className="text-3xl font-bold font-serif mb-6 text-dark">Kullanıcı Yönetimi</h1>
|
||||
<UsersClient initialUsers={users} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export function GET() {
|
||||
const body = `# Kite Beach Akyaka
|
||||
|
||||
> Kite Beach Akyaka is a boutique kitesurf hotel and beach club located on the banks of the Azmak River in Akyaka, Muğla, Turkey. It offers kitesurf lessons (IKO-certified), accommodation, an Aegean restaurant & bar, and various outdoor activities.
|
||||
|
||||
- Turkish site: https://kitebeachakyaka.com.tr/tr
|
||||
- English site: https://kitebeachakyaka.com.tr/en
|
||||
|
||||
## Key Information
|
||||
|
||||
- **Location**: Akyaka Mahallesi, Akçapınar Mevkii, Akyaka, Muğla, Turkey
|
||||
- **Coordinates**: 37.0543° N, 28.3286° E
|
||||
- **Phone / WhatsApp**: +90 533 081 6181
|
||||
- **Email**: info@kitebeachakyaka.com.tr
|
||||
- **Reception hours**: 08:00 – 22:00 daily
|
||||
- **Type**: Boutique hotel, kitesurf school, beach club, restaurant & bar
|
||||
|
||||
## Site Sections
|
||||
|
||||
- Home / Ana Sayfa: https://kitebeachakyaka.com.tr/tr
|
||||
- Activities / Aktiviteler: https://kitebeachakyaka.com.tr/tr/aktiviteler
|
||||
- Events / Etkinlikler: https://kitebeachakyaka.com.tr/tr/etkinlikler
|
||||
- Restaurant & Bar / Restoran: https://kitebeachakyaka.com.tr/tr/restoran
|
||||
- About / Hakkımızda: https://kitebeachakyaka.com.tr/tr/hakkimizda
|
||||
- Gallery / Galeri: https://kitebeachakyaka.com.tr/tr/galeri
|
||||
- Contact / İletişim: https://kitebeachakyaka.com.tr/tr/iletisim
|
||||
|
||||
## Services
|
||||
|
||||
- **Kitesurf school**: IKO-certified instructors, beginner to advanced lessons, equipment rental
|
||||
- **Accommodation**: Boutique rooms with river and sea views
|
||||
- **Restaurant**: Aegean cuisine, breakfast spreads, mezze, fresh seafood, sunset cocktails
|
||||
- **Activities**: Stand-up paddleboarding, cycling, boat tours, photography tours
|
||||
- **Events**: Concerts (Mahmut Orhan), kitesurf festivals, sunset parties, private events
|
||||
|
||||
## About
|
||||
|
||||
Kite Beach Akyaka was founded with the mission of combining kitesurf passion with nature, comfort, and local flavors on the banks of the Azmak River — where the river meets the Gökova Bay. The property emphasises sustainability, zero-waste practices, and sourcing ingredients from local Aegean producers.
|
||||
|
||||
- 10+ years of experience
|
||||
- 500+ happy guests
|
||||
- 3 IKO-certified instructors
|
||||
- Supports sustainable tourism in Akyaka
|
||||
|
||||
## Languages
|
||||
|
||||
This site is fully bilingual: Turkish (default) and English.
|
||||
`;
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "Kite Beach Akyaka",
|
||||
short_name: "Kite Beach",
|
||||
description: "Nehrin huzuru, denizin enerjisi — tek bir noktada.",
|
||||
start_url: "/tr",
|
||||
display: "standalone",
|
||||
background_color: "#0E1A24",
|
||||
theme_color: "#0D3D50",
|
||||
icons: [
|
||||
{ src: "/logo.png", sizes: "any", type: "image/png" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/admin/', '/api/'],
|
||||
},
|
||||
// Allow AI citation/search bots (they surface the site in AI answers)
|
||||
{ userAgent: 'OAI-SearchBot', allow: '/' },
|
||||
{ userAgent: 'PerplexityBot', allow: '/' },
|
||||
{ userAgent: 'anthropic-ai', allow: '/' },
|
||||
{ userAgent: 'ClaudeBot', allow: '/' },
|
||||
// Block training crawlers
|
||||
{ userAgent: 'GPTBot', disallow: '/' },
|
||||
{ userAgent: 'Google-Extended', disallow: '/' },
|
||||
{ userAgent: 'CCBot', disallow: '/' },
|
||||
],
|
||||
sitemap: 'https://kitebeachakyaka.com.tr/sitemap.xml',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
const baseUrl = 'https://kitebeachakyaka.com.tr';
|
||||
const langs = ['tr', 'en'];
|
||||
const routes = [
|
||||
{ path: '', changeFrequency: 'daily' as const, priority: 1.0 },
|
||||
{ path: '/aktiviteler', changeFrequency: 'weekly' as const, priority: 0.9 },
|
||||
{ path: '/etkinlikler', changeFrequency: 'weekly' as const, priority: 0.9 },
|
||||
{ path: '/restoran', changeFrequency: 'weekly' as const, priority: 0.8 },
|
||||
{ path: '/iletisim', changeFrequency: 'monthly' as const, priority: 0.8 },
|
||||
{ path: '/hakkimizda', changeFrequency: 'monthly' as const, priority: 0.7 },
|
||||
{ path: '/galeri', changeFrequency: 'monthly' as const, priority: 0.7 },
|
||||
];
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return langs.flatMap((lang) =>
|
||||
routes.map(({ path, changeFrequency, priority }) => ({
|
||||
url: `${baseUrl}/${lang}${path}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency,
|
||||
priority,
|
||||
alternates: {
|
||||
languages: Object.fromEntries(
|
||||
langs.map((l) => [l, `${baseUrl}/${l}${path}`])
|
||||
),
|
||||
},
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export default function Navbar() {
|
||||
alt="Kite Beach Logo"
|
||||
width={140}
|
||||
height={48}
|
||||
className={`object-contain transition-all duration-300 ${!scrolled && isHome ? 'brightness-0 invert' : ''}`}
|
||||
className={`w-auto h-auto object-contain transition-all duration-300 ${!scrolled && isHome ? 'brightness-0 invert' : ''}`}
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
const BASE_URL = "https://kitebeachakyaka.com.tr";
|
||||
|
||||
export function buildMeta(
|
||||
lang: string,
|
||||
path: string,
|
||||
tr: { title: string; description: string },
|
||||
en: { title: string; description: string }
|
||||
): Metadata {
|
||||
const isEn = lang === "en";
|
||||
return {
|
||||
title: isEn ? en.title : tr.title,
|
||||
description: isEn ? en.description : tr.description,
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}/${lang}${path}`,
|
||||
languages: {
|
||||
"x-default": `${BASE_URL}/tr${path}`,
|
||||
tr: `${BASE_URL}/tr${path}`,
|
||||
en: `${BASE_URL}/en${path}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,10 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
poweredByHeader: false,
|
||||
images: {
|
||||
formats: ["image/avif", "image/webp"],
|
||||
minimumCacheTTL: 2592000,
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
|
||||
Generated
+18
@@ -11,6 +11,7 @@
|
||||
"@formatjs/intl-localematcher": "^0.8.10",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"framer-motion": "^12.40.0",
|
||||
"lucide-react": "^1.17.0",
|
||||
"negotiator": "^1.0.0",
|
||||
@@ -21,6 +22,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/negotiator": "^0.6.4",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.20.0",
|
||||
@@ -2080,6 +2082,13 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -3101,6 +3110,15 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/better-result": {
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@formatjs/intl-localematcher": "^0.8.10",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"framer-motion": "^12.40.0",
|
||||
"lucide-react": "^1.17.0",
|
||||
"negotiator": "^1.0.0",
|
||||
@@ -22,6 +23,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/negotiator": "^0.6.4",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.20.0",
|
||||
|
||||
@@ -83,3 +83,12 @@ model AboutSection {
|
||||
badgeIkoLabel String
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
password String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user