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.
This commit is contained in:
Ege Can Komur
2026-05-20 18:34:44 +03:00
parent 4d5186ff0c
commit deff889f0c
12 changed files with 1344 additions and 49 deletions
+2 -2
View File
@@ -3,7 +3,7 @@ import Link from "next/link";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ArrowLeft, Calendar } from "lucide-react";
import { marked } from "marked";
import { renderContent } from "@/lib/content-render";
import { getPostBySlug } from "@/lib/data";
import { buildMetadata } from "@/lib/seo";
@@ -36,7 +36,7 @@ export default async function BlogPostPage({
const post = await getPostBySlug(slug);
if (!post || post.status !== "published") notFound();
const html = post.content ? marked.parse(post.content, { async: false }) as string : "";
const html = renderContent(post.content);
return (
<article className="mx-auto max-w-3xl px-6 py-20">
+2 -4
View File
@@ -3,7 +3,7 @@ import Link from "next/link";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ArrowLeft, ArrowRight, CheckCircle2 } from "lucide-react";
import { marked } from "marked";
import { renderContent } from "@/lib/content-render";
import { getServiceBySlug, listProjects } from "@/lib/data";
import { buildMetadata } from "@/lib/seo";
import { Icon } from "@/components/icon";
@@ -55,9 +55,7 @@ export default async function ServiceDetailPage({
]);
const faqItems = parseFaq(service.faq);
const html = service.content
? (marked.parse(service.content, { async: false }) as string)
: "";
const html = renderContent(service.content);
return (
<>
+2 -4
View File
@@ -3,7 +3,7 @@ import Link from "next/link";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ArrowLeft, Building2, Calendar, Clock, ExternalLink, Tag } from "lucide-react";
import { marked } from "marked";
import { renderContent } from "@/lib/content-render";
import { getProjectBySlug, listProjects } from "@/lib/data";
import { buildMetadata } from "@/lib/seo";
import { Gallery } from "@/components/gallery";
@@ -54,9 +54,7 @@ export default async function ProjectDetailPage({
const project = await getProjectBySlug(slug);
if (!project) notFound();
const html = project.content
? (marked.parse(project.content, { async: false }) as string)
: "";
const html = renderContent(project.content);
const metrics = parseMetrics(project.metrics);
+2 -4
View File
@@ -3,7 +3,7 @@ import Link from "next/link";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ArrowRight, ArrowLeft, CheckCircle2 } from "lucide-react";
import { marked } from "marked";
import { renderContent } from "@/lib/content-render";
import {
getIndustryBySlug,
listProjects,
@@ -65,9 +65,7 @@ export default async function IndustryPage({
]);
const faqItems = parseFaq(industry.faq);
const html = industry.content
? (marked.parse(industry.content, { async: false }) as string)
: "";
const html = renderContent(industry.content);
return (
<>
+14 -7
View File
@@ -9,6 +9,7 @@ import {
Textarea,
} from "@/components/admin/form";
import { MediaPicker } from "@/components/admin/media-picker";
import { RichEditor } from "@/components/admin/rich-editor";
import { saveBlogPost } from "@/lib/admin-actions";
import type { BlogPostRow } from "@/lib/types";
import { Save } from "lucide-react";
@@ -75,13 +76,19 @@ export function BlogForm({ post }: { post?: BlogPostRow }) {
rows={3}
placeholder="Liste/kart görünümünde gösterilecek kısa özet"
/>
<Textarea
label="İçerik (Markdown)"
name="content"
defaultValue={post?.content}
rows={14}
placeholder={"# Başlık\n\nMarkdown desteklenir…"}
/>
<div>
<span className="text-sm font-medium text-[var(--navy)]">
İçerik
</span>
<div className="mt-1.5">
<RichEditor
name="content"
defaultValue={post?.content}
placeholder="Yazıya başlayın… `/` ile başlık, görsel, liste ekleyin"
minHeight={500}
/>
</div>
</div>
<MediaPicker
label="Kapak görseli"
name="cover_image"
+16 -8
View File
@@ -10,6 +10,7 @@ import {
Textarea,
} from "@/components/admin/form";
import { MediaPicker } from "@/components/admin/media-picker";
import { RichEditor } from "@/components/admin/rich-editor";
import { saveService } from "@/lib/admin-actions";
import type { FaqItem, ServiceRow } from "@/lib/types";
@@ -90,14 +91,21 @@ export function ServiceForm({ service }: { service?: ServiceRow }) {
help="Listede ve anasayfa kartında gösterilir."
/>
<Textarea
label="Detay içerik (Markdown)"
name="content"
defaultValue={service?.content}
rows={10}
placeholder={"## Yaklaşım\n\nMarkdown desteklenir…"}
help="Hizmet detay sayfasında ana içerik olarak gösterilir."
/>
<div>
<span className="text-sm font-medium text-[var(--navy)]">
Detay içerik
</span>
<div className="mt-1.5">
<RichEditor
name="content"
defaultValue={service?.content}
placeholder="Hizmetin detaylarını anlatın… `/` ile blok ekleyin"
/>
</div>
<p className="mt-1 text-xs text-[var(--muted)]">
Hizmet detay sayfasında ana içerik olarak gösterilir.
</p>
</div>
<Textarea
label="Özellikler"
+16 -10
View File
@@ -10,6 +10,7 @@ import {
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";
@@ -95,16 +96,21 @@ export async function ProjectForm({ project }: { project?: ProjectRow }) {
rows={3}
/>
<Textarea
label="Vaka çalışması içeriği (Markdown)"
name="content"
defaultValue={project?.content}
rows={12}
placeholder={
"## Müşteri\n\n## Problem\n\n## Çözüm\n\n## Sonuç"
}
help="Proje detay sayfasında uzun anlatım olarak gösterilir."
/>
<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"
+13 -7
View File
@@ -10,6 +10,7 @@ import {
Textarea,
} from "@/components/admin/form";
import { MediaPicker } from "@/components/admin/media-picker";
import { RichEditor } from "@/components/admin/rich-editor";
import { saveIndustry } from "@/lib/admin-actions";
import type { FaqItem, IndustryRow } from "@/lib/types";
@@ -75,13 +76,18 @@ export function IndustryForm({ row }: { row?: IndustryRow }) {
placeholder="Avukatlar için KVKK uyumlu, randevu sistemli, SEO odaklı modern web siteleri."
/>
<Textarea
label="İçerik (Markdown)"
name="content"
rows={10}
defaultValue={row?.content}
placeholder="## Sektör özellikleri\n\nAvukatlar için..."
/>
<div>
<span className="text-sm font-medium text-[var(--navy)]">
İçerik
</span>
<div className="mt-1.5">
<RichEditor
name="content"
defaultValue={row?.content}
placeholder="Sektörünüz için içerik yazın…"
/>
</div>
</div>
<Textarea
label="Özellikler"