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