Update blog editor features

This commit is contained in:
fatemeh sane
2026-09-13 10:39:25 +03:30
parent 20c95e73b3
commit bb6c586d99
21 changed files with 1235 additions and 187 deletions
+2 -1
View File
@@ -2,6 +2,7 @@ import React from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site'
import { formatJalali } from '../utils/date'
export function ArticleCard({ article }: { article: Article }) {
return (
@@ -33,7 +34,7 @@ export function ArticleCard({ article }: { article: Article }) {
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-xs font-light text-ivory/50">
<span className="flex items-center gap-3">
<span>{article.date}</span>
<span>{formatJalali(article.date)}</span>
<span className="flex items-center gap-1.5">
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
{article.read}
+53
View File
@@ -0,0 +1,53 @@
import { CalendarIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site'
import { JALALI_TIME_FORMATTER, formatJalali } from '../utils/date'
type ArticleMetaProps = {
article: Article
className?: string
}
/**
* سربرگ نویسنده/تاریخ مقاله — زیر تیتر اصلی نمایش داده می‌شود:
* آواتار حرف اول نویسنده، نام نویسنده، تاریخ جلالی و زمان مطالعه.
*/
export function ArticleMeta({ article, className = '' }: ArticleMetaProps) {
const rawDate = (article.date ?? '').trim()
const author = (article.author ?? '').trim() || 'گروه ساختمانی آرامش'
const initial = author.charAt(0)
// تاریخ ذخیره‌شده (میلادی) به شمسی تبدیل می‌شود؛ در نبود تاریخ، امروز با ساعت نمایش داده می‌شود
const todayLabel = `${formatJalali(new Date())} — ساعت ${JALALI_TIME_FORMATTER.format(new Date())}`
const dateLabel = formatJalali(rawDate, todayLabel)
return (
<div className={`flex flex-wrap items-center gap-x-6 gap-y-4 ${className}`}>
{/* آواتار و نام نویسنده */}
<span className="flex items-center gap-3">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-lg font-bold text-copper shadow-[0_0_20px_rgba(213,135,76,0.25),inset_0_1px_1px_rgba(255,255,255,0.2)]">
{initial}
</span>
<span className="flex flex-col">
<span className="text-sm font-bold text-ivory">{author}</span>
<span className="text-[11px] font-light text-ivory/45">نویسنده</span>
</span>
</span>
<span className="hidden h-9 w-px bg-ivory/10 sm:block" aria-hidden="true" />
{/* تاریخ جلالی */}
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
<CalendarIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
{dateLabel}
</span>
{/* زمان مطالعه */}
{article.read && (
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
<ClockIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
{article.read}
</span>
)}
</div>
)
}
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useState } from 'react'
import type { MouseEvent } from 'react'
import { ListIcon } from 'lucide-react'
import { slugifyFa, uniqueSlug } from '../utils/slug'
export type TocItem = { id: string; text: string; level: 2 | 3 }
/**
* استخراج فهرست مطالب از HTML ویرایشگر — تیترهای H2 و H3 شناسه لنگر می‌گیرند
* (با slugify و پشتیبانی فارسی) و HTML برگشتی همان است که باید رندر شود.
*/
export function extractToc(html: string): { items: TocItem[]; html: string } {
if (!html || typeof window === 'undefined') return { items: [], html }
const doc = new DOMParser().parseFromString(html, 'text/html')
const used = new Map<string, number>()
const items: TocItem[] = []
doc.body.querySelectorAll('h2, h3').forEach((el) => {
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim()
if (!text) return
const id = uniqueSlug(slugifyFa(text), used)
el.setAttribute('id', id)
items.push({ id, text, level: el.tagName === 'H3' ? 3 : 2 })
})
return { items, html: doc.body.innerHTML }
}
type TableOfContentsProps = {
items: TocItem[]
title?: string
className?: string
}
/** فهرست مطالب کلیک‌پذیر با هایلایت خودکار تیتر فعال (IntersectionObserver) */
export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) {
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
useEffect(() => {
setActiveId(items[0]?.id ?? '')
}, [items])
useEffect(() => {
if (!items.length || typeof IntersectionObserver === 'undefined') return
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
const first = visible[0]?.target.id
if (first) setActiveId(first)
},
{ rootMargin: '-15% 0px -70% 0px', threshold: [0, 1] },
)
items.forEach((item) => {
const el = document.getElementById(item.id)
if (el) observer.observe(el)
})
return () => observer.disconnect()
}, [items])
if (items.length === 0) return null
const go = (event: MouseEvent<HTMLButtonElement>, id: string) => {
event.preventDefault()
const el = document.getElementById(id)
if (!el) return
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
window.history.replaceState(null, '', `#${id}`)
}
return (
<nav
aria-label="فهرست مطالب"
className={`rounded-2xl border border-white/10 bg-char/60 p-6 backdrop-blur-xl shadow-[0_20px_50px_rgba(0,0,0,0.35),inset_0_1px_1px_rgba(255,255,255,0.08)] ${className}`}
>
<p className="flex items-center gap-2 text-sm font-bold text-copper">
<ListIcon className="h-4 w-4" aria-hidden="true" />
{title}
</p>
<div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" />
<ul className="mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-r border-ivory/10 pr-3">
{items.map((item) => {
const isActive = item.id === activeId
return (
<li key={item.id}>
<button
type="button"
onClick={(event) => go(event, item.id)}
aria-current={isActive ? 'location' : undefined}
className={`block w-full text-right text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
item.level === 3 ? 'pr-4 text-[13px]' : ''
} ${
isActive
? 'font-normal text-copper'
: 'text-ivory/60 hover:text-copper'
}`}
>
{item.text}
</button>
</li>
)
})}
</ul>
</nav>
)
}
+79 -50
View File
@@ -3,15 +3,19 @@ import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import { Mark, mergeAttributes } from '@tiptap/core'
import {
BoldIcon,
ItalicIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
ImagePlusIcon,
LanguagesIcon,
Link2Icon,
ListIcon,
ListOrderedIcon,
PilcrowIcon,
QuoteIcon,
Redo2Icon,
StrikethroughIcon,
@@ -22,12 +26,20 @@ import {
import Swal from 'sweetalert2'
import { useAdmin } from '../../admin/AdminContext'
import { adminApi } from '../../admin/adminClient'
import { ENGLISH_TEXT_MARK, EnglishText } from './EnglishText'
const SmallSizeMark = Mark.create({
name: 'smallSize',
parseHTML() { return [{ tag: 'span[data-small]' }] },
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes, { 'data-small': 'true' }), 0]
}
})
/** اکستنشن‌های مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
export const ARTICLE_EXTENSIONS = [
StarterKit.configure({
link: false,
heading: { levels: [2, 3] },
heading: { levels: [1, 2, 3] },
}),
Image.configure({ allowBase64: false }),
Link.configure({
@@ -35,6 +47,8 @@ export const ARTICLE_EXTENSIONS = [
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
isAllowedUri: (uri: string) => /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(uri),
}),
SmallSizeMark,
EnglishText,
]
type ArticleEditorProps = {
@@ -73,10 +87,6 @@ function ToolButton({
const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="true" />
/**
* ویرایشگر WYSIWYG مقاله (TipTap) — راست‌به‌چپ، با آپلود مستقیم تصویر به MinIO
* (انتخاب فایل، چسباندن یا کشیدن تصویر داخل متن).
*/
export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
const { runSave } = useAdmin()
const fileRef = useRef<HTMLInputElement>(null)
@@ -118,9 +128,14 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
async function uploadAndInsert(file: File) {
if (!editor) return
setUploading(true)
// استفاده از ریگکس یونیکد اصلاح‌شده برای نام فایل و Alt
const originalName = file.name.replace(/\.[^.]+$/, '')
const cleanAlt = originalName.replace(/[^\w\u0600-\u06FF\s-]/g, '-').replace(/\s+/g, '-').toLowerCase()
await runSave(async () => {
const { url } = await adminApi.upload(file)
editor.chain().focus().setImage({ src: url, alt: file.name.replace(/\.[^.]+$/, '') }).run()
editor.chain().focus().setImage({ src: url, alt: cleanAlt }).run()
})
setUploading(false)
}
@@ -158,47 +173,32 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
return (
<div className="border border-ivory/15 bg-char">
<div className="flex flex-wrap items-center gap-0.5 border-b border-ivory/10 px-2 py-1.5">
<div className="flex flex-wrap items-center gap-1 border-b border-ivory/10 px-2 py-1.5">
{/* قالب‌بندی بلوک — پاراگراف و تیترها با سایز مشخص */}
<ToolButton
label="بولد"
active={editor.isActive('bold')}
onClick={() => editor.chain().focus().toggleBold().run()}
label="پاراگراف (۱۴ پیکسل)"
active={editor.isActive('paragraph')}
onClick={() => editor.chain().focus().setParagraph().run()}
>
<BoldIcon className="h-4 w-4" />
<PilcrowIcon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="ایتالیک"
active={editor.isActive('italic')}
onClick={() => editor.chain().focus().toggleItalic().run()}
label="تیتر ۱ (۱۸ پیکسل)"
active={editor.isActive('heading', { level: 1 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
>
<ItalicIcon className="h-4 w-4" />
<Heading1Icon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="زیرخط"
active={editor.isActive('underline')}
onClick={() => editor.chain().focus().toggleUnderline().run()}
>
<UnderlineIcon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="خط‌خورده"
active={editor.isActive('strike')}
onClick={() => editor.chain().focus().toggleStrike().run()}
>
<StrikethroughIcon className="h-4 w-4" />
</ToolButton>
<Divider />
<ToolButton
label="تیتر میانی"
label="تیتر ۲ (۱۶ پیکسل)"
active={editor.isActive('heading', { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
<Heading2Icon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="تیتر کوچک"
label="تیتر ۳ (۱۴ پیکسل)"
active={editor.isActive('heading', { level: 3 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
@@ -207,25 +207,54 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
<Divider />
<ToolButton
label="لیست نقطه‌ای"
active={editor.isActive('bulletList')}
onClick={() => editor.chain().focus().toggleBulletList().run()}
<select
className="h-8 bg-ink border border-ivory/15 text-ivory/90 text-xs px-2 outline-none focus:border-copper cursor-pointer transition-colors"
value={editor.isActive('smallSize') ? '12' : '14'}
onChange={(e) => {
if (e.target.value === '12') {
editor.chain().focus().setMark('smallSize').run()
} else {
editor.chain().focus().unsetMark('smallSize').run()
}
}}
>
<option value="14">سایز ۱۴ (فارسی)</option>
<option value="12">سایز ۱۲ (انگلیسی)</option>
</select>
{/* متن انگلیسی — span با dir=ltr و سایز ۱۲ پیکسل */}
<ToolButton
label="متن انگلیسی (۱۲ پیکسل، چپ‌به‌راست)"
active={editor.isActive(ENGLISH_TEXT_MARK)}
onClick={() => editor.chain().focus().toggleMark(ENGLISH_TEXT_MARK).run()}
>
<LanguagesIcon className="h-4 w-4" />
</ToolButton>
<Divider />
<ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
<BoldIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="ایتالیک" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
<ItalicIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="زیرخط" active={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}>
<UnderlineIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="خط‌خورده" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
<StrikethroughIcon className="h-4 w-4" />
</ToolButton>
<Divider />
<ToolButton label="لیست نقطه‌ای" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
<ListIcon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="لیست شماره‌ای"
active={editor.isActive('orderedList')}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
<ToolButton label="لیست شماره‌ای" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
<ListOrderedIcon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="نقل‌قول"
active={editor.isActive('blockquote')}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
<ToolButton label="نقل‌قول" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
<QuoteIcon className="h-4 w-4" />
</ToolButton>
@@ -264,4 +293,4 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
/>
</div>
)
}
}
@@ -0,0 +1,26 @@
import { Mark, mergeAttributes } from '@tiptap/core'
/** نام مارک متن انگلیسی — ویرایشگر و رندر استاتیک مقاله از همین نام استفاده می‌کنند */
export const ENGLISH_TEXT_MARK = 'englishText'
/**
* مارک «متن انگلیسی» — متن انتخاب‌شده را داخل
* <span dir="ltr" class="english-text"> می‌پیچد تا ۱۲ پیکسل و چپ‌به‌راست
* نمایش داده شود، بدون اینکه جهت متن فارسی اطرافش به‌هم بخورد.
*/
export const EnglishText = Mark.create({
name: ENGLISH_TEXT_MARK,
inclusive: false,
parseHTML() {
return [{ tag: 'span.english-text' }]
},
renderHTML({ HTMLAttributes }) {
return [
'span',
mergeAttributes(HTMLAttributes, { dir: 'ltr', class: 'english-text' }),
0,
]
},
})
+62 -4
View File
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react'
import { useEffect, useId, useRef, useState } from 'react'
import { ImagePlusIcon, Loader2Icon } from 'lucide-react'
import { adminApi } from '../../admin/adminClient'
import { useAdmin } from '../../admin/AdminContext'
@@ -7,33 +7,69 @@ type ImageFieldProps = {
label: string
value: string
onSave: (url: string) => Promise<boolean>
/** متن جایگزین (Alt) — با ارسال این prop ورودی Alt نمایش داده می‌شود */
alt?: string
/** ذخیره متن جایگزین — بدون آن، Alt فقط برای پیش‌نمایش و پیشنهاد استفاده می‌شود */
onSaveAlt?: (alt: string) => Promise<boolean>
/** پیشنهاد Alt وقتی خالی است (مثل عنوان مقاله یا نام موجودیت) */
altFallback?: string
}
/**
* انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش استفاده می‌شود.
* انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش و صفحه نوشتن مقاله.
* پس از آپلود موفق، URL نسبی /media/... ذخیره می‌شود.
* اگر Alt خالی باشد، پیش از فراخوانی API از عنوان موجودیت یا نام فایل ساخته می‌شود.
*/
export function ImageField({ label, value, onSave }: ImageFieldProps) {
export function ImageField({ label, value, onSave, alt, onSaveAlt, altFallback = '' }: ImageFieldProps) {
const { runSave, isAuthenticated, isEditMode } = useAdmin()
const inputRef = useRef<HTMLInputElement>(null)
const altInputId = useId()
const [uploading, setUploading] = useState(false)
const [altText, setAltText] = useState(alt ?? '')
const showAlt = alt !== undefined || !!onSaveAlt
// همگام‌سازی با مقدار ذخیره‌شده (پس از به‌روزرسانی خوش‌بینانه در درخت داده)
useEffect(() => {
setAltText(alt ?? '')
}, [alt])
if (!isAuthenticated || !isEditMode) return null
/** متن جایگزین نهایی: ورودی کاربر → عنوان موجودیت → نام فایل */
const resolveAlt = (fileName: string): string => {
const typed = altText.trim()
if (typed) return typed
const fromEntity = altFallback.trim()
if (fromEntity) return fromEntity
return fileName.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() || label
}
const pick = async (file: File | undefined) => {
if (!file) return
const generatedAlt = resolveAlt(file.name)
setUploading(true)
try {
await runSave(async () => {
const { url } = await adminApi.upload(file)
const ok = await onSave(url)
if (!ok) throw new Error('ذخیره نشد')
// Alt ساخته‌شده هم ذخیره می‌شود تا تصویر بدون متن جایگزین نماند
if (showAlt && generatedAlt !== (alt ?? '').trim()) {
setAltText(generatedAlt)
if (onSaveAlt) await onSaveAlt(generatedAlt)
}
})
} finally {
setUploading(false)
}
}
const commitAlt = () => {
const next = altText.trim()
if (!onSaveAlt || next === (alt ?? '').trim()) return
void onSaveAlt(next)
}
return (
<div>
<p className="text-xs font-bold text-ivory/80">{label}</p>
@@ -41,7 +77,7 @@ export function ImageField({ label, value, onSave }: ImageFieldProps) {
{value ? (
<img
src={value}
alt={label}
alt={altText || label}
className="h-16 w-24 border border-ivory/15 object-cover"
/>
) : (
@@ -66,6 +102,28 @@ export function ImageField({ label, value, onSave }: ImageFieldProps) {
{value && <p className="mt-1.5 truncate text-[10px] font-light text-ivory/35" dir="ltr">{value}</p>}
</div>
</div>
{showAlt && (
<div className="mt-3">
<div className="flex items-center justify-between gap-3">
<label htmlFor={altInputId} className="text-[11px] font-light text-ivory/50">
متن جایگزین (Alt)
</label>
<span className="text-[10px] font-light text-ivory/30">برای سئو و صفحه‌خوان‌ها</span>
</div>
<input
id={altInputId}
type="text"
dir="auto"
value={altText}
onChange={(e) => setAltText(e.target.value)}
onBlur={commitAlt}
placeholder={altFallback ? `پیش‌فرض: ${altFallback}` : 'توضیح کوتاه تصویر…'}
className="mt-1.5 w-full border border-ivory/15 bg-char px-3 py-2 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none"
/>
</div>
)}
<input
ref={inputRef}
type="file"
@@ -0,0 +1,52 @@
import DatePicker from 'react-multi-date-picker'
import type { DateObject } from 'react-multi-date-picker'
import persian from 'react-date-object/calendars/persian'
import persian_fa from 'react-date-object/locales/persian_fa'
import { toISODate, toJalaliDateObject } from '../../utils/date'
type JalaliDatePickerProps = {
/** مقدار ذخیره‌شده — ISO میلادی (YYYY-MM-DD) */
value: string
/** تغییر مقدار — همیشه ISO میلادی برمی‌گرداند تا ذخیره‌سازی یکدست بماند */
onChange: (isoDate: string) => void
id?: string
placeholder?: string
disabled?: boolean
className?: string
}
/**
* انتخابگر تاریخ شمسی (react-multi-date-picker) با تقویم و locale فارسی.
* نمایش جلالی است اما مقدار ذخیره‌شده ISO میلادی می‌ماند.
*/
export function JalaliDatePicker({
value,
onChange,
id,
placeholder = 'انتخاب تاریخ',
disabled = false,
className = '',
}: JalaliDatePickerProps) {
return (
<DatePicker
id={id}
value={toJalaliDateObject(value)}
onChange={(selected) => {
const picked = Array.isArray(selected) ? selected[0] : selected
if (!picked) return
onChange(toISODate(picked as DateObject))
}}
calendar={persian}
locale={persian_fa}
format="YYYY/MM/DD"
calendarPosition="bottom-right"
className={`aramesh-datepicker ${className}`}
containerClassName="w-full"
inputClass="w-full cursor-pointer border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder={placeholder}
disabled={disabled}
mobileLabels={{ OK: 'تأیید', CANCEL: 'انصراف' }}
portal
/>
)
}
@@ -0,0 +1,161 @@
import { useMemo } from 'react'
import Select from 'react-select'
import CreatableSelect from 'react-select/creatable'
import type { StylesConfig } from 'react-select'
export type LuxeOption = { label: string; value: string }
const COPPER = '#d5874c'
const IVORY = '#F5F3EF'
const SURFACE = '#1e1a16'
const BORDER = 'rgba(245,243,239,0.15)'
/** استایل تیره و لوکس react-select هماهنگ با پالت سایت (مسی روی زغالی) */
function makeStyles<IsMulti extends boolean>(): StylesConfig<LuxeOption, IsMulti> {
return {
control: (base, state) => ({
...base,
minHeight: 42,
backgroundColor: SURFACE,
borderColor: state.isFocused ? COPPER : BORDER,
borderRadius: 0,
boxShadow: state.isFocused ? '0 0 0 1px rgba(213,135,76,0.35)' : 'none',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
':hover': { borderColor: state.isFocused ? COPPER : 'rgba(213,135,76,0.5)' },
}),
valueContainer: (base) => ({ ...base, padding: '2px 12px' }),
placeholder: (base) => ({ ...base, color: 'rgba(245,243,239,0.35)', fontSize: 13 }),
singleValue: (base) => ({ ...base, color: IVORY, fontSize: 13 }),
input: (base) => ({ ...base, color: IVORY, fontSize: 13 }),
menu: (base) => ({
...base,
backgroundColor: SURFACE,
border: `1px solid ${BORDER}`,
borderRadius: 0,
boxShadow: '0 24px 50px -18px rgba(0,0,0,0.75)',
overflow: 'hidden',
zIndex: 60,
}),
menuList: (base) => ({ ...base, padding: 4 }),
menuPortal: (base) => ({ ...base, zIndex: 200 }),
option: (base, state) => ({
...base,
backgroundColor: state.isSelected
? 'rgba(213,135,76,0.25)'
: state.isFocused
? 'rgba(213,135,76,0.12)'
: 'transparent',
color: state.isSelected ? COPPER : 'rgba(245,243,239,0.85)',
fontSize: 13,
fontWeight: state.isSelected ? 700 : 400,
cursor: 'pointer',
borderRadius: 0,
':active': { backgroundColor: 'rgba(213,135,76,0.3)' },
}),
noOptionsMessage: (base) => ({ ...base, color: 'rgba(245,243,239,0.45)', fontSize: 12 }),
indicatorSeparator: (base) => ({ ...base, backgroundColor: BORDER }),
dropdownIndicator: (base, state) => ({
...base,
color: state.isFocused ? COPPER : 'rgba(245,243,239,0.4)',
':hover': { color: COPPER },
}),
clearIndicator: (base) => ({
...base,
color: 'rgba(245,243,239,0.4)',
':hover': { color: COPPER },
}),
multiValue: (base) => ({
...base,
backgroundColor: 'rgba(213,135,76,0.15)',
border: '1px solid rgba(213,135,76,0.4)',
borderRadius: 999,
paddingInline: 2,
}),
multiValueLabel: (base) => ({ ...base, color: COPPER, fontSize: 12 }),
multiValueRemove: (base) => ({
...base,
color: COPPER,
borderRadius: 999,
':hover': { backgroundColor: COPPER, color: SURFACE },
}),
}
}
const portalTarget = (): HTMLElement | undefined =>
typeof document === 'undefined' ? undefined : document.body
type LuxeSelectProps = {
value: string
options: string[]
onChange: (value: string) => void
placeholder?: string
isClearable?: boolean
isDisabled?: boolean
inputId?: string
}
/** react-select تکی با ظاهر تیره پروژه — گزینه فعلی حتی اگر در لیست نباشد حفظ می‌شود */
export function LuxeSelect({
value,
options,
onChange,
placeholder = 'انتخاب کنید…',
isClearable = true,
isDisabled = false,
inputId,
}: LuxeSelectProps) {
const selectOptions = useMemo(() => [...new Set([...options, ...(value ? [value] : [])])].map(
(item) => ({ label: item, value: item }),
), [options, value])
return (
<Select<LuxeOption, false>
inputId={inputId}
classNamePrefix="luxe"
options={selectOptions}
value={selectOptions.find((option) => option.value === value) ?? null}
onChange={(option) => onChange(option?.value ?? '')}
placeholder={placeholder}
isClearable={isClearable}
isDisabled={isDisabled}
noOptionsMessage={() => 'موردی یافت نشد'}
styles={makeStyles<false>()}
menuPosition="fixed"
menuPortalTarget={portalTarget()}
/>
)
}
type LuxeTagsSelectProps = {
value: string[]
onChange: (value: string[]) => void
placeholder?: string
}
/** هشتگ‌ها با react-select قابل ساخت — هر تایپ جدید با Enter به فهرست اضافه می‌شود */
export function LuxeTagsSelect({
value,
onChange,
placeholder = 'هشتگ بنویسید و Enter بزنید…',
}: LuxeTagsSelectProps) {
const options = useMemo(
() => [...new Set(value.filter(Boolean))].map((item) => ({ label: item, value: item })),
[value],
)
return (
<CreatableSelect<LuxeOption, true>
classNamePrefix="luxe"
isMulti
options={options}
value={options}
onChange={(next) => onChange(next.map((option) => option.value))}
placeholder={placeholder}
formatCreateLabel={(input) => `افزودن «${input}»`}
noOptionsMessage={() => 'هشتگی یافت نشد'}
styles={makeStyles<true>()}
menuPosition="fixed"
menuPortalTarget={portalTarget()}
/>
)
}