← К статьям
21 июня 2026 г.
t6
Текст-рыба: добавьте содержимое.V"use client";
import { useEffect, useRef, useState, type ChangeEventHandler } from "react";
type Cmd =
| "bold"
| "italic"
| "underline"
| "insertUnorderedList"
| "insertOrderedList"
| "insertImage"
| "formatBlock"
| "undo"
| "redo"
| "createLink"
| "unlink"
| "removeFormat";
function btnClass(active?: boolean) {
return [
"rounded-lg px-2 py-1 text-xs font-semibold transition",
active
? "bg-[var(--text)] text-white"
: "bg-white/40 text-[var(--text)] ring-1 ring-[var(--card-border)] hover:bg-white/60",
].join(" ");
}
export function RichTextEditor({
name,
initialHtml = "",
placeholder = "Введите текст...",
draftKey,
}: {
name: string;
initialHtml?: string;
placeholder?: string;
draftKey?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const hiddenRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
useEffect(() => {
const el = ref.current;
const hidden = hiddenRef.current;
if (!el || !hidden) return;
const savedHtml = draftKey ? window.localStorage.getItem(draftKey) : null;
const html = savedHtml ?? initialHtml;
el.innerHTML = html;
hidden.value = html;
}, [draftKey, initialHtml]);
const syncHidden = () => {
const el = ref.current;
const hidden = hiddenRef.current;
if (!el || !hidden) return;
hidden.value = el.innerHTML;
if (draftKey) window.localStorage.setItem(draftKey, el.innerHTML);
};
const exec = (cmd: Cmd, value?: string) => {
document.execCommand(cmd, false, value);
ref.current?.focus();
syncHidden();
};
const h2 = () => exec("formatBlock", "h2");
const h3 = () => exec("formatBlock", "h3");
const p = () => exec("formatBlock", "p");
const link = () => {
const url = window.prompt("URL");
if (!url) return;
exec("createLink", url);
};
const pickImage = () => imageInputRef.current?.click();
const onPickImage: ChangeEventHandler<HTMLInputElement> = async (e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
try {
setUploading(true);
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/admin/upload-image", {
method: "POST",
body: fd,
credentials: "include",
});
if (!res.ok) {
console.error("Image upload failed", await res.text());
return;
}
const json = (await res.json()) as { url?: string };
if (!json.url) {
console.error("Image upload response did not include url", json);
return;
}
ref.current?.focus();
exec("insertImage", json.url);
} finally {
setUploading(false);
}
};
return (
<div className="flex h-full flex-col overflow-hidden rounded-2xl bg-white/60 ring-1 ring-[var(--card-border)]">
<div className="flex flex-wrap items-center gap-1 border-b border-[var(--card-border)] p-2">
<button type="button" className={btnClass()} onClick={h2}>
H2
</button>
<button type="button" className={btnClass()} onClick={h3}>
H3
</button>
<button type="button" className={btnClass()} onClick={p}>
P
</button>
<span className="mx-1 h-6 w-px bg-[var(--card-border)]" />
<button type="button" className={btnClass()} onClick={() => exec("bold")}>
Bold
</button>
<button
type="button"
className={btnClass()}
onClick={() => exec("italic")}
>
Italic
</button>
<button
type="button"
className={btnClass()}
onClick={() => exec("underline")}
>
Underline
</button>
<span className="mx-1 h-6 w-px bg-[var(--card-border)]" />
<button
type="button"
className={btnClass()}
onClick={() => exec("insertUnorderedList")}
>
• List
</button>
<button
type="button"
className={btnClass()}
onClick={() => exec("insertOrderedList")}
>
1. List
</button>
<span className="mx-1 h-6 w-px bg-[var(--card-border)]" />
<button type="button" className={btnClass()} onClick={link}>
Link
</button>
<button
type="button"
className={btnClass()}
onClick={pickImage}
disabled={uploading}
title="Вставить картинку"
>
{uploading ? "Uploading..." : "Image"}
</button>
<button
type="button"
className={btnClass()}
onClick={() => exec("unlink")}
>
Unlink
</button>
<span className="mx-1 h-6 w-px bg-[var(--card-border)]" />
<button type="button" className={btnClass()} onClick={() => exec("undo")}>
Undo
</button>
<button type="button" className={btnClass()} onClick={() => exec("redo")}>
Redo
</button>
<span className="mx-1 h-6 w-px bg-[var(--card-border)]" />
<button
type="button"
className={btnClass()}
onClick={() => exec("removeFormat")}
>
Clear
</button>
</div>
<div
ref={ref}
className="rte rt min-h-44 flex-1 px-3 py-2 outline-none"
contentEditable
suppressContentEditableWarning
data-placeholder={placeholder}
onInput={syncHidden}
/>
<input ref={hiddenRef} type="hidden" name={name} defaultValue={initialHtml} />
<input
ref={imageInputRef}
className="hidden"
type="file"
accept="image/*"
onChange={onPickImage}
/>
</div>
);
}