feat: admin paneli + blog + testimonials + SEO yöneticisi

Backend altyapısı:
- 4 yeni Appwrite tablosu: blog_posts, testimonials, seo_pages, seo_settings
- Appwrite Storage bucket: kovak-yazilim-media (görsel yüklemeleri)
- Appwrite Auth ile session cookie tabanlı koruma

Admin paneli (/admin):
- Login akışı (email/password) + protected layout
- Dashboard: sayım kartları + hızlı aksiyonlar
- Blog CRUD: markdown content, kapak görseli, draft/published, SEO alanları
- Services CRUD: lucide ikon seçici
- Projects CRUD: teknoloji etiketleri, live URL
- Testimonials CRUD: puanlama
- SEO yöneticisi: global ayarlar + sayfa bazlı override
- Mesaj inbox: status filtreleme + güncelleme
- Medya kütüphanesi: Appwrite Storage upload/delete

Public:
- /blog ve /blog/[slug] sayfaları (markdown render)
- Anasayfaya Testimonials bölümü
- Tüm public sayfalarda generateMetadata + seo_pages override
- Header'a Blog linki

Route yapısı:
- app/(site)/ — public site, Header/Footer ortak
- app/admin/login — auth dışı
- app/admin/(protected)/ — requireUser() korumalı

23 route üretiliyor, public static, admin dynamic.
This commit is contained in:
Ege Can Komur
2026-05-20 02:13:09 +03:00
parent 0f20309e4d
commit f833d429fc
52 changed files with 2999 additions and 81 deletions
+102
View File
@@ -0,0 +1,102 @@
import Image from "next/image";
import Link from "next/link";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ArrowLeft, Calendar } from "lucide-react";
import { marked } from "marked";
import { getPostBySlug } from "@/lib/data";
import { buildMetadata } from "@/lib/seo";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) return { title: "Yazı bulunamadı" };
return buildMetadata(`/blog/${slug}`, {
title: post.seo_title || post.title,
description: post.seo_description || post.excerpt || undefined,
openGraph: {
title: post.seo_title || post.title,
description: post.seo_description || post.excerpt || undefined,
images: post.seo_image || post.cover_image ? [{ url: (post.seo_image || post.cover_image) as string }] : undefined,
type: "article",
},
});
}
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post || post.status !== "published") notFound();
const html = post.content ? marked.parse(post.content, { async: false }) as string : "";
return (
<article className="mx-auto max-w-3xl px-6 py-20">
<Link
href="/blog"
className="inline-flex items-center gap-1 text-sm text-[var(--muted)] hover:text-[var(--navy)]"
>
<ArrowLeft className="size-3.5" /> Tüm yazılar
</Link>
<header className="mt-6 border-b border-[var(--border)] pb-8">
{post.tags && post.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{post.tags.map((t) => (
<span
key={t}
className="rounded-full bg-[var(--sky-50)] px-2.5 py-1 text-xs text-[var(--sky-600)]"
>
{t}
</span>
))}
</div>
)}
<h1 className="mt-4 text-3xl font-bold leading-tight tracking-tight text-[var(--navy)] sm:text-4xl">
{post.title}
</h1>
{post.excerpt && (
<p className="mt-4 text-lg leading-relaxed text-[var(--muted)]">
{post.excerpt}
</p>
)}
<div className="mt-6 flex items-center gap-3 text-xs text-[var(--muted)]">
{post.author && <span>{post.author}</span>}
{post.author && post.published_at && <span></span>}
{post.published_at && (
<span className="inline-flex items-center gap-1">
<Calendar className="size-3" />
{new Date(post.published_at).toLocaleDateString("tr-TR")}
</span>
)}
</div>
</header>
{post.cover_image && (
<div className="relative mt-8 aspect-video overflow-hidden rounded-2xl">
<Image
src={post.cover_image}
alt={post.title}
fill
sizes="(min-width: 1024px) 768px, 100vw"
className="object-cover"
priority
/>
</div>
)}
<div
className="prose prose-lg mt-10 max-w-none text-[var(--foreground)]"
dangerouslySetInnerHTML={{ __html: html }}
/>
</article>
);
}
+84
View File
@@ -0,0 +1,84 @@
import Image from "next/image";
import Link from "next/link";
import type { Metadata } from "next";
import { ArrowRight, Calendar } from "lucide-react";
import { SectionTitle } from "@/components/section-title";
import { listPublishedPosts } from "@/lib/data";
import { buildMetadata } from "@/lib/seo";
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata("/blog", {
title: "Blog",
description: "Yazılım, web tasarım, SEO ve dijital pazarlama üzerine yazılar.",
});
}
export default async function BlogIndex() {
const posts = await listPublishedPosts();
return (
<div className="mx-auto max-w-7xl px-6 py-20">
<SectionTitle
eyebrow="Blog"
title="Yazılım, tasarım ve büyüme üzerine"
description="Sektörden notlar, vaka çalışmaları ve teknik rehberler."
/>
<div className="mt-14">
{posts.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--border)] bg-[var(--navy-50)]/40 p-12 text-center">
<p className="text-sm text-[var(--muted)]">
Henüz yayınlanmış yazı yok.
</p>
</div>
) : (
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{posts.map((p) => (
<article
key={p.$id}
className="group overflow-hidden rounded-2xl border border-[var(--border)] bg-white transition hover:shadow-lg"
>
<Link href={`/blog/${p.slug}`}>
<div className="relative aspect-video overflow-hidden bg-[var(--navy-50)]">
{p.cover_image ? (
<Image
src={p.cover_image}
alt={p.title}
fill
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
className="object-cover transition group-hover:scale-105"
/>
) : (
<div className="flex h-full items-center justify-center text-3xl font-bold text-[var(--navy)]/30">
{p.title.charAt(0)}
</div>
)}
</div>
<div className="p-6">
<p className="flex items-center gap-1.5 text-xs text-[var(--muted)]">
<Calendar className="size-3.5" />
{p.published_at
? new Date(p.published_at).toLocaleDateString("tr-TR")
: "—"}
</p>
<h3 className="mt-2 text-lg font-semibold text-[var(--navy)] group-hover:text-[var(--sky-600)]">
{p.title}
</h3>
{p.excerpt && (
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)] line-clamp-3">
{p.excerpt}
</p>
)}
<span className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-[var(--sky-600)]">
Devamını oku <ArrowRight className="size-3.5" />
</span>
</div>
</Link>
</article>
))}
</div>
)}
</div>
</div>
);
}
@@ -2,12 +2,15 @@ import type { Metadata } from "next";
import Image from "next/image";
import { SectionTitle } from "@/components/section-title";
import { CheckCircle2 } from "lucide-react";
import { buildMetadata } from "@/lib/seo";
export const metadata: Metadata = {
title: "Hakkımızda",
description:
"Kovak Yazılım, Kocaeli merkezli bir teknoloji ajansıdır. Web, mobil ve CRM çözümleri üretir.",
};
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata("/hakkimizda", {
title: "Hakkımızda",
description:
"Kovak Yazılım, Kocaeli merkezli bir teknoloji ajansıdır. Web, mobil ve CRM çözümleri üretir.",
});
}
const values = [
{
@@ -2,12 +2,15 @@ import type { Metadata } from "next";
import { SectionTitle } from "@/components/section-title";
import { ServicesGrid } from "@/components/services-grid";
import { listServices } from "@/lib/data";
import { buildMetadata } from "@/lib/seo";
export const metadata: Metadata = {
title: "Hizmetler",
description:
"Web tasarım, e-ticaret, mobil uygulama, yazılım geliştirme, CRM ve dijital pazarlama hizmetleri.",
};
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata("/hizmetler", {
title: "Hizmetler",
description:
"Web tasarım, e-ticaret, mobil uygulama, yazılım geliştirme, CRM ve dijital pazarlama hizmetleri.",
});
}
export default async function ServicesPage() {
const services = await listServices();
@@ -3,12 +3,15 @@ import { Mail, MapPin, Phone, Clock } from "lucide-react";
import { SectionTitle } from "@/components/section-title";
import { ContactForm } from "@/components/contact-form";
import { siteConfig } from "@/lib/site-config";
import { buildMetadata } from "@/lib/seo";
export const metadata: Metadata = {
title: letişim",
description:
"Projeniz hakkında konuşmak için bize ulaşın. İzmit Sanayi Sitesi, Kocaeli.",
};
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata("/iletisim", {
title: "İletişim",
description:
"Projeniz hakkında konuşmak için bize ulaşın. İzmit Sanayi Sitesi, Kocaeli.",
});
}
export default function ContactPage() {
return (
+16
View File
@@ -0,0 +1,16 @@
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
export default function SiteLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<Header />
<main className="flex-1">{children}</main>
<Footer />
</>
);
}
+25 -2
View File
@@ -1,15 +1,23 @@
import Link from "next/link";
import type { Metadata } from "next";
import { ArrowRight } from "lucide-react";
import { Hero } from "@/components/hero";
import { SectionTitle } from "@/components/section-title";
import { ServicesGrid } from "@/components/services-grid";
import { ProjectsGrid } from "@/components/projects-grid";
import { listProjects, listServices } from "@/lib/data";
import { TestimonialsCarousel } from "@/components/testimonials";
import { listProjects, listServices, listTestimonials } from "@/lib/data";
import { buildMetadata } from "@/lib/seo";
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata("/");
}
export default async function Home() {
const [services, projects] = await Promise.all([
const [services, projects, testimonials] = await Promise.all([
listServices({ featured: true }),
listProjects({ featured: true, limit: 6 }),
listTestimonials({ featured: true }),
]);
return (
@@ -51,6 +59,21 @@ export default async function Home() {
</div>
</section>
{testimonials.length > 0 && (
<section className="border-y border-[var(--border)] bg-[var(--navy-50)]/40 py-20">
<div className="mx-auto max-w-7xl px-6">
<SectionTitle
eyebrow="Referanslar"
title="Müşterilerimiz ne diyor?"
description="Birlikte çalıştığımız markalardan geri bildirimler."
/>
<div className="mt-12">
<TestimonialsCarousel items={testimonials} />
</div>
</div>
</section>
)}
<section className="relative overflow-hidden bg-[var(--navy)] py-20 text-white">
<div className="absolute -left-20 top-0 size-96 rounded-full bg-[var(--sky)]/20 blur-3xl" aria-hidden />
<div className="relative mx-auto max-w-4xl px-6 text-center">
@@ -2,11 +2,14 @@ import type { Metadata } from "next";
import { SectionTitle } from "@/components/section-title";
import { ProjectsGrid } from "@/components/projects-grid";
import { listProjects } from "@/lib/data";
import { buildMetadata } from "@/lib/seo";
export const metadata: Metadata = {
title: "Projeler",
description: "Tamamladığımız web, mobil ve CRM projelerinden seçkiler.",
};
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata("/projeler", {
title: "Projeler",
description: "Tamamladığımız web, mobil ve CRM projelerinden seçkiler.",
});
}
export default async function ProjectsPage() {
const projects = await listProjects();
+2 -2
View File
@@ -1,7 +1,7 @@
"use server";
import { ID } from "node-appwrite";
import { serverTablesDB, DATABASE_ID, TABLES } from "@/lib/appwrite-server";
import { adminDB, DATABASE_ID, TABLES } from "@/lib/appwrite-server";
export type ContactFormState = {
ok: boolean;
@@ -34,7 +34,7 @@ export async function submitContact(
}
try {
await serverTablesDB.createRow({
await adminDB.createRow({
databaseId: DATABASE_ID,
tableId: TABLES.contactMessages,
rowId: ID.unique(),
@@ -0,0 +1,16 @@
import { notFound } from "next/navigation";
import { getRow } from "@/lib/data";
import { TABLES } from "@/lib/appwrite-server";
import type { BlogPostRow } from "@/lib/types";
import { BlogForm } from "../../form";
export default async function EditBlogPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const post = await getRow<BlogPostRow>(TABLES.blogPosts, id);
if (!post) notFound();
return <BlogForm post={post} />;
}
+137
View File
@@ -0,0 +1,137 @@
import {
Field,
FormActions,
FormShell,
GhostLink,
PageHeader,
PrimaryButton,
Select,
Textarea,
} from "@/components/admin/form";
import { saveBlogPost } from "@/lib/admin-actions";
import type { BlogPostRow } from "@/lib/types";
import { Save } from "lucide-react";
export function BlogForm({ post }: { post?: BlogPostRow }) {
return (
<div>
<PageHeader
title={post ? "Yazıyı düzenle" : "Yeni yazı"}
backHref="/admin/blog"
description="Markdown formatında içerik yazabilirsiniz."
/>
<form action={saveBlogPost}>
{post && <input type="hidden" name="id" value={post.$id} />}
<FormShell>
<div className="grid gap-5 md:grid-cols-2">
<Field
label="Başlık"
name="title"
required
defaultValue={post?.title}
placeholder="Yazı başlığı"
/>
<Field
label="Slug"
name="slug"
defaultValue={post?.slug}
placeholder="otomatik-uretilir"
help="Boş bırakırsanız başlıktan üretilir."
/>
<Field label="Yazar" name="author" defaultValue={post?.author} />
<Select
label="Durum"
name="status"
defaultValue={post?.status ?? "draft"}
options={[
{ value: "draft", label: "Taslak" },
{ value: "published", label: "Yayında" },
]}
/>
<Field
label="Yayın tarihi (ISO)"
name="published_at"
type="datetime-local"
defaultValue={post?.published_at?.slice(0, 16)}
help="Boş bırakırsanız yayına alındığı an kullanılır."
/>
<Field
label="Etiketler"
name="tags"
defaultValue={post?.tags?.join(", ")}
placeholder="seo, web tasarım, kocaeli"
help="Virgülle ayırın."
/>
</div>
<div className="mt-5 space-y-5">
<Textarea
label="Özet"
name="excerpt"
defaultValue={post?.excerpt}
rows={3}
placeholder="Liste/kart görünümünde gösterilecek kısa özet"
/>
<Textarea
label="İçerik (Markdown)"
name="content"
defaultValue={post?.content}
rows={14}
placeholder={"# Başlık\n\nMarkdown desteklenir…"}
/>
<Field
label="Kapak görseli URL"
name="cover_image"
type="url"
defaultValue={post?.cover_image}
placeholder="https://…"
help="Medya kütüphanesinden bir görselin view URL'ini yapıştırın."
/>
<Field
label="Kapak file_id"
name="cover_file_id"
defaultValue={post?.cover_file_id}
help="(opsiyonel) Appwrite storage file ID'si"
/>
</div>
<h3 className="mt-8 text-sm font-semibold uppercase tracking-wider text-[var(--muted)]">
SEO
</h3>
<div className="mt-3 grid gap-5 md:grid-cols-2">
<Field
label="SEO başlığı"
name="seo_title"
defaultValue={post?.seo_title}
help="Boş bırakırsanız yazı başlığı kullanılır."
/>
<Field
label="SEO OG görseli"
name="seo_image"
type="url"
defaultValue={post?.seo_image}
/>
</div>
<div className="mt-5">
<Textarea
label="SEO açıklaması"
name="seo_description"
defaultValue={post?.seo_description}
rows={2}
help="150160 karakter ideal."
/>
</div>
<FormActions>
<GhostLink href="/admin/blog">İptal</GhostLink>
<PrimaryButton>
<Save className="size-4" /> Kaydet
</PrimaryButton>
</FormActions>
</FormShell>
</form>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { BlogForm } from "../form";
export default function NewBlogPostPage() {
return <BlogForm />;
}
+103
View File
@@ -0,0 +1,103 @@
import Link from "next/link";
import { Plus, Edit, ExternalLink } from "lucide-react";
import { PageHeader } from "@/components/admin/form";
import { DeleteButton } from "@/components/admin/delete-button";
import { listAllPosts } from "@/lib/data";
import { deleteBlogPost } from "@/lib/admin-actions";
export default async function BlogListPage() {
const posts = await listAllPosts();
return (
<div>
<PageHeader
title="Blog"
description="Yazılarınızı yönetin ve yayınlayın."
action={
<Link
href="/admin/blog/new"
className="inline-flex items-center gap-2 rounded-full bg-[var(--navy)] px-4 py-2 text-sm font-medium text-white transition hover:bg-[var(--navy-700)]"
>
<Plus className="size-4" /> Yeni yazı
</Link>
}
/>
<div className="mt-6 overflow-hidden rounded-2xl border border-[var(--border)] bg-white">
<table className="w-full text-sm">
<thead className="bg-[var(--navy-50)] text-xs uppercase tracking-wider text-[var(--muted)]">
<tr>
<th className="px-4 py-3 text-left">Başlık</th>
<th className="px-4 py-3 text-left">Slug</th>
<th className="px-4 py-3 text-left">Durum</th>
<th className="px-4 py-3 text-left">Yayın</th>
<th className="px-4 py-3 text-right">İşlem</th>
</tr>
</thead>
<tbody>
{posts.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-12 text-center text-[var(--muted)]">
Henüz yazı yok. İlk yazınızı oluşturun.
</td>
</tr>
)}
{posts.map((p) => (
<tr key={p.$id} className="border-t border-[var(--border)]">
<td className="px-4 py-3 font-medium text-[var(--navy)]">{p.title}</td>
<td className="px-4 py-3 text-[var(--muted)]">/{p.slug}</td>
<td className="px-4 py-3">
<StatusBadge status={p.status} />
</td>
<td className="px-4 py-3 text-[var(--muted)]">
{p.published_at
? new Date(p.published_at).toLocaleDateString("tr-TR")
: "—"}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
{p.status === "published" && (
<Link
href={`/blog/${p.slug}`}
target="_blank"
className="rounded-md border border-[var(--border)] p-1.5 text-[var(--muted)] hover:text-[var(--navy)]"
>
<ExternalLink className="size-3.5" />
</Link>
)}
<Link
href={`/admin/blog/${p.$id}/edit`}
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-white px-2.5 py-1.5 text-xs font-medium text-[var(--navy)] hover:bg-[var(--navy-50)]"
>
<Edit className="size-3.5" />
Düzenle
</Link>
<form action={deleteBlogPost}>
<input type="hidden" name="id" value={p.$id} />
<DeleteButton />
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function StatusBadge({ status }: { status?: string | null }) {
const isPub = status === "published";
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
isPub
? "bg-green-100 text-green-800"
: "bg-amber-100 text-amber-800"
}`}
>
{isPub ? "Yayında" : "Taslak"}
</span>
);
}
@@ -0,0 +1,16 @@
import { notFound } from "next/navigation";
import { getRow } from "@/lib/data";
import { TABLES } from "@/lib/appwrite-server";
import type { ServiceRow } from "@/lib/types";
import { ServiceForm } from "../../form";
export default async function EditServicePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const service = await getRow<ServiceRow>(TABLES.services, id);
if (!service) notFound();
return <ServiceForm service={service} />;
}
+90
View File
@@ -0,0 +1,90 @@
import { Save } from "lucide-react";
import {
Checkbox,
Field,
FormActions,
FormShell,
GhostLink,
PageHeader,
PrimaryButton,
Textarea,
} from "@/components/admin/form";
import { saveService } from "@/lib/admin-actions";
import type { ServiceRow } from "@/lib/types";
const ICON_OPTIONS = [
"Globe",
"ShoppingCart",
"Smartphone",
"Code2",
"Users",
"TrendingUp",
"Share2",
"Megaphone",
"Layers",
];
export function ServiceForm({ service }: { service?: ServiceRow }) {
return (
<div>
<PageHeader
title={service ? "Hizmeti düzenle" : "Yeni hizmet"}
backHref="/admin/hizmetler"
/>
<form action={saveService}>
{service && <input type="hidden" name="id" value={service.$id} />}
<FormShell>
<div className="grid gap-5 md:grid-cols-2">
<Field label="Başlık" name="title" required defaultValue={service?.title} />
<Field label="Slug" name="slug" defaultValue={service?.slug} />
<Field
label="Sıra"
name="order"
type="number"
defaultValue={service?.order ?? 0}
/>
<label className="block">
<span className="text-sm font-medium text-[var(--navy)]">İkon</span>
<select
name="icon"
defaultValue={service?.icon ?? "Layers"}
className="mt-1.5 w-full rounded-xl border border-[var(--border)] bg-white px-4 py-2.5 text-sm outline-none focus:border-[var(--sky)] focus:ring-2 focus:ring-[var(--sky)]/20"
>
{ICON_OPTIONS.map((i) => (
<option key={i} value={i}>
{i}
</option>
))}
</select>
<span className="mt-1 block text-xs text-[var(--muted)]">
Lucide icon adı.
</span>
</label>
</div>
<div className="mt-5">
<Textarea
label="Açıklama"
name="description"
required
defaultValue={service?.description}
rows={4}
/>
</div>
<div className="mt-5">
<Checkbox
label="Öne çıkar (Anasayfada göster)"
name="featured"
defaultChecked={service?.featured ?? false}
/>
</div>
<FormActions>
<GhostLink href="/admin/hizmetler">İptal</GhostLink>
<PrimaryButton>
<Save className="size-4" /> Kaydet
</PrimaryButton>
</FormActions>
</FormShell>
</form>
</div>
);
}
@@ -0,0 +1,5 @@
import { ServiceForm } from "../form";
export default function NewServicePage() {
return <ServiceForm />;
}
+80
View File
@@ -0,0 +1,80 @@
import Link from "next/link";
import { Plus, Edit } from "lucide-react";
import { PageHeader } from "@/components/admin/form";
import { DeleteButton } from "@/components/admin/delete-button";
import { listServices } from "@/lib/data";
import { deleteService } from "@/lib/admin-actions";
export default async function ServicesAdminPage() {
const services = await listServices();
return (
<div>
<PageHeader
title="Hizmetler"
description="Anasayfa ve /hizmetler sayfasında gösterilen hizmet kartları."
action={
<Link
href="/admin/hizmetler/new"
className="inline-flex items-center gap-2 rounded-full bg-[var(--navy)] px-4 py-2 text-sm font-medium text-white transition hover:bg-[var(--navy-700)]"
>
<Plus className="size-4" /> Yeni hizmet
</Link>
}
/>
<div className="mt-6 overflow-hidden rounded-2xl border border-[var(--border)] bg-white">
<table className="w-full text-sm">
<thead className="bg-[var(--navy-50)] text-xs uppercase tracking-wider text-[var(--muted)]">
<tr>
<th className="px-4 py-3 text-left">Sıra</th>
<th className="px-4 py-3 text-left">Başlık</th>
<th className="px-4 py-3 text-left">Slug</th>
<th className="px-4 py-3 text-left">İkon</th>
<th className="px-4 py-3 text-left">Öne çıkan</th>
<th className="px-4 py-3 text-right">İşlem</th>
</tr>
</thead>
<tbody>
{services.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-[var(--muted)]">
Hizmet eklenmemiş.
</td>
</tr>
)}
{services.map((s) => (
<tr key={s.$id} className="border-t border-[var(--border)]">
<td className="px-4 py-3 text-[var(--muted)]">{s.order ?? 0}</td>
<td className="px-4 py-3 font-medium text-[var(--navy)]">{s.title}</td>
<td className="px-4 py-3 text-[var(--muted)]">{s.slug}</td>
<td className="px-4 py-3 text-[var(--muted)]">{s.icon ?? "—"}</td>
<td className="px-4 py-3">
{s.featured ? (
<span className="rounded-full bg-[var(--sky-50)] px-2 py-0.5 text-xs text-[var(--sky-600)]">
Öne çıkan
</span>
) : (
<span className="text-xs text-[var(--muted)]"></span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<Link
href={`/admin/hizmetler/${s.$id}/edit`}
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-white px-2.5 py-1.5 text-xs font-medium text-[var(--navy)] hover:bg-[var(--navy-50)]"
>
<Edit className="size-3.5" /> Düzenle
</Link>
<form action={deleteService}>
<input type="hidden" name="id" value={s.$id} />
<DeleteButton />
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
import Link from "next/link";
import { Mail, Phone } from "lucide-react";
import { PageHeader } from "@/components/admin/form";
import { DeleteButton } from "@/components/admin/delete-button";
import { listMessages } from "@/lib/data";
import { deleteMessage, updateMessageStatus } from "@/lib/admin-actions";
import type { ContactMessageRow } from "@/lib/types";
const FILTERS = [
{ value: "", label: "Tümü" },
{ value: "new", label: "Yeni" },
{ value: "read", label: "Okundu" },
{ value: "replied", label: "Yanıtlandı" },
{ value: "archived", label: "Arşiv" },
];
export default async function MessagesAdminPage({
searchParams,
}: {
searchParams: Promise<{ filter?: string }>;
}) {
const sp = await searchParams;
const filter = sp.filter as ContactMessageRow["status"] | undefined;
const messages = await listMessages(filter || undefined);
return (
<div>
<PageHeader
title="İletişim mesajları"
description="Forma gelen mesajları yönetin."
/>
<div className="mt-6 flex flex-wrap gap-2">
{FILTERS.map((f) => {
const active = (filter ?? "") === f.value;
return (
<Link
key={f.value}
href={`/admin/iletisim${f.value ? `?filter=${f.value}` : ""}`}
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition ${
active
? "border-[var(--navy)] bg-[var(--navy)] text-white"
: "border-[var(--border)] bg-white text-[var(--muted)] hover:border-[var(--navy)]"
}`}
>
{f.label}
</Link>
);
})}
</div>
<div className="mt-6 space-y-3">
{messages.length === 0 && (
<div className="rounded-2xl border border-dashed border-[var(--border)] bg-white p-12 text-center text-sm text-[var(--muted)]">
Bu filtrede mesaj yok.
</div>
)}
{messages.map((m) => (
<article
key={m.$id}
className="rounded-2xl border border-[var(--border)] bg-white p-5"
>
<header className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold text-[var(--navy)]">{m.name}</p>
<StatusBadge status={m.status} />
</div>
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-[var(--muted)]">
<a href={`mailto:${m.email}`} className="inline-flex items-center gap-1 hover:text-[var(--navy)]">
<Mail className="size-3" />
{m.email}
</a>
{m.phone && (
<a href={`tel:${m.phone}`} className="inline-flex items-center gap-1 hover:text-[var(--navy)]">
<Phone className="size-3" />
{m.phone}
</a>
)}
<span>{new Date(m.$createdAt).toLocaleString("tr-TR")}</span>
</div>
</div>
<div className="flex items-center gap-2">
<form action={updateMessageStatus} className="flex items-center gap-1">
<input type="hidden" name="id" value={m.$id} />
<select
name="status"
defaultValue={m.status ?? "new"}
className="rounded-md border border-[var(--border)] bg-white px-2 py-1 text-xs"
>
<option value="new">Yeni</option>
<option value="read">Okundu</option>
<option value="replied">Yanıtlandı</option>
<option value="archived">Arşiv</option>
</select>
<button
type="submit"
className="rounded-md bg-[var(--navy)] px-2.5 py-1 text-xs font-medium text-white hover:bg-[var(--navy-700)]"
>
Güncelle
</button>
</form>
<form action={deleteMessage}>
<input type="hidden" name="id" value={m.$id} />
<DeleteButton />
</form>
</div>
</header>
{m.subject && (
<p className="mt-3 text-sm font-medium text-[var(--navy)]">
{m.subject}
</p>
)}
<p className="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-[var(--foreground)]">
{m.message}
</p>
</article>
))}
</div>
</div>
);
}
function StatusBadge({ status }: { status?: string | null }) {
const map: Record<string, { label: string; cls: string }> = {
new: { label: "Yeni", cls: "bg-red-100 text-red-800" },
read: { label: "Okundu", cls: "bg-amber-100 text-amber-800" },
replied: { label: "Yanıtlandı", cls: "bg-green-100 text-green-800" },
archived: { label: "Arşiv", cls: "bg-gray-100 text-gray-700" },
};
const meta = map[status ?? "new"] ?? map.new;
return (
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${meta.cls}`}>
{meta.label}
</span>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { AdminSidebar } from "@/components/admin/sidebar";
import { AdminTopbar } from "@/components/admin/topbar";
export const metadata: Metadata = {
title: { default: "Yönetim", template: "%s | Yönetim Paneli" },
robots: { index: false, follow: false },
};
export default async function ProtectedAdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const user = await requireUser();
return (
<div className="flex min-h-screen bg-[var(--navy-50)]/30">
<AdminSidebar />
<div className="flex-1">
<AdminTopbar email={user.email} name={user.name} />
<div className="px-6 py-8 md:px-10">{children}</div>
</div>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { PageHeader } from "@/components/admin/form";
import { adminStorage, MEDIA_BUCKET_ID } from "@/lib/appwrite-server";
import { Query } from "node-appwrite";
import { Upload } from "lucide-react";
import { DeleteButton } from "@/components/admin/delete-button";
import { uploadMedia, deleteMediaFile } from "@/lib/admin-actions";
async function listFiles() {
try {
const res = await adminStorage.listFiles({
bucketId: MEDIA_BUCKET_ID,
queries: [Query.orderDesc("$createdAt"), Query.limit(100)],
});
return res.files;
} catch {
return [];
}
}
function fileViewUrl(id: string) {
return `${process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT}/storage/buckets/${MEDIA_BUCKET_ID}/files/${id}/view?project=${process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID}`;
}
export default async function MediaPage() {
const files = await listFiles();
async function deleteAction(formData: FormData) {
"use server";
const id = String(formData.get("id"));
await deleteMediaFile(id);
}
return (
<div>
<PageHeader
title="Medya kütüphanesi"
description="Görselleri yükleyin ve URL'lerini kopyalayın."
/>
<form
action={uploadMedia}
encType="multipart/form-data"
className="mt-6 rounded-2xl border border-dashed border-[var(--border)] bg-white p-6"
>
<label className="flex flex-col items-center gap-3 text-center">
<Upload className="size-8 text-[var(--sky-600)]" />
<span className="text-sm font-medium text-[var(--navy)]">
Görsel yüklemek için dosya seçin (max 10 MB)
</span>
<input
name="file"
type="file"
accept="image/*"
required
className="block max-w-md text-sm"
/>
<button
type="submit"
className="rounded-full bg-[var(--navy)] px-5 py-2 text-sm font-medium text-white transition hover:bg-[var(--navy-700)]"
>
Yükle
</button>
</label>
</form>
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{files.length === 0 && (
<div className="col-span-full rounded-2xl border border-dashed border-[var(--border)] bg-white p-12 text-center text-sm text-[var(--muted)]">
Henüz görsel yüklenmemiş.
</div>
)}
{files.map((f) => {
const url = fileViewUrl(f.$id);
return (
<div
key={f.$id}
className="overflow-hidden rounded-xl border border-[var(--border)] bg-white"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={f.name}
className="aspect-square w-full object-cover"
loading="lazy"
/>
<div className="p-3">
<p className="truncate text-xs font-medium text-[var(--navy)]">
{f.name}
</p>
<p className="text-[10px] text-[var(--muted)]">
{(f.sizeOriginal / 1024).toFixed(0)} KB
</p>
<div className="mt-2 flex items-center justify-between gap-2">
<a
href={url}
target="_blank"
rel="noopener"
className="text-xs text-[var(--sky-600)] hover:text-[var(--navy)]"
>
</a>
<form action={deleteAction}>
<input type="hidden" name="id" value={f.$id} />
<DeleteButton label="Sil" />
</form>
</div>
<input
readOnly
value={url}
className="mt-2 w-full truncate rounded-md border border-[var(--border)] bg-[var(--navy-50)] px-2 py-1 text-[10px] text-[var(--muted)]"
/>
</div>
</div>
);
})}
</div>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { adminDB, DATABASE_ID, TABLES } from "@/lib/appwrite-server";
import { Query } from "node-appwrite";
async function safeCount(tableId: string, queries: string[] = []) {
try {
const res = await adminDB.listRows({
databaseId: DATABASE_ID,
tableId,
queries: [...queries, Query.limit(1)],
});
return res.total ?? 0;
} catch {
return 0;
}
}
export default async function AdminDashboard() {
const [posts, drafts, services, projects, testimonials, newMessages, totalMessages] =
await Promise.all([
safeCount(TABLES.blogPosts, [Query.equal("status", "published")]),
safeCount(TABLES.blogPosts, [Query.equal("status", "draft")]),
safeCount(TABLES.services),
safeCount(TABLES.projects),
safeCount(TABLES.testimonials),
safeCount(TABLES.contactMessages, [Query.equal("status", "new")]),
safeCount(TABLES.contactMessages),
]);
const cards = [
{ label: "Yayında blog", value: posts, href: "/admin/blog", accent: "navy" },
{ label: "Taslak blog", value: drafts, href: "/admin/blog", accent: "amber" },
{ label: "Hizmet", value: services, href: "/admin/hizmetler", accent: "navy" },
{ label: "Proje", value: projects, href: "/admin/projeler", accent: "navy" },
{ label: "Referans", value: testimonials, href: "/admin/referanslar", accent: "navy" },
{ label: "Yeni mesaj", value: newMessages, href: "/admin/iletisim?filter=new", accent: "red" },
{ label: "Toplam mesaj", value: totalMessages, href: "/admin/iletisim", accent: "navy" },
];
return (
<div>
<header>
<h1 className="text-2xl font-bold text-[var(--navy)]">Pano</h1>
<p className="mt-1 text-sm text-[var(--muted)]">
Site içeriklerini buradan yönetin.
</p>
</header>
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{cards.map((c) => (
<Link
key={c.label}
href={c.href}
className="group rounded-2xl border border-[var(--border)] bg-white p-5 transition hover:border-[var(--sky)]/40 hover:shadow-md"
>
<p className="text-xs font-medium uppercase tracking-wider text-[var(--muted)]">
{c.label}
</p>
<div className="mt-2 flex items-end justify-between">
<p
className={`text-3xl font-bold ${
c.accent === "red"
? "text-red-600"
: c.accent === "amber"
? "text-amber-600"
: "text-[var(--navy)]"
}`}
>
{c.value}
</p>
<ArrowRight className="size-4 -translate-x-2 text-[var(--muted)] opacity-0 transition group-hover:translate-x-0 group-hover:opacity-100" />
</div>
</Link>
))}
</div>
<section className="mt-10 grid gap-6 lg:grid-cols-2">
<QuickActions />
<RecentLinks />
</section>
</div>
);
}
function QuickActions() {
const actions = [
{ href: "/admin/blog/new", label: "Yeni blog yazısı" },
{ href: "/admin/projeler/new", label: "Yeni proje ekle" },
{ href: "/admin/referanslar/new", label: "Yeni referans" },
{ href: "/admin/seo", label: "SEO ayarları" },
];
return (
<div className="rounded-2xl border border-[var(--border)] bg-white p-6">
<h2 className="text-base font-semibold text-[var(--navy)]">Hızlı aksiyonlar</h2>
<ul className="mt-4 space-y-2">
{actions.map((a) => (
<li key={a.href}>
<Link
href={a.href}
className="flex items-center justify-between rounded-lg border border-[var(--border)] px-3 py-2 text-sm text-[var(--navy)] transition hover:border-[var(--sky)]"
>
{a.label}
<ArrowRight className="size-4 text-[var(--sky-600)]" />
</Link>
</li>
))}
</ul>
</div>
);
}
function RecentLinks() {
return (
<div className="rounded-2xl border border-[var(--border)] bg-white p-6">
<h2 className="text-base font-semibold text-[var(--navy)]">Kısayollar</h2>
<ul className="mt-4 space-y-1 text-sm text-[var(--muted)]">
<li> <Link href="/" target="_blank" className="hover:text-[var(--navy)]">Siteyi yeni sekmede </Link></li>
<li> <Link href="/admin/medya" className="hover:text-[var(--navy)]">Medya kütüphanesi</Link></li>
<li> <Link href="/admin/seo" className="hover:text-[var(--navy)]">SEO yönetimi</Link></li>
</ul>
</div>
);
}
@@ -0,0 +1,16 @@
import { notFound } from "next/navigation";
import { getRow } from "@/lib/data";
import { TABLES } from "@/lib/appwrite-server";
import type { ProjectRow } from "@/lib/types";
import { ProjectForm } from "../../form";
export default async function EditProjectPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const project = await getRow<ProjectRow>(TABLES.projects, id);
if (!project) notFound();
return <ProjectForm project={project} />;
}
+81
View File
@@ -0,0 +1,81 @@
import { Save } from "lucide-react";
import {
Checkbox,
Field,
FormActions,
FormShell,
GhostLink,
PageHeader,
PrimaryButton,
Textarea,
} from "@/components/admin/form";
import { saveProject } from "@/lib/admin-actions";
import type { ProjectRow } from "@/lib/types";
export function ProjectForm({ project }: { project?: ProjectRow }) {
return (
<div>
<PageHeader
title={project ? "Projeyi düzenle" : "Yeni proje"}
backHref="/admin/projeler"
/>
<form action={saveProject}>
{project && <input type="hidden" name="id" value={project.$id} />}
<FormShell>
<div className="grid gap-5 md:grid-cols-2">
<Field label="Başlık" name="title" required defaultValue={project?.title} />
<Field label="Slug" name="slug" defaultValue={project?.slug} />
<Field label="Kategori" name="category" defaultValue={project?.category} />
<Field
label="Yıl"
name="year"
type="number"
defaultValue={project?.year ?? new Date().getFullYear()}
/>
<Field
label="Görsel URL"
name="image_url"
type="url"
defaultValue={project?.image_url}
/>
<Field
label="Canlı URL"
name="live_url"
type="url"
defaultValue={project?.live_url}
/>
<Field
label="Teknolojiler"
name="technologies"
defaultValue={project?.technologies?.join(", ")}
placeholder="Next.js, Appwrite, Tailwind"
help="Virgülle ayırın."
/>
</div>
<div className="mt-5">
<Textarea
label="Açıklama"
name="description"
required
defaultValue={project?.description}
rows={6}
/>
</div>
<div className="mt-5">
<Checkbox
label="Öne çıkar (Anasayfada göster)"
name="featured"
defaultChecked={project?.featured ?? false}
/>
</div>
<FormActions>
<GhostLink href="/admin/projeler">İptal</GhostLink>
<PrimaryButton>
<Save className="size-4" /> Kaydet
</PrimaryButton>
</FormActions>
</FormShell>
</form>
</div>
);
}
@@ -0,0 +1,5 @@
import { ProjectForm } from "../form";
export default function NewProjectPage() {
return <ProjectForm />;
}
+88
View File
@@ -0,0 +1,88 @@
import Link from "next/link";
import { Plus, Edit, ExternalLink } from "lucide-react";
import { PageHeader } from "@/components/admin/form";
import { DeleteButton } from "@/components/admin/delete-button";
import { listProjects } from "@/lib/data";
import { deleteProject } from "@/lib/admin-actions";
export default async function ProjectsAdminPage() {
const projects = await listProjects();
return (
<div>
<PageHeader
title="Projeler"
description="Portföyünüzdeki çalışmaları yönetin."
action={
<Link
href="/admin/projeler/new"
className="inline-flex items-center gap-2 rounded-full bg-[var(--navy)] px-4 py-2 text-sm font-medium text-white transition hover:bg-[var(--navy-700)]"
>
<Plus className="size-4" /> Yeni proje
</Link>
}
/>
<div className="mt-6 overflow-hidden rounded-2xl border border-[var(--border)] bg-white">
<table className="w-full text-sm">
<thead className="bg-[var(--navy-50)] text-xs uppercase tracking-wider text-[var(--muted)]">
<tr>
<th className="px-4 py-3 text-left">Başlık</th>
<th className="px-4 py-3 text-left">Kategori</th>
<th className="px-4 py-3 text-left">Yıl</th>
<th className="px-4 py-3 text-left">Öne çıkan</th>
<th className="px-4 py-3 text-right">İşlem</th>
</tr>
</thead>
<tbody>
{projects.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-12 text-center text-[var(--muted)]">
Henüz proje yok.
</td>
</tr>
)}
{projects.map((p) => (
<tr key={p.$id} className="border-t border-[var(--border)]">
<td className="px-4 py-3 font-medium text-[var(--navy)]">{p.title}</td>
<td className="px-4 py-3 text-[var(--muted)]">{p.category ?? "—"}</td>
<td className="px-4 py-3 text-[var(--muted)]">{p.year ?? "—"}</td>
<td className="px-4 py-3">
{p.featured ? (
<span className="rounded-full bg-[var(--sky-50)] px-2 py-0.5 text-xs text-[var(--sky-600)]">
Öne çıkan
</span>
) : (
<span className="text-xs text-[var(--muted)]"></span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
{p.live_url && (
<a
href={p.live_url}
target="_blank"
rel="noopener"
className="rounded-md border border-[var(--border)] p-1.5 text-[var(--muted)] hover:text-[var(--navy)]"
>
<ExternalLink className="size-3.5" />
</a>
)}
<Link
href={`/admin/projeler/${p.$id}/edit`}
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-white px-2.5 py-1.5 text-xs font-medium text-[var(--navy)] hover:bg-[var(--navy-50)]"
>
<Edit className="size-3.5" /> Düzenle
</Link>
<form action={deleteProject}>
<input type="hidden" name="id" value={p.$id} />
<DeleteButton />
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,16 @@
import { notFound } from "next/navigation";
import { getRow } from "@/lib/data";
import { TABLES } from "@/lib/appwrite-server";
import type { TestimonialRow } from "@/lib/types";
import { TestimonialForm } from "../../form";
export default async function EditTestimonialPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const row = await getRow<TestimonialRow>(TABLES.testimonials, id);
if (!row) notFound();
return <TestimonialForm row={row} />;
}
@@ -0,0 +1,64 @@
import { Save } from "lucide-react";
import {
Checkbox,
Field,
FormActions,
FormShell,
GhostLink,
PageHeader,
PrimaryButton,
Textarea,
} from "@/components/admin/form";
import { saveTestimonial } from "@/lib/admin-actions";
import type { TestimonialRow } from "@/lib/types";
export function TestimonialForm({ row }: { row?: TestimonialRow }) {
return (
<div>
<PageHeader
title={row ? "Referansı düzenle" : "Yeni referans"}
backHref="/admin/referanslar"
/>
<form action={saveTestimonial}>
{row && <input type="hidden" name="id" value={row.$id} />}
<FormShell>
<div className="grid gap-5 md:grid-cols-2">
<Field label="İsim" name="name" required defaultValue={row?.name} />
<Field label="Görsel URL" name="image_url" type="url" defaultValue={row?.image_url} />
<Field label="Pozisyon" name="role" defaultValue={row?.role} />
<Field label="Firma" name="company" defaultValue={row?.company} />
<Field
label="Puan (15)"
name="rating"
type="number"
defaultValue={row?.rating ?? 5}
/>
<Field label="Sıra" name="order" type="number" defaultValue={row?.order ?? 0} />
</div>
<div className="mt-5">
<Textarea
label="Yorum"
name="message"
required
rows={5}
defaultValue={row?.message}
/>
</div>
<div className="mt-5">
<Checkbox
label="Öne çıkar"
name="featured"
defaultChecked={row?.featured ?? false}
/>
</div>
<FormActions>
<GhostLink href="/admin/referanslar">İptal</GhostLink>
<PrimaryButton>
<Save className="size-4" /> Kaydet
</PrimaryButton>
</FormActions>
</FormShell>
</form>
</div>
);
}
@@ -0,0 +1,5 @@
import { TestimonialForm } from "../form";
export default function NewTestimonialPage() {
return <TestimonialForm />;
}
@@ -0,0 +1,73 @@
import Link from "next/link";
import { Plus, Edit, Star } from "lucide-react";
import { PageHeader } from "@/components/admin/form";
import { DeleteButton } from "@/components/admin/delete-button";
import { listTestimonials } from "@/lib/data";
import { deleteTestimonial } from "@/lib/admin-actions";
export default async function TestimonialsAdminPage() {
const items = await listTestimonials();
return (
<div>
<PageHeader
title="Referanslar"
description="Müşteri yorumları ve değerlendirmeleri."
action={
<Link
href="/admin/referanslar/new"
className="inline-flex items-center gap-2 rounded-full bg-[var(--navy)] px-4 py-2 text-sm font-medium text-white transition hover:bg-[var(--navy-700)]"
>
<Plus className="size-4" /> Yeni referans
</Link>
}
/>
<div className="mt-6 grid gap-4 md:grid-cols-2">
{items.length === 0 && (
<div className="md:col-span-2 rounded-2xl border border-dashed border-[var(--border)] bg-white p-12 text-center text-sm text-[var(--muted)]">
Henüz referans eklenmemiş.
</div>
)}
{items.map((t) => (
<div
key={t.$id}
className="rounded-2xl border border-[var(--border)] bg-white p-5"
>
<div className="flex items-start justify-between">
<div>
<p className="text-base font-semibold text-[var(--navy)]">{t.name}</p>
<p className="text-xs text-[var(--muted)]">
{[t.role, t.company].filter(Boolean).join(" — ") || "—"}
</p>
</div>
<div className="flex items-center gap-0.5 text-amber-500">
{Array.from({ length: t.rating ?? 5 }).map((_, i) => (
<Star key={i} className="size-3.5 fill-current" />
))}
</div>
</div>
<p className="mt-3 text-sm leading-relaxed text-[var(--foreground)] line-clamp-4">
{t.message}
</p>
<div className="mt-4 flex items-center justify-between border-t border-[var(--border)] pt-3">
<span className="text-xs text-[var(--muted)]">
Sıra: {t.order ?? 0} {t.featured && "• Öne çıkan"}
</span>
<div className="flex items-center gap-2">
<Link
href={`/admin/referanslar/${t.$id}/edit`}
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-white px-2.5 py-1.5 text-xs font-medium text-[var(--navy)] hover:bg-[var(--navy-50)]"
>
<Edit className="size-3.5" /> Düzenle
</Link>
<form action={deleteTestimonial}>
<input type="hidden" name="id" value={t.$id} />
<DeleteButton />
</form>
</div>
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,16 @@
import { notFound } from "next/navigation";
import { getRow } from "@/lib/data";
import { TABLES } from "@/lib/appwrite-server";
import type { SeoPageRow } from "@/lib/types";
import { SeoPageForm } from "../../page-form";
export default async function EditSeoPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const row = await getRow<SeoPageRow>(TABLES.seoPages, id);
if (!row) notFound();
return <SeoPageForm row={row} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { SeoPageForm } from "../page-form";
export default function NewSeoPage() {
return <SeoPageForm />;
}
+70
View File
@@ -0,0 +1,70 @@
import { Save } from "lucide-react";
import {
Checkbox,
Field,
FormActions,
FormShell,
GhostLink,
PageHeader,
PrimaryButton,
Textarea,
} from "@/components/admin/form";
import { saveSeoPage } from "@/lib/admin-actions";
import type { SeoPageRow } from "@/lib/types";
export function SeoPageForm({ row }: { row?: SeoPageRow }) {
return (
<div>
<PageHeader
title={row ? "Sayfa SEO override" : "Yeni sayfa SEO override"}
backHref="/admin/seo"
description="Sadece bu path için title/description/og bilgisini değiştir."
/>
<form action={saveSeoPage}>
{row && <input type="hidden" name="id" value={row.$id} />}
<FormShell>
<div className="grid gap-5">
<Field
label="Sayfa yolu (path)"
name="path"
required
defaultValue={row?.path}
placeholder="/hizmetler"
help="Örn: /, /hizmetler, /blog/yeni-yazi"
/>
<Field label="Başlık" name="title" defaultValue={row?.title} />
<Textarea
label="Açıklama"
name="description"
rows={3}
defaultValue={row?.description}
/>
<Field
label="OG görseli"
name="og_image"
type="url"
defaultValue={row?.og_image}
/>
<Field
label="Canonical URL"
name="canonical"
type="url"
defaultValue={row?.canonical}
/>
<Checkbox
label="Aramada gizle (noindex)"
name="noindex"
defaultChecked={row?.noindex ?? false}
/>
</div>
<FormActions>
<GhostLink href="/admin/seo">İptal</GhostLink>
<PrimaryButton>
<Save className="size-4" /> Kaydet
</PrimaryButton>
</FormActions>
</FormShell>
</form>
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
import Link from "next/link";
import { Edit, Plus, Save } from "lucide-react";
import {
Field,
FormActions,
FormShell,
PageHeader,
PrimaryButton,
Textarea,
} from "@/components/admin/form";
import { DeleteButton } from "@/components/admin/delete-button";
import { getSeoSettings, listSeoPages } from "@/lib/data";
import { deleteSeoPage, saveSeoSettings } from "@/lib/admin-actions";
export default async function SeoAdminPage() {
const [settings, pages] = await Promise.all([
getSeoSettings(),
listSeoPages(),
]);
return (
<div className="space-y-12">
<section>
<PageHeader
title="SEO yönetimi"
description="Global site ayarları ve sayfa bazlı meta override."
/>
<form action={saveSeoSettings}>
<FormShell>
<h2 className="text-base font-semibold text-[var(--navy)]">
Global ayarlar
</h2>
<p className="mt-1 text-xs text-[var(--muted)]">
Tüm sayfalarda varsayılan olarak kullanılır.
</p>
<div className="mt-6 grid gap-5 md:grid-cols-2">
<Field
label="Site adı"
name="site_name"
defaultValue={settings?.site_name}
placeholder="Kovak Yazılım"
/>
<Field
label="Varsayılan OG görseli"
name="default_og_image"
type="url"
defaultValue={settings?.default_og_image}
/>
<Field
label="Twitter handle"
name="twitter_handle"
defaultValue={settings?.twitter_handle}
placeholder="@kovakyazilim"
/>
<Field
label="LinkedIn URL"
name="linkedin_url"
type="url"
defaultValue={settings?.linkedin_url}
/>
<Field
label="Facebook URL"
name="facebook_url"
type="url"
defaultValue={settings?.facebook_url}
/>
<Field
label="Instagram URL"
name="instagram_url"
type="url"
defaultValue={settings?.instagram_url}
/>
<Field
label="Google Site Verification"
name="google_site_verification"
defaultValue={settings?.google_site_verification}
/>
<Field
label="Google Tag Manager ID"
name="gtm_id"
defaultValue={settings?.gtm_id}
placeholder="GTM-XXXXXXX"
/>
</div>
<div className="mt-5">
<Textarea
label="Site açıklaması (varsayılan)"
name="site_description"
rows={2}
defaultValue={settings?.site_description}
/>
</div>
<FormActions>
<PrimaryButton>
<Save className="size-4" /> Global ayarları kaydet
</PrimaryButton>
</FormActions>
</FormShell>
</form>
</section>
<section>
<div className="flex items-end justify-between">
<div>
<h2 className="text-xl font-bold text-[var(--navy)]">Sayfa override'ları</h2>
<p className="mt-1 text-sm text-[var(--muted)]">
Belirli bir sayfa yolu için title/description/og override edin.
</p>
</div>
<Link
href="/admin/seo/new"
className="inline-flex items-center gap-2 rounded-full bg-[var(--navy)] px-4 py-2 text-sm font-medium text-white transition hover:bg-[var(--navy-700)]"
>
<Plus className="size-4" /> Sayfa override ekle
</Link>
</div>
<div className="mt-6 overflow-hidden rounded-2xl border border-[var(--border)] bg-white">
<table className="w-full text-sm">
<thead className="bg-[var(--navy-50)] text-xs uppercase tracking-wider text-[var(--muted)]">
<tr>
<th className="px-4 py-3 text-left">Path</th>
<th className="px-4 py-3 text-left">Başlık</th>
<th className="px-4 py-3 text-left">Noindex</th>
<th className="px-4 py-3 text-right">İşlem</th>
</tr>
</thead>
<tbody>
{pages.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-12 text-center text-[var(--muted)]">
Override eklenmemiş.
</td>
</tr>
)}
{pages.map((p) => (
<tr key={p.$id} className="border-t border-[var(--border)]">
<td className="px-4 py-3 font-mono text-xs text-[var(--navy)]">{p.path}</td>
<td className="px-4 py-3 text-[var(--muted)]">{p.title ?? "—"}</td>
<td className="px-4 py-3">
{p.noindex ? (
<span className="rounded-full bg-red-100 px-2 py-0.5 text-xs text-red-800">
noindex
</span>
) : (
<span className="text-xs text-[var(--muted)]"></span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<Link
href={`/admin/seo/${p.$id}/edit`}
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-white px-2.5 py-1.5 text-xs font-medium text-[var(--navy)] hover:bg-[var(--navy-50)]"
>
<Edit className="size-3.5" /> Düzenle
</Link>
<form action={deleteSeoPage}>
<input type="hidden" name="id" value={p.$id} />
<DeleteButton />
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { Account, Client } from "node-appwrite";
import { SESSION_COOKIE } from "@/lib/auth";
const endpoint = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT!;
const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID!;
export type LoginState = { error?: string };
export async function loginAction(
_prev: LoginState | undefined,
formData: FormData,
): Promise<LoginState> {
const email = String(formData.get("email") ?? "").trim();
const password = String(formData.get("password") ?? "");
if (!email || !password) return { error: "E-posta ve şifre zorunlu" };
try {
const client = new Client().setEndpoint(endpoint).setProject(projectId);
const account = new Account(client);
const session = await account.createEmailPasswordSession({ email, password });
const store = await cookies();
store.set(SESSION_COOKIE, session.secret, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
expires: new Date(session.expire),
});
} catch (err) {
const msg = err instanceof Error ? err.message : "Giriş başarısız";
return { error: msg };
}
redirect("/admin");
}
export async function logoutAction() {
const store = await cookies();
store.delete(SESSION_COOKIE);
redirect("/admin/login");
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useActionState } from "react";
import { loginAction, type LoginState } from "./actions";
import { AlertCircle, Loader2, LogIn } from "lucide-react";
const initial: LoginState = {};
export function LoginForm() {
const [state, action, pending] = useActionState(loginAction, initial);
return (
<form action={action} className="space-y-4">
<div>
<label className="text-sm font-medium text-[var(--navy)]">E-posta</label>
<input
name="email"
type="email"
autoComplete="email"
required
className="mt-1.5 w-full rounded-xl border border-[var(--border)] bg-white px-4 py-2.5 text-sm outline-none transition focus:border-[var(--sky)] focus:ring-2 focus:ring-[var(--sky)]/20"
placeholder="admin@kovakyazilim.com"
/>
</div>
<div>
<label className="text-sm font-medium text-[var(--navy)]">Şifre</label>
<input
name="password"
type="password"
autoComplete="current-password"
required
className="mt-1.5 w-full rounded-xl border border-[var(--border)] bg-white px-4 py-2.5 text-sm outline-none transition focus:border-[var(--sky)] focus:ring-2 focus:ring-[var(--sky)]/20"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={pending}
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-[var(--navy)] px-4 py-2.5 text-sm font-medium text-white transition hover:bg-[var(--navy-700)] disabled:opacity-60"
>
{pending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<LogIn className="size-4" />
)}
Giriş Yap
</button>
{state?.error && (
<div className="flex items-start gap-2 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-800">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{state.error}</span>
</div>
)}
</form>
);
}
+29
View File
@@ -0,0 +1,29 @@
import Image from "next/image";
import type { Metadata } from "next";
import { LoginForm } from "./form";
export const metadata: Metadata = {
title: "Yönetici Girişi",
robots: { index: false, follow: false },
};
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-[var(--navy-50)] via-white to-[var(--sky-50)] px-6 py-12">
<div className="w-full max-w-md rounded-2xl border border-[var(--border)] bg-white p-8 shadow-xl shadow-[var(--navy)]/5">
<div className="flex flex-col items-center text-center">
<Image src="/logo.png" alt="Kovak Yazılım" width={56} height={56} />
<h1 className="mt-4 text-xl font-semibold text-[var(--navy)]">
Yönetici Paneli
</h1>
<p className="mt-1 text-sm text-[var(--muted)]">
Devam etmek için giriş yapın
</p>
</div>
<div className="mt-8">
<LoginForm />
</div>
</div>
</div>
);
}
+1 -5
View File
@@ -1,8 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
import { siteConfig } from "@/lib/site-config";
const geistSans = Geist({
@@ -40,9 +38,7 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col bg-white text-[var(--foreground)]">
<Header />
<main className="flex-1">{children}</main>
<Footer />
{children}
</body>
</html>
);