Files
kovakyazilim/app/admin/(protected)/projeler/form.tsx
T
Ege Can Komur deff889f0c feat: WordPress tarzı rich editor (TipTap + slash menu + MediaPicker)
WordPress Gutenberg + Notion karışımı blok editor. 4 admin formunda
markdown textarea yerine gerçek WYSIWYG editor.

RichEditor component (components/admin/rich-editor.tsx):
- TipTap v3 (@tiptap/react + starter-kit + link + image + placeholder + underline)
- Üst toolbar (her zaman görünür):
  - B / I / U (bold, italic, underline)
  - H1 / H2 / H3
  - Bullet list / Ordered list / Quote / Code block
  - Link (URL prompt)
  - Görsel ekle (MediaPicker modal)
  - Undo / Redo
- Slash menu: '/' yazınca blok seçim menüsü açılır
  - Notion tarzı keyboard navigation (↓↑ Enter Esc)
  - 8 blok tipi: H1/H2/H3/ul/ol/quote/code/hr
- Image picker modal (toolbar görsel butonundan)
  - Mevcut MediaPicker'ı kullanır
  - 'Yeni görsel yükle' (progress bar ile) + 'Kütüphaneden seç' grid
- HTML çıktı (hidden input ile form'a)
- Mevcut content alanlarıyla backward compat

Formlarda değişiklik (4 dosya):
- app/admin/(protected)/blog/form.tsx → content
- app/admin/(protected)/hizmetler/form.tsx → content
- app/admin/(protected)/projeler/form.tsx → content
- app/admin/(protected)/sektorler/form.tsx → content

Public render (lib/content-render.ts):
- renderContent() yardımcısı:
  - İçerik '<' ile başlıyorsa → HTML (direkt döner)
  - Aksi halde → markdown (marked.parse)
- 4 detay sayfası bu helper'ı kullanıyor (blog/[slug], projeler/[slug],
  hizmetler/[slug], sektor/[slug])
- Eski markdown içerikler hala çalışıyor, yeni içerikler HTML olarak gelir

37 route, build temiz.
2026-05-20 18:34:44 +03:00

169 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Save } from "lucide-react";
import {
Checkbox,
Field,
FormActions,
FormShell,
GhostLink,
PageHeader,
PrimaryButton,
Textarea,
} from "@/components/admin/form";
import { MediaPicker } from "@/components/admin/media-picker";
import { RichEditor } from "@/components/admin/rich-editor";
import { saveProject } from "@/lib/admin-actions";
import { listServices } from "@/lib/data";
import type { ProjectRow } from "@/lib/types";
export async function ProjectForm({ project }: { project?: ProjectRow }) {
const services = await listServices();
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} />
<label className="block">
<span className="text-sm font-medium text-[var(--navy)]">
İlgili hizmet
</span>
<select
name="service_slug"
defaultValue={project?.service_slug ?? ""}
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"
>
<option value=""> Yok </option>
{services.map((s) => (
<option key={s.slug} value={s.slug}>
{s.title}
</option>
))}
</select>
<span className="mt-1 block text-xs text-[var(--muted)]">
Bu projenin ait olduğu hizmet detay sayfasında "ilgili projeler" olarak görünür.
</span>
</label>
<Field label="Müşteri" name="client_name" defaultValue={project?.client_name} />
<Field label="Sektör" name="industry" defaultValue={project?.industry} />
<Field
label="Süre"
name="duration"
defaultValue={project?.duration}
placeholder="örn: 3 ay"
/>
<Field
label="Yıl"
name="year"
type="number"
defaultValue={project?.year ?? new Date().getFullYear()}
/>
<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 space-y-5">
<MediaPicker
label="Kapak görseli"
name="image_url"
defaultValue={project?.image_url}
help="Liste ve detay sayfasının üstündeki ana görsel."
/>
<Textarea
label="Kısa açıklama (kart için)"
name="description"
required
defaultValue={project?.description}
rows={3}
/>
<div>
<span className="text-sm font-medium text-[var(--navy)]">
Vaka çalışması içeriği
</span>
<div className="mt-1.5">
<RichEditor
name="content"
defaultValue={project?.content}
placeholder="Müşteri / Problem / Çözüm / Sonuç…"
/>
</div>
<p className="mt-1 text-xs text-[var(--muted)]">
Proje detay sayfasında uzun anlatım olarak gösterilir.
</p>
</div>
<MediaPicker
label="Galeri görselleri"
name="gallery"
multiple
defaultValue={project?.gallery ?? []}
help="Detay sayfasında lightbox galeri olarak gösterilir. Birden fazla görsel yükleyebilirsiniz."
/>
<Textarea
label="Sonuç metrikleri"
name="metrics"
rows={4}
defaultValue={(() => {
if (!project?.metrics) return "";
return project.metrics
.map((raw) => {
try {
const m = JSON.parse(raw) as {
value?: string;
label?: string;
};
return m.value && m.label ? `${m.value} | ${m.label}` : "";
} catch {
return "";
}
})
.filter(Boolean)
.join("\n");
})()}
placeholder={
"+150% | Organik trafik artışı\n2x | Dönüşüm oranı\n-40% | Sayfa yüklenme süresi"
}
help='Her satır "Değer | Etiket". Proje detay sayfasında büyük metric kartları olarak gösterilir.'
/>
</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>
);
}