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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user