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
+24
View File
@@ -0,0 +1,24 @@
"use client";
import { Trash2 } from "lucide-react";
export function DeleteButton({
label = "Sil",
confirm = "Bu kaydı silmek istediğinize emin misiniz?",
}: {
label?: string;
confirm?: string;
}) {
return (
<button
type="submit"
onClick={(e) => {
if (!window.confirm(confirm)) e.preventDefault();
}}
className="inline-flex items-center gap-1 rounded-md border border-red-200 bg-white px-2.5 py-1.5 text-xs font-medium text-red-700 transition hover:bg-red-50"
>
<Trash2 className="size-3.5" />
{label}
</button>
);
}
+202
View File
@@ -0,0 +1,202 @@
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
export function PageHeader({
title,
description,
backHref,
action,
}: {
title: string;
description?: string;
backHref?: string;
action?: React.ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-4">
<div>
{backHref && (
<Link
href={backHref}
className="mb-2 inline-flex items-center gap-1 text-xs text-[var(--muted)] hover:text-[var(--navy)]"
>
<ArrowLeft className="size-3" />
Geri
</Link>
)}
<h1 className="text-2xl font-bold text-[var(--navy)]">{title}</h1>
{description && (
<p className="mt-1 text-sm text-[var(--muted)]">{description}</p>
)}
</div>
{action}
</div>
);
}
export function Field({
label,
name,
type = "text",
defaultValue,
placeholder,
required,
help,
}: {
label: string;
name: string;
type?: string;
defaultValue?: string | number | null;
placeholder?: string;
required?: boolean;
help?: string;
}) {
return (
<label className="block">
<span className="text-sm font-medium text-[var(--navy)]">
{label}
{required && <span className="text-red-500"> *</span>}
</span>
<input
name={name}
type={type}
required={required}
defaultValue={defaultValue ?? undefined}
placeholder={placeholder}
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"
/>
{help && <span className="mt-1 block text-xs text-[var(--muted)]">{help}</span>}
</label>
);
}
export function Textarea({
label,
name,
defaultValue,
rows = 4,
placeholder,
required,
help,
}: {
label: string;
name: string;
defaultValue?: string | null;
rows?: number;
placeholder?: string;
required?: boolean;
help?: string;
}) {
return (
<label className="block">
<span className="text-sm font-medium text-[var(--navy)]">
{label}
{required && <span className="text-red-500"> *</span>}
</span>
<textarea
name={name}
required={required}
rows={rows}
defaultValue={defaultValue ?? undefined}
placeholder={placeholder}
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"
/>
{help && <span className="mt-1 block text-xs text-[var(--muted)]">{help}</span>}
</label>
);
}
export function Select({
label,
name,
options,
defaultValue,
}: {
label: string;
name: string;
options: { value: string; label: string }[];
defaultValue?: string | null;
}) {
return (
<label className="block">
<span className="text-sm font-medium text-[var(--navy)]">{label}</span>
<select
name={name}
defaultValue={defaultValue ?? undefined}
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"
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
);
}
export function Checkbox({
label,
name,
defaultChecked,
}: {
label: string;
name: string;
defaultChecked?: boolean;
}) {
return (
<label className="inline-flex items-center gap-2">
<input
type="checkbox"
name={name}
defaultChecked={defaultChecked}
className="size-4 rounded border-[var(--border)] text-[var(--navy)] focus:ring-[var(--sky)]"
/>
<span className="text-sm text-[var(--foreground)]">{label}</span>
</label>
);
}
export function FormShell({ children }: { children: React.ReactNode }) {
return (
<div className="mt-6 rounded-2xl border border-[var(--border)] bg-white p-6 sm:p-8">
{children}
</div>
);
}
export function FormActions({ children }: { children: React.ReactNode }) {
return (
<div className="mt-6 flex flex-wrap items-center justify-end gap-3 border-t border-[var(--border)] pt-6">
{children}
</div>
);
}
export function PrimaryButton({ children }: { children: React.ReactNode }) {
return (
<button
type="submit"
className="inline-flex items-center gap-2 rounded-full bg-[var(--navy)] px-5 py-2.5 text-sm font-medium text-white transition hover:bg-[var(--navy-700)]"
>
{children}
</button>
);
}
export function GhostLink({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
return (
<Link
href={href}
className="text-sm font-medium text-[var(--muted)] hover:text-[var(--navy)]"
>
{children}
</Link>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
Newspaper,
Layers,
Briefcase,
MessageSquareQuote,
Search,
Inbox,
Image as ImageIcon,
type LucideIcon,
} from "lucide-react";
type Item = { href: string; label: string; icon: LucideIcon };
const items: Item[] = [
{ href: "/admin", label: "Pano", icon: LayoutDashboard },
{ href: "/admin/blog", label: "Blog", icon: Newspaper },
{ href: "/admin/hizmetler", label: "Hizmetler", icon: Layers },
{ href: "/admin/projeler", label: "Projeler", icon: Briefcase },
{ href: "/admin/referanslar", label: "Referanslar", icon: MessageSquareQuote },
{ href: "/admin/seo", label: "SEO", icon: Search },
{ href: "/admin/iletisim", label: "Mesajlar", icon: Inbox },
{ href: "/admin/medya", label: "Medya", icon: ImageIcon },
];
export function AdminSidebar() {
const pathname = usePathname();
return (
<aside className="hidden w-64 shrink-0 border-r border-[var(--border)] bg-white md:flex md:flex-col">
<Link
href="/admin"
className="flex items-center gap-3 border-b border-[var(--border)] px-5 py-4"
>
<Image src="/logo.png" alt="Kovak Yazılım" width={36} height={36} />
<div>
<p className="text-sm font-semibold text-[var(--navy)]">Kovak Yazılım</p>
<p className="text-xs text-[var(--muted)]">Yönetim Paneli</p>
</div>
</Link>
<nav className="flex-1 space-y-1 p-3">
{items.map((it) => {
const active =
it.href === "/admin"
? pathname === "/admin"
: pathname.startsWith(it.href);
const Icon = it.icon;
return (
<Link
key={it.href}
href={it.href}
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition ${
active
? "bg-[var(--navy)] text-white"
: "text-[var(--muted)] hover:bg-[var(--navy-50)] hover:text-[var(--navy)]"
}`}
>
<Icon className="size-4" />
{it.label}
</Link>
);
})}
</nav>
<Link
href="/"
target="_blank"
className="border-t border-[var(--border)] px-5 py-3 text-xs text-[var(--muted)] hover:text-[var(--navy)]"
>
Siteyi görüntüle
</Link>
</aside>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { logoutAction } from "@/app/admin/login/actions";
import { LogOut } from "lucide-react";
export function AdminTopbar({ email, name }: { email: string; name?: string }) {
return (
<div className="flex items-center justify-between border-b border-[var(--border)] bg-white px-6 py-3 md:px-10">
<div>
<p className="text-xs text-[var(--muted)]">Giriş yapan</p>
<p className="text-sm font-medium text-[var(--navy)]">
{name || email}
</p>
</div>
<form action={logoutAction}>
<button
type="submit"
className="inline-flex items-center gap-2 rounded-lg border border-[var(--border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--muted)] transition hover:border-red-300 hover:text-red-600"
>
<LogOut className="size-3.5" />
Çıkış
</button>
</form>
</div>
);
}
+1
View File
@@ -7,6 +7,7 @@ const nav = [
{ href: "/", label: "Anasayfa" },
{ href: "/hizmetler", label: "Hizmetler" },
{ href: "/projeler", label: "Projeler" },
{ href: "/blog", label: "Blog" },
{ href: "/hakkimizda", label: "Hakkımızda" },
{ href: "/iletisim", label: "İletişim" },
];
+50
View File
@@ -0,0 +1,50 @@
import { Quote, Star } from "lucide-react";
import Image from "next/image";
import type { TestimonialRow } from "@/lib/types";
export function TestimonialsCarousel({ items }: { items: TestimonialRow[] }) {
return (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{items.map((t) => (
<figure
key={t.$id}
className="relative rounded-2xl border border-[var(--border)] bg-white p-6"
>
<Quote
className="absolute right-5 top-5 size-8 text-[var(--sky-50)]"
aria-hidden
/>
<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>
<blockquote className="mt-4 text-sm leading-relaxed text-[var(--foreground)]">
{t.message}
</blockquote>
<figcaption className="mt-5 flex items-center gap-3 border-t border-[var(--border)] pt-4">
{t.image_url ? (
<Image
src={t.image_url}
alt={t.name}
width={40}
height={40}
className="rounded-full object-cover"
/>
) : (
<div className="flex size-10 items-center justify-center rounded-full bg-[var(--navy-50)] text-sm font-semibold text-[var(--navy)]">
{t.name.charAt(0)}
</div>
)}
<div>
<p className="text-sm font-semibold text-[var(--navy)]">{t.name}</p>
<p className="text-xs text-[var(--muted)]">
{[t.role, t.company].filter(Boolean).join(" — ")}
</p>
</div>
</figcaption>
</figure>
))}
</div>
);
}