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
+574
View File
@@ -0,0 +1,574 @@
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import Underline from "@tiptap/extension-underline";
import { useEffect, useRef, useState } from "react";
import {
Bold,
Italic,
Underline as UnderlineIcon,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Code,
Link as LinkIcon,
Image as ImageIcon,
Minus,
Undo,
Redo,
type LucideIcon,
} from "lucide-react";
interface BlockCommand {
id: string;
title: string;
description: string;
icon: LucideIcon;
command: (e: ReturnType<typeof useEditor> & object) => void;
}
const SLASH_BLOCKS: BlockCommand[] = [
{
id: "h1",
title: "Başlık 1",
description: "Büyük başlık",
icon: Heading1,
command: (e) => e.chain().focus().deleteRange({ from: 0, to: 0 }).toggleHeading({ level: 1 }).run(),
},
{
id: "h2",
title: "Başlık 2",
description: "Orta başlık",
icon: Heading2,
command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run(),
},
{
id: "h3",
title: "Başlık 3",
description: "Küçük başlık",
icon: Heading3,
command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run(),
},
{
id: "ul",
title: "Madde listesi",
description: "Bullet list",
icon: List,
command: (e) => e.chain().focus().toggleBulletList().run(),
},
{
id: "ol",
title: "Numaralı liste",
description: "Ordered list",
icon: ListOrdered,
command: (e) => e.chain().focus().toggleOrderedList().run(),
},
{
id: "quote",
title: "Alıntı",
description: "Blockquote",
icon: Quote,
command: (e) => e.chain().focus().toggleBlockquote().run(),
},
{
id: "code",
title: "Kod bloğu",
description: "Code block",
icon: Code,
command: (e) => e.chain().focus().toggleCodeBlock().run(),
},
{
id: "hr",
title: "Ayırıcı",
description: "Yatay çizgi",
icon: Minus,
command: (e) => e.chain().focus().setHorizontalRule().run(),
},
];
export function RichEditor({
name,
defaultValue,
placeholder = "İçeriği yazmaya başlayın… `/` ile blok seç",
minHeight = 400,
}: {
name: string;
defaultValue?: string | null;
placeholder?: string;
minHeight?: number;
}) {
const [html, setHtml] = useState<string>(defaultValue ?? "");
const [showSlash, setShowSlash] = useState(false);
const [slashQuery, setSlashQuery] = useState("");
const [slashIndex, setSlashIndex] = useState(0);
const [imageModal, setImageModal] = useState(false);
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Underline,
Link.configure({
openOnClick: false,
HTMLAttributes: { class: "text-[var(--sky-600)] underline" },
}),
Image.configure({
HTMLAttributes: { class: "rounded-xl my-4" },
}),
Placeholder.configure({ placeholder }),
],
content: defaultValue || "",
editorProps: {
attributes: {
class:
"prose prose-base max-w-none focus:outline-none px-6 py-6 min-h-[400px]",
},
},
onUpdate: ({ editor }) => {
setHtml(editor.getHTML());
},
immediatelyRender: false,
});
// Slash menu detection
useEffect(() => {
if (!editor) return;
const onUpdate = () => {
const { $from } = editor.state.selection;
const lineText = $from.parent.textContent;
const beforeCursor = lineText.slice(0, $from.parentOffset);
const match = beforeCursor.match(/\/(\w*)$/);
if (match) {
setShowSlash(true);
setSlashQuery(match[1].toLowerCase());
setSlashIndex(0);
} else {
setShowSlash(false);
}
};
editor.on("update", onUpdate);
editor.on("selectionUpdate", onUpdate);
return () => {
editor.off("update", onUpdate);
editor.off("selectionUpdate", onUpdate);
};
}, [editor]);
const filteredBlocks = SLASH_BLOCKS.filter(
(b) =>
!slashQuery ||
b.title.toLowerCase().includes(slashQuery) ||
b.id.includes(slashQuery),
);
// Slash menu keyboard
useEffect(() => {
if (!showSlash || !editor) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setShowSlash(false);
e.preventDefault();
return;
}
if (e.key === "ArrowDown") {
setSlashIndex((i) => Math.min(i + 1, filteredBlocks.length - 1));
e.preventDefault();
return;
}
if (e.key === "ArrowUp") {
setSlashIndex((i) => Math.max(i - 1, 0));
e.preventDefault();
return;
}
if (e.key === "Enter") {
const block = filteredBlocks[slashIndex];
if (block) {
e.preventDefault();
// Delete the slash + query first
const { $from } = editor.state.selection;
const from = $from.pos - (slashQuery.length + 1);
editor.chain().focus().deleteRange({ from, to: $from.pos }).run();
block.command(
editor as unknown as ReturnType<typeof useEditor> & object,
);
setShowSlash(false);
}
return;
}
};
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [showSlash, slashIndex, filteredBlocks, editor, slashQuery]);
if (!editor) return null;
return (
<div>
<input type="hidden" name={name} value={html} />
<div className="overflow-hidden rounded-2xl border border-[var(--border)] bg-white">
{/* Top toolbar — always visible, fixed */}
<div className="flex flex-wrap items-center gap-0.5 border-b border-[var(--border)] bg-[var(--navy-50)]/40 p-2">
<ToolbarButton
active={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleBold().run()}
icon={Bold}
label="Kalın (⌘B)"
/>
<ToolbarButton
active={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleItalic().run()}
icon={Italic}
label="İtalik (⌘I)"
/>
<ToolbarButton
active={editor.isActive("underline")}
onClick={() => editor.chain().focus().toggleUnderline().run()}
icon={UnderlineIcon}
label="Altı çizili"
/>
<Divider />
<ToolbarButton
active={editor.isActive("heading", { level: 1 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
icon={Heading1}
label="Başlık 1"
/>
<ToolbarButton
active={editor.isActive("heading", { level: 2 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
icon={Heading2}
label="Başlık 2"
/>
<ToolbarButton
active={editor.isActive("heading", { level: 3 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 3 }).run()
}
icon={Heading3}
label="Başlık 3"
/>
<Divider />
<ToolbarButton
active={editor.isActive("bulletList")}
onClick={() => editor.chain().focus().toggleBulletList().run()}
icon={List}
label="Madde listesi"
/>
<ToolbarButton
active={editor.isActive("orderedList")}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
icon={ListOrdered}
label="Numaralı liste"
/>
<ToolbarButton
active={editor.isActive("blockquote")}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
icon={Quote}
label="Alıntı"
/>
<ToolbarButton
active={editor.isActive("codeBlock")}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
icon={Code}
label="Kod"
/>
<Divider />
<ToolbarButton
onClick={() => {
const previousUrl = editor.getAttributes("link").href;
const url = window.prompt("URL", previousUrl ?? "https://");
if (url === null) return;
if (url === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
editor
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href: url })
.run();
}}
active={editor.isActive("link")}
icon={LinkIcon}
label="Bağlantı"
/>
<ToolbarButton
onClick={() => setImageModal(true)}
icon={ImageIcon}
label="Görsel ekle"
/>
<Divider />
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
icon={Undo}
label="Geri al"
/>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
icon={Redo}
label="İleri al"
/>
</div>
<div className="relative">
<EditorContent editor={editor} style={{ minHeight }} />
{showSlash && filteredBlocks.length > 0 && (
<div className="absolute left-6 top-12 z-10 w-72 rounded-xl border border-[var(--border)] bg-white p-1 shadow-xl">
<p className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--muted)]">
Blok seç
</p>
{filteredBlocks.map((b, i) => {
const Icon = b.icon;
return (
<button
key={b.id}
type="button"
onMouseEnter={() => setSlashIndex(i)}
onClick={() => {
const { $from } = editor.state.selection;
const from = $from.pos - (slashQuery.length + 1);
editor
.chain()
.focus()
.deleteRange({ from, to: $from.pos })
.run();
b.command(
editor as unknown as ReturnType<typeof useEditor> &
object,
);
setShowSlash(false);
}}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
i === slashIndex
? "bg-[var(--navy-50)]"
: "hover:bg-[var(--navy-50)]/60"
}`}
>
<div className="flex size-8 items-center justify-center rounded-md bg-white text-[var(--navy)]">
<Icon className="size-4" />
</div>
<div>
<p className="font-medium text-[var(--navy)]">{b.title}</p>
<p className="text-[11px] text-[var(--muted)]">
{b.description}
</p>
</div>
</button>
);
})}
</div>
)}
</div>
</div>
{/* Footer info */}
<p className="mt-2 text-xs text-[var(--muted)]">
/ ile blok ekle Seçili metin için araç çubuğu otomatik açılır
</p>
{imageModal && (
<ImagePickerModal
onClose={() => setImageModal(false)}
onPick={(url) => {
editor.chain().focus().setImage({ src: url }).run();
setImageModal(false);
}}
/>
)}
</div>
);
}
function ToolbarButton({
icon: Icon,
label,
active,
onClick,
}: {
icon: LucideIcon;
label: string;
active?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
title={label}
aria-label={label}
className={`flex size-8 items-center justify-center rounded-md transition ${
active
? "bg-[var(--navy)] text-white"
: "text-[var(--muted)] hover:bg-white hover:text-[var(--navy)]"
}`}
>
<Icon className="size-4" />
</button>
);
}
function Divider() {
return <div className="mx-1 h-5 w-px bg-[var(--border)]" />;
}
// ─── Image picker — uses media library API ───────────────
interface MediaFile {
id: string;
name: string;
url: string;
}
function ImagePickerModal({
onClose,
onPick,
}: {
onClose: () => void;
onPick: (url: string) => void;
}) {
const [files, setFiles] = useState<MediaFile[] | null>(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetch("/api/admin/media/list")
.then((r) => r.json())
.then((d) => setFiles(d.files || []))
.catch(() => setFiles([]));
}, []);
function handleFile(file: File) {
setUploading(true);
setProgress(0);
const fd = new FormData();
fd.append("file", file);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/admin/media/upload");
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100));
});
xhr.onload = () => {
try {
const data = JSON.parse(xhr.responseText);
if (data.url) {
onPick(data.url);
}
} catch {
/* ignore */
}
setUploading(false);
};
xhr.onerror = () => {
setUploading(false);
};
xhr.send(fd);
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={onClose}
>
<div
className="w-full max-w-3xl rounded-2xl bg-white p-5 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-base font-semibold text-[var(--navy)]">
Görsel ekle
</h3>
<div className="mt-4 flex gap-2">
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="rounded-full bg-[var(--navy)] px-4 py-2 text-xs font-medium text-white hover:bg-[var(--navy-700)] disabled:opacity-60"
>
{uploading ? `Yükleniyor… ${progress}%` : "Yeni görsel yükle"}
</button>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
e.target.value = "";
}}
/>
</div>
{uploading && (
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-[var(--navy-50)]">
<div
className="h-full bg-[var(--sky)] transition-all"
style={{ width: `${progress}%` }}
/>
</div>
)}
<div className="mt-5 max-h-[60vh] overflow-y-auto">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
Kütüphane
</p>
{!files ? (
<p className="py-8 text-center text-sm text-[var(--muted)]">
Yükleniyor
</p>
) : files.length === 0 ? (
<p className="py-8 text-center text-sm text-[var(--muted)]">
Kütüphane boş. Yeni görsel yükleyin.
</p>
) : (
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4 lg:grid-cols-5">
{files.map((f) => (
<button
key={f.id}
type="button"
onClick={() => onPick(f.url)}
className="aspect-square overflow-hidden rounded-lg border border-[var(--border)] transition hover:border-[var(--sky)]"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={f.url}
alt={f.name}
className="size-full object-cover"
loading="lazy"
/>
</button>
))}
</div>
)}
</div>
<div className="mt-4 text-right">
<button
type="button"
onClick={onClose}
className="rounded-full border border-[var(--border)] bg-white px-4 py-2 text-xs font-medium text-[var(--muted)] hover:text-[var(--navy)]"
>
Kapat
</button>
</div>
</div>
</div>
);
}