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
+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>
);
}