Update carousels, card hover, and IranBay branding.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import aiStyles from "@/styles/ai.module.css";
|
||||
import styles from "./RichTextArea.module.css";
|
||||
|
||||
const FONT_SIZES = [
|
||||
{ value: "2", label: "S" },
|
||||
{ value: "3", label: "M" },
|
||||
{ value: "4", label: "L" },
|
||||
{ value: "5", label: "XL" },
|
||||
] as const;
|
||||
|
||||
function escapeAttr(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<");
|
||||
}
|
||||
|
||||
export default function RichTextArea({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
minHeight = 200,
|
||||
toolbarEnd,
|
||||
aiWorking = false,
|
||||
onUploadImage,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (html: string) => void;
|
||||
disabled?: boolean;
|
||||
minHeight?: number;
|
||||
toolbarEnd?: ReactNode;
|
||||
aiWorking?: boolean;
|
||||
onUploadImage?: (file: File) => Promise<string>;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const [direction, setDirection] = useState<"ltr" | "rtl">("ltr");
|
||||
const [fontSize, setFontSize] = useState("3");
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [imageError, setImageError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
if (node.innerHTML !== value) {
|
||||
node.innerHTML = value || "";
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
function emit() {
|
||||
onChange(ref.current?.innerHTML ?? "");
|
||||
}
|
||||
|
||||
function exec(command: string, commandValue?: string) {
|
||||
if (disabled) return;
|
||||
ref.current?.focus();
|
||||
document.execCommand(command, false, commandValue);
|
||||
emit();
|
||||
}
|
||||
|
||||
function applyFontSize(size: string) {
|
||||
setFontSize(size);
|
||||
exec("fontSize", size);
|
||||
}
|
||||
|
||||
function applyDirection(next: "ltr" | "rtl") {
|
||||
setDirection(next);
|
||||
const node = ref.current;
|
||||
if (!node || disabled) return;
|
||||
node.setAttribute("dir", next);
|
||||
node.focus();
|
||||
emit();
|
||||
}
|
||||
|
||||
async function insertImage(file: File) {
|
||||
if (!onUploadImage || disabled) return;
|
||||
setUploadingImage(true);
|
||||
setImageError("");
|
||||
try {
|
||||
const url = await onUploadImage(file);
|
||||
if (
|
||||
!url.startsWith("/") &&
|
||||
!url.startsWith("http://") &&
|
||||
!url.startsWith("https://")
|
||||
) {
|
||||
throw new Error("Could not add image.");
|
||||
}
|
||||
ref.current?.focus();
|
||||
document.execCommand(
|
||||
"insertHTML",
|
||||
false,
|
||||
`<img src="${escapeAttr(url)}" alt="" />`,
|
||||
);
|
||||
emit();
|
||||
} catch (err) {
|
||||
setImageError(
|
||||
err instanceof Error ? err.message : "Could not add image.",
|
||||
);
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<div className={styles.head}>
|
||||
{label ? <span className={styles.label}>{label}</span> : null}
|
||||
<div className={styles.tools}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tool}
|
||||
disabled={disabled}
|
||||
title="Bold"
|
||||
onClick={() => exec("bold")}
|
||||
>
|
||||
B
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tool}
|
||||
disabled={disabled}
|
||||
title="Italic"
|
||||
onClick={() => exec("italic")}
|
||||
>
|
||||
I
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tool}
|
||||
disabled={disabled}
|
||||
title="Bullet list"
|
||||
onClick={() => exec("insertUnorderedList")}
|
||||
>
|
||||
• List
|
||||
</button>
|
||||
{onUploadImage ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tool}
|
||||
disabled={disabled || uploadingImage}
|
||||
title="Insert image"
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
>
|
||||
{uploadingImage ? "…" : "Image"}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<span className={styles.toolSep} aria-hidden />
|
||||
|
||||
<label className={styles.toolSelectWrap}>
|
||||
<span className={styles.srOnly}>Font size</span>
|
||||
<select
|
||||
className={styles.toolSelect}
|
||||
value={fontSize}
|
||||
disabled={disabled}
|
||||
title="Font size"
|
||||
onChange={(event) => applyFontSize(event.target.value)}
|
||||
>
|
||||
{FONT_SIZES.map((size) => (
|
||||
<option key={size.value} value={size.value}>
|
||||
Size {size.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<span className={styles.toolSep} aria-hidden />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.tool} ${direction === "ltr" ? styles.toolActive : ""}`}
|
||||
disabled={disabled}
|
||||
title="Left to right"
|
||||
onClick={() => applyDirection("ltr")}
|
||||
>
|
||||
LTR
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.tool} ${direction === "rtl" ? styles.toolActive : ""}`}
|
||||
disabled={disabled}
|
||||
title="Right to left"
|
||||
onClick={() => applyDirection("rtl")}
|
||||
>
|
||||
RTL
|
||||
</button>
|
||||
|
||||
<span className={styles.toolSep} aria-hidden />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tool}
|
||||
disabled={disabled}
|
||||
title="Align left"
|
||||
onClick={() => exec("justifyLeft")}
|
||||
>
|
||||
Left
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tool}
|
||||
disabled={disabled}
|
||||
title="Align center"
|
||||
onClick={() => exec("justifyCenter")}
|
||||
>
|
||||
Center
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tool}
|
||||
disabled={disabled}
|
||||
title="Align right"
|
||||
onClick={() => exec("justifyRight")}
|
||||
>
|
||||
Right
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tool}
|
||||
disabled={disabled}
|
||||
title="Justify"
|
||||
onClick={() => exec("justifyFull")}
|
||||
>
|
||||
Justify
|
||||
</button>
|
||||
|
||||
{toolbarEnd ? (
|
||||
<div className={styles.toolEnd}>{toolbarEnd}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{imageError ? <p className={styles.imageError}>{imageError}</p> : null}
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${styles.editor}${aiWorking ? ` ${aiStyles.aiWorkingBorder}` : ""}`}
|
||||
style={{ minHeight }}
|
||||
contentEditable={!disabled}
|
||||
suppressContentEditableWarning
|
||||
dir={direction}
|
||||
onInput={emit}
|
||||
onBlur={emit}
|
||||
/>
|
||||
{onUploadImage ? (
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className={styles.fileInput}
|
||||
disabled={disabled || uploadingImage}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (file) void insertImage(file);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user