Files
kovakyazilim/components/admin/rich-editor.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

575 lines
17 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.
"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>
);
}