feat: add RichTextEditor component and integrate company policy management into settings page

This commit is contained in:
sazzadulalambd
2026-05-06 00:50:30 +06:00
parent 4b93060c1a
commit a3fabcde62
2 changed files with 472 additions and 73 deletions

View File

@@ -1,7 +1,8 @@
'use client';
import { useState } from 'react';
import { Settings, Upload, Image, Globe, Mail, MessageSquare, Phone, MapPin, Link2, Clock, Save, FileText, Camera, Palette, Ruler, Sun, Moon, Monitor, Smartphone, Tablet, Package, Wrench, FileCheck, BadgeDollarSign, CreditCard, Plus, X, DollarSign, Zap, Users } from 'lucide-react';
import { Settings, Upload, Image, Globe, Mail, MessageSquare, Phone, MapPin, Link2, Clock, Save, FileText, Camera, Palette, Ruler, Sun, Moon, Monitor, Smartphone, Tablet, Package, Wrench, FileCheck, BadgeDollarSign, CreditCard, Plus, X, DollarSign, Zap, Users, Check, Pencil } from 'lucide-react';
import RichTextEditor from '@/components/RichTextEditor';
interface CompanySettings {
name: string;
@@ -71,6 +72,12 @@ interface CompanySettings {
};
parts: { id: string; name: string; price?: number; minPrice?: number; maxPrice?: number; inStock: number }[];
serviceCenters: { id: string; name: string; address: string; phone: string; rating: number }[];
companyPolicy: {
investor: { title: string; description: string; rules: { name: string; description: string }[] };
merchant: { title: string; description: string; rules: { name: string; description: string }[] };
swapStation: { title: string; description: string; rules: { name: string; description: string }[] };
rentalTypes: { type: string; name: string; title: string; description: string; rules: { name: string; description: string }[]; enabled: boolean }[];
};
rentalPolicy: {
minAge: number;
requireLicense: boolean;
@@ -342,6 +349,64 @@ const initialSettings: CompanySettings = {
{ id: 'SC-001', name: 'JAIBEN Service Center - Gulshan', address: 'House 45, Road 13, Gulshan 1, Dhaka', phone: '+8801712345670', rating: 4.8 },
{ id: 'SC-002', name: 'JAIBEN Service Center - Banani', address: 'Road 11, Banani, Dhaka', phone: '+8801712345671', rating: 4.5 },
],
companyPolicy: {
investor: {
title: 'Investor Policy',
description: '<h2>Investor Guidelines</h2><p>Welcome to our investor program. This document outlines the terms and conditions for all investors participating in our EV fleet sharing initiative.</p><h3>Key Requirements</h3><ul><li>All investors must complete KYC verification</li><li>Minimum investment amount: ৳100,000</li><li>Monthly returns are calculated based on fleet utilization</li></ul><p><strong>Note:</strong> Past performance does not guarantee future results. Please read all terms carefully before investing.</p>',
rules: [
{ name: 'No smoking in vehicles', description: 'Smoking is strictly prohibited inside any vehicle in the fleet' },
{ name: 'Return with same fuel/charge level', description: 'Vehicles must be returned with the same fuel/charge level as when rented' },
{ name: 'No unauthorized drivers', description: 'Only authorized drivers listed in the agreement are permitted to drive' },
{ name: 'Follow traffic rules', description: 'All traffic laws and regulations must be followed' },
{ name: 'Report accidents within 24 hours', description: 'Any accident must be reported within 24 hours of occurrence' }
]
},
merchant: {
title: 'Merchant Policy',
description: '<h2>Merchant Terms & Conditions</h2><p>Thank you for joining our merchant network. These guidelines ensure smooth operations for all participating merchants.</p><h3>Operational Requirements</h3><ul><li>Maintain minimum inventory levels</li><li>Provide excellent customer service</li><li>Accept all payment methods offered</li><li>Process orders within 24 hours</li></ul><h3>Commission Structure</h3><p>Merchants receive <strong>15% commission</strong> on each completed delivery plus monthly bonuses for high performance.</p>',
rules: [
{ name: 'No smoking in vehicles', description: 'Smoking is strictly prohibited inside any vehicle in the fleet' },
{ name: 'Return with same fuel/charge level', description: 'Vehicles must be returned with the same fuel/charge level as when rented' },
{ name: 'No unauthorized drivers', description: 'Only authorized drivers listed in the agreement are permitted to drive' },
{ name: 'Follow traffic rules', description: 'All traffic laws and regulations must be followed' },
{ name: 'Report accidents within 24 hours', description: 'Any accident must be reported within 24 hours of occurrence' }
]
},
swapStation: {
title: 'Swap Station Policy',
description: '<h2>Swap Station Guidelines</h2><p>Welcome to our battery swap station network. Follow these safety and operational protocols for optimal service.</p><h3>Safety Protocols</h3><ul><li>Always wear protective gloves when handling batteries</li><li>Inspect batteries for damage before swapping</li><li>Keep swap station area clean and organized</li><li>Report any malfunctioning equipment immediately</li></ul><h3>Operating Hours</h3><p>Stations operate <strong>24/7</strong> for subscriber convenience. Emergency support available round the clock.</p>',
rules: [
{ name: 'No smoking in vehicles', description: 'Smoking is strictly prohibited inside any vehicle in the fleet' },
{ name: 'Return with same fuel/charge level', description: 'Vehicles must be returned with the same fuel/charge level as when rented' },
{ name: 'No unauthorized drivers', description: 'Only authorized drivers listed in the agreement are permitted to drive' },
{ name: 'Follow traffic rules', description: 'All traffic laws and regulations must be followed' },
{ name: 'Report accidents within 24 hours', description: 'Any accident must be reported within 24 hours of occurrence' }
]
},
rentalTypes: [
{ type: 'single', name: 'Rental (Single)', title: 'Rental (Single)', description: '<h2>Single Person Rental</h2><p>Perfect for individual riders who need a reliable vehicle for daily commute or delivery work.</p><h3>Plan Features</h3><ul><li>Daily, weekly, and monthly options available</li><li>Comprehensive insurance included</li><li>24/7 roadside assistance</li><li>Free maintenance during rental period</li></ul><h3>Pricing</h3><p>Starting from <strong>৳400/day</strong> with deposit of ৳5,000.</p>', rules: [
{ name: 'No smoking in vehicles', description: 'Smoking is strictly prohibited inside any vehicle' },
{ name: 'Return with same fuel/charge level', description: 'Vehicles must be returned with the same fuel/charge level' },
{ name: 'No unauthorized drivers', description: 'Only the registered rider is permitted to drive' },
{ name: 'Follow traffic rules', description: 'All traffic laws must be followed' },
{ name: 'Report accidents within 24 hours', description: 'Any accident must be reported within 24 hours' }
], enabled: true },
{ type: 'shared', name: 'Rental (2 Person Shared)', title: 'Rental (2 Person Shared)', description: '<h2>Shared Rental Plan</h2><p>Ideal for companions or delivery partners who want to share riding costs and responsibilities.</p><h3>Plan Features</h3><ul><li>Split costs between two riders</li><li>Both users must be verified</li><li>Shared liability coverage</li><li>Flexible switch driver feature</li></ul><h3>Pricing</h3><p>Starting from <strong>৳600/day</strong> (৳300 each) with deposit of ৳8,000.</p>', rules: [
{ name: 'No smoking in vehicles', description: 'Smoking is strictly prohibited inside any vehicle' },
{ name: 'Return with same fuel/charge level', description: 'Vehicles must be returned with the same fuel/charge level' },
{ name: 'No unauthorized drivers', description: 'Both registered riders are permitted to drive' },
{ name: 'Follow traffic rules', description: 'All traffic laws must be followed' },
{ name: 'Report accidents within 24 hours', description: 'Any accident must be reported within 24 hours' }
], enabled: true },
{ type: 'renttoown', name: 'Rent-to-Own', title: 'Rent-to-Own', description: '<h2>Rent-to-Own Plan</h2><p>Build ownership gradually with our rent-to-own program. After completing the tenure, own the EV outright.</p><h3>Program Benefits</h3><ul><li>50% of rental payments go toward purchase</li><li>Option to buyout anytime</li><li>Full ownership after 36 months</li><li>Transferable to family members</li></ul><h3>Requirements</h3><p>Good payment history required. Credit check applies.</p>', rules: [
{ name: 'No smoking in vehicles', description: 'Smoking is strictly prohibited inside any vehicle' },
{ name: 'Return with same fuel/charge level', description: 'Vehicles must be returned with the same fuel/charge level' },
{ name: 'No unauthorized drivers', description: 'Only the registered rider is permitted to drive' },
{ name: 'Follow traffic rules', description: 'All traffic laws must be followed' },
{ name: 'Report accidents within 24 hours', description: 'Any accident must be reported within 24 hours' }
], enabled: true },
],
},
rentalPolicy: {
minAge: 18,
requireLicense: true,
@@ -603,14 +668,17 @@ const initialSettings: CompanySettings = {
export default function CompanySettingsPage() {
const [settings, setSettings] = useState<CompanySettings>(initialSettings);
const [activeTab, setActiveTab] = useState<'general' | 'branding' | 'social' | 'integration' | 'landing' | 'kyc' | 'parts' | 'rental' | 'plans' | 'investment' | 'swapstation' | 'riderrequest'>('general');
const [activeMasterTab, setActiveMasterTab] = useState<'investor' | 'merchant' | 'swapstation' | 'rental'>('investor');
const [activeTab, setActiveTab] = useState<'general' | 'branding' | 'social' | 'integration' | 'landing' | 'kyc' | 'parts' | 'companyPolicy' | 'plans' | 'investment' | 'swapstation' | 'riderrequest'>('general');
const [activeMasterTab, setActiveMasterTab] = useState<'investor' | 'merchant' | 'swapstation' | 'rental' | 'rentalType'>('investor');
const [saved, setSaved] = useState(false);
const [activePlanTab, setActivePlanTab] = useState<'singleRent' | 'rentToOwn' | 'shareEv'>('singleRent');
const [addDocType, setAddDocType] = useState<'investor' | 'merchant' | 'swapstation' | 'rental' | null>(null);
const [newDocName, setNewDocName] = useState('');
const [newDocDesc, setNewDocDesc] = useState('');
const [activeInvestTab, setActiveInvestTab] = useState(0);
const [editingPolicy, setEditingPolicy] = useState<{tab: string; index: number} | null>(null);
const [editPolicyName, setEditPolicyName] = useState('');
const [editPolicyDesc, setEditPolicyDesc] = useState('');
const [addInvestPlan, setAddInvestPlan] = useState(false);
const [newInvestName, setNewInvestName] = useState('');
const [newInvestTier, setNewInvestTier] = useState('Standard');
@@ -742,7 +810,7 @@ setNewSwapName('');
{ id: 'landing', label: 'Landing Page', icon: Monitor },
{ id: 'kyc', label: 'KYC Documents', icon: Package },
{ id: 'parts', label: 'EV Parts', icon: Package },
{ id: 'rental', label: 'Rental Policy', icon: FileCheck },
{ id: 'companyPolicy', label: "Company's Policy", icon: FileCheck },
{ id: 'plans', label: 'Plan Selection', icon: Package },
{ id: 'investment', label: 'Investment Plan', icon: DollarSign },
{ id: 'swapstation', label: 'Swap Station Plan', icon: Zap },
@@ -1747,85 +1815,158 @@ setNewSwapName('');
</div>
)}
{activeTab === 'rental' && (
{activeTab === 'companyPolicy' && (
<div className="p-6 space-y-6">
<h3 className="text-lg font-semibold text-slate-800">Rental Policy</h3>
<h3 className="text-lg font-semibold text-slate-800">Company's Policy</h3>
<div className="grid lg:grid-cols-2 gap-4">
<div>
<label className="text-sm text-slate-600">Minimum Age</label>
<input
type="number"
value={settings.rentalPolicy.minAge}
onChange={(e) => setSettings({ ...settings, rentalPolicy: { ...settings.rentalPolicy, minAge: parseInt(e.target.value) } })}
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
/>
</div>
<div>
<label className="text-sm text-slate-600">Security Deposit ()</label>
<input
type="number"
value={settings.rentalPolicy.deposit}
onChange={(e) => setSettings({ ...settings, rentalPolicy: { ...settings.rentalPolicy, deposit: parseInt(e.target.value) } })}
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
/>
</div>
<div>
<label className="text-sm text-slate-600">Late Fee per Hour ()</label>
<input
type="number"
value={settings.rentalPolicy.lateFeePerHour}
onChange={(e) => setSettings({ ...settings, rentalPolicy: { ...settings.rentalPolicy, lateFeePerHour: parseInt(e.target.value) } })}
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
/>
</div>
<div>
<label className="text-sm text-slate-600">Cancellation Fee ()</label>
<input
type="number"
value={settings.rentalPolicy.cancellationFee}
onChange={(e) => setSettings({ ...settings, rentalPolicy: { ...settings.rentalPolicy, cancellationFee: parseInt(e.target.value) } })}
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
/>
</div>
<div className="flex gap-2 border-b border-slate-200 pb-2">
<button onClick={() => setActiveMasterTab('investor')} className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${activeMasterTab === 'investor' ? 'border-blue-500 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}>Investor</button>
<button onClick={() => setActiveMasterTab('merchant')} className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${activeMasterTab === 'merchant' ? 'border-blue-500 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}>Merchant</button>
<button onClick={() => setActiveMasterTab('swapstation')} className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${activeMasterTab === 'swapstation' ? 'border-blue-500 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}>Swap Station</button>
<button onClick={() => setActiveMasterTab('rentalType')} className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${activeMasterTab === 'rentalType' ? 'border-blue-500 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}>Rental Types</button>
</div>
{activeMasterTab === 'investor' && (
<div className="space-y-4">
<div>
<label className="text-sm text-slate-600">Policy Title</label>
<input type="text" value={settings.companyPolicy?.investor?.title || ''} onChange={(e) => setSettings({ ...settings, companyPolicy: { ...settings.companyPolicy, investor: { title: e.target.value, description: settings.companyPolicy?.investor?.description || '', rules: settings.companyPolicy?.investor?.rules || [] } } })} className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1" />
</div>
<div>
<label className="text-sm text-slate-600">Policy Description</label>
<RichTextEditor value={settings.companyPolicy?.investor?.description || ''} onChange={(val) => setSettings({ ...settings, companyPolicy: { ...settings.companyPolicy, investor: { title: settings.companyPolicy?.investor?.title || '', description: val, rules: settings.companyPolicy?.investor?.rules || [] } } })} placeholder="Enter policy description..." minHeight={160} />
</div>
<div className="flex justify-end">
<button onClick={handleSave} className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">Save Changes</button>
</div>
<div className="border-t border-slate-200 pt-4">
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-slate-600 font-medium">Policy List</label>
</div>
<div className="space-y-2">
{(settings.companyPolicy?.investor?.rules || []).map((policy, i) => (
<div key={i} className="p-3 bg-slate-50 rounded-lg border border-slate-200">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={settings.rentalPolicy.requireLicense}
onChange={(e) => setSettings({ ...settings, rentalPolicy: { ...settings.rentalPolicy, requireLicense: e.target.checked } })}
className="w-4 h-4"
/>
<label className="text-sm text-slate-600">Require Driving License</label>
<span className="text-sm font-medium text-slate-700">{policy.name}</span>
</div>
<p className="text-xs text-slate-500 mt-1">{policy.description}</p>
</div>
</div>
<div>
<h4 className="font-medium text-slate-700 mb-3">Damage Penalties</h4>
<div className="space-y-2">
{settings.rentalPolicy.damagePenalty.map((penalty, i) => (
<div key={i} className="flex items-center gap-3 p-2 border border-slate-200 rounded-lg">
<span className="w-32 text-sm">{penalty.level}</span>
<span className="text-sm font-medium">{penalty.amount}</span>
</div>
))}
</div>
</div>
<div>
<h4 className="font-medium text-slate-700 mb-3">Rental Rules</h4>
<div className="space-y-2">
{settings.rentalPolicy.rules.map((rule, i) => (
<div key={i} className="flex items-center gap-2 p-2 bg-slate-50 rounded-lg">
<span className="text-sm">{rule}</span>
</div>
))}
</div>
</div>
</div>
)}
{activeMasterTab === 'merchant' && (
<div className="space-y-4">
<div>
<label className="text-sm text-slate-600">Policy Title</label>
<input type="text" value={settings.companyPolicy?.merchant?.title || ''} onChange={(e) => setSettings({ ...settings, companyPolicy: { ...settings.companyPolicy, merchant: { title: e.target.value, description: settings.companyPolicy?.merchant?.description || '', rules: settings.companyPolicy?.merchant?.rules || [] } } })} className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1" />
</div>
<div>
<label className="text-sm text-slate-600">Policy Description</label>
<RichTextEditor value={settings.companyPolicy?.merchant?.description || ''} onChange={(val) => setSettings({ ...settings, companyPolicy: { ...settings.companyPolicy, merchant: { title: settings.companyPolicy?.merchant?.title || '', description: val, rules: settings.companyPolicy?.merchant?.rules || [] } } })} placeholder="Enter policy description..." minHeight={160} />
</div>
<div className="flex justify-end">
<button onClick={handleSave} className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">Save Changes</button>
</div>
<div className="border-t border-slate-200 pt-4">
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-slate-600 font-medium">Policy List</label>
</div>
<div className="space-y-2">
{(settings.companyPolicy?.merchant?.rules || []).map((policy, i) => (
<div key={i} className="p-3 bg-slate-50 rounded-lg border border-slate-200">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-700">{policy.name}</span>
</div>
<p className="text-xs text-slate-500 mt-1">{policy.description}</p>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
{activeMasterTab === 'swapstation' && (
<div className="space-y-4">
<div>
<label className="text-sm text-slate-600">Policy Title</label>
<input type="text" value={settings.companyPolicy?.swapStation?.title || ''} onChange={(e) => setSettings({ ...settings, companyPolicy: { ...settings.companyPolicy, swapStation: { title: e.target.value, description: settings.companyPolicy?.swapStation?.description || '', rules: settings.companyPolicy?.swapStation?.rules || [] } } })} className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1" />
</div>
<div>
<label className="text-sm text-slate-600">Policy Description</label>
<RichTextEditor value={settings.companyPolicy?.swapStation?.description || ''} onChange={(val) => setSettings({ ...settings, companyPolicy: { ...settings.companyPolicy, swapStation: { title: settings.companyPolicy?.swapStation?.title || '', description: val, rules: settings.companyPolicy?.swapStation?.rules || [] } } })} placeholder="Enter policy description..." minHeight={160} />
</div>
<div className="flex justify-end">
<button onClick={handleSave} className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">Save Changes</button>
</div>
<div className="border-t border-slate-200 pt-4">
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-slate-600 font-medium">Policy List</label>
</div>
<div className="space-y-2">
{(settings.companyPolicy?.swapStation?.rules || []).map((policy, i) => (
<div key={i} className="p-3 bg-slate-50 rounded-lg border border-slate-200">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-700">{policy.name}</span>
</div>
<p className="text-xs text-slate-500 mt-1">{policy.description}</p>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
{activeMasterTab === 'rentalType' && (
<div className="space-y-6">
{(settings.companyPolicy?.rentalTypes || []).map((rtype, idx) => (
<div key={idx} className="border border-slate-200 rounded-lg overflow-hidden">
<div className="bg-slate-50 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<input type="checkbox" checked={rtype.enabled} onChange={(e) => {
const updated = [...(settings.companyPolicy?.rentalTypes || [])];
updated[idx] = { ...updated[idx], enabled: e.target.checked };
setSettings({ ...settings, companyPolicy: { ...settings.companyPolicy, rentalTypes: updated } });
}} className="w-4 h-4" />
<h4 className="font-medium text-slate-700">{rtype.name}</h4>
</div>
</div>
<div className="p-4 space-y-3">
<input type="text" value={rtype.title} onChange={(e) => {
const updated = [...(settings.companyPolicy?.rentalTypes || [])];
updated[idx] = { ...updated[idx], title: e.target.value };
setSettings({ ...settings, companyPolicy: { ...settings.companyPolicy, rentalTypes: updated } });
}} className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm" placeholder="Policy Title" />
<RichTextEditor value={rtype.description} onChange={(val) => {
const updated = [...(settings.companyPolicy?.rentalTypes || [])];
updated[idx] = { ...updated[idx], description: val };
setSettings({ ...settings, companyPolicy: { ...settings.companyPolicy, rentalTypes: updated } });
}} placeholder="Enter policy description..." minHeight={120} />
</div>
</div>
))}
<div className="flex justify-end">
<button onClick={handleSave} className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">Save Changes</button>
</div>
</div>
)}
</div>
)}
{activeTab === 'plans' && (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">

View File

@@ -0,0 +1,258 @@
'use client';
import { useState, useRef, useEffect, useCallback, Fragment } from 'react';
import { Bold, Italic, Underline, List, ListOrdered, AlignLeft, AlignCenter, AlignRight, Undo, Redo, Table, Trash2, Rows, Columns } from 'lucide-react';
interface RichTextEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
minHeight?: number;
}
export default function RichTextEditor({ value, onChange, placeholder = 'Enter description...', minHeight = 150 }: RichTextEditorProps) {
const editorRef = useRef<HTMLDivElement>(null);
const [isInitialized, setIsInitialized] = useState(false);
const [showTableMenu, setShowTableMenu] = useState(false);
const lastValueRef = useRef(value);
useEffect(() => {
if (editorRef.current && !isInitialized) {
editorRef.current.innerHTML = value || '';
lastValueRef.current = value;
setIsInitialized(true);
}
}, [value, isInitialized]);
useEffect(() => {
if (editorRef.current && isInitialized && value !== lastValueRef.current) {
if (document.activeElement !== editorRef.current) {
editorRef.current.innerHTML = value || '';
lastValueRef.current = value;
}
}
}, [value, isInitialized]);
const execCommand = (command: string) => {
editorRef.current?.focus();
if (command === 'bold') {
document.execCommand('bold', false);
} else if (command === 'italic') {
document.execCommand('italic', false);
} else if (command === 'underline') {
document.execCommand('underline', false);
} else if (command === 'insertUnorderedList') {
document.execCommand('insertUnorderedList', false);
} else if (command === 'insertOrderedList') {
document.execCommand('insertOrderedList', false);
} else if (command === 'justifyLeft') {
document.execCommand('justifyLeft', false);
} else if (command === 'justifyCenter') {
document.execCommand('justifyCenter', false);
} else if (command === 'justifyRight') {
document.execCommand('justifyRight', false);
} else if (command === 'undo') {
document.execCommand('undo', false);
} else if (command === 'redo') {
document.execCommand('redo', false);
}
updateValue();
};
const updateValue = useCallback(() => {
if (editorRef.current) {
lastValueRef.current = editorRef.current.innerHTML;
onChange(editorRef.current.innerHTML);
}
}, [onChange]);
const insertTable = (rows: number, cols: number) => {
let tableHTML = '<table style="border-collapse: collapse; width: 100%; margin: 10px 0;">';
for (let r = 0; r < rows; r++) {
tableHTML += '<tr>';
for (let c = 0; c < cols; c++) {
const cellTag = r === 0 ? 'th' : 'td';
const border = 'border: 1px solid #ccc; padding: 8px;';
tableHTML += `<${cellTag} style="${border}"><br></${cellTag}>`;
}
tableHTML += '</tr>';
}
tableHTML += '</table><p><br></p>';
if (editorRef.current) {
editorRef.current.focus();
document.execCommand('insertHTML', false, tableHTML);
updateValue();
}
setShowTableMenu(false);
};
const insertRow = () => {
if (!editorRef.current) return;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
let cell = range.commonAncestorContainer;
while (cell && cell !== editorRef.current) {
if ((cell as HTMLElement).tagName === 'TD' || (cell as HTMLElement).tagName === 'TH') {
const row = (cell as HTMLElement).parentElement;
if (row && row.tagName === 'TR') {
const newRow = row.cloneNode(true) as HTMLElement;
row.parentElement?.insertBefore(newRow, row.nextSibling);
updateValue();
break;
}
}
cell = (cell as HTMLElement).parentElement || (cell as Text).parentElement!;
}
};
const insertColumn = () => {
if (!editorRef.current) return;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
let cell = range.commonAncestorContainer;
while (cell && cell !== editorRef.current) {
if ((cell as HTMLElement).tagName === 'TD' || (cell as HTMLElement).tagName === 'TH') {
const table = (cell as HTMLElement).closest('table');
if (table) {
const colIndex = Array.from((cell as HTMLElement).parentElement?.children || []).indexOf(cell as HTMLElement);
table.querySelectorAll('tr').forEach(row => {
const newCell = (cell as HTMLElement).cloneNode() as HTMLElement;
newCell.innerHTML = '<br>';
if (row.children[colIndex]) {
row.insertBefore(newCell, row.children[colIndex]);
} else {
row.appendChild(newCell);
}
});
updateValue();
break;
}
}
cell = (cell as HTMLElement).parentElement || (cell as Text).parentElement!;
}
};
const deleteTable = () => {
if (!editorRef.current) return;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
let cell = range.commonAncestorContainer;
while (cell && cell !== editorRef.current) {
if ((cell as HTMLElement).tagName === 'TABLE') {
(cell as HTMLElement).remove();
updateValue();
break;
}
cell = (cell as HTMLElement).parentElement || (cell as Text).parentElement!;
}
};
const ToolbarButton = ({ onClick, children, title }: { onClick: () => void; children: React.ReactNode; title: string }) => (
<button
type="button"
onClick={onClick}
title={title}
className="p-1.5 rounded text-slate-600 hover:bg-slate-100"
>
{children}
</button>
);
const ToolbarDivider = () => <div className="w-px h-5 bg-slate-300 mx-1" />;
return (
<div className="border border-slate-200 rounded-lg overflow-hidden bg-white">
<div className="flex items-center gap-1 px-2 py-1 bg-slate-50 border-b border-slate-200 flex-wrap">
<ToolbarButton onClick={() => execCommand('bold')} title="Bold">
<Bold className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton onClick={() => execCommand('italic')} title="Italic">
<Italic className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton onClick={() => execCommand('underline')} title="Underline">
<Underline className="w-4 h-4" />
</ToolbarButton>
<ToolbarDivider />
<ToolbarButton onClick={() => execCommand('insertUnorderedList')} title="Bullet List">
<List className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton onClick={() => execCommand('insertOrderedList')} title="Numbered List">
<ListOrdered className="w-4 h-4" />
</ToolbarButton>
<ToolbarDivider />
<ToolbarButton onClick={() => execCommand('justifyLeft')} title="Align Left">
<AlignLeft className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton onClick={() => execCommand('justifyCenter')} title="Align Center">
<AlignCenter className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton onClick={() => execCommand('justifyRight')} title="Align Right">
<AlignRight className="w-4 h-4" />
</ToolbarButton>
<ToolbarDivider />
<div className="relative">
<ToolbarButton onClick={() => setShowTableMenu(!showTableMenu)} title="Insert Table">
<Table className="w-4 h-4" />
</ToolbarButton>
{showTableMenu && (
<div className="absolute top-full left-0 mt-1 bg-white border border-slate-200 rounded-lg shadow-lg p-2 z-50 min-w-[160px]">
<div className="text-xs text-slate-500 mb-2 px-1">Insert Table</div>
<div className="grid grid-cols-4 gap-1 mb-2">
{[2, 3, 4, 5].map(rows =>
<Fragment key={rows}>
{[2, 3, 4, 5].map(cols => (
<button
key={`${rows}-${cols}`}
onClick={() => insertTable(rows, cols)}
className="w-6 h-6 text-xs bg-slate-100 hover:bg-blue-100 rounded flex items-center justify-center"
>
{rows}x{cols}
</button>
))}
</Fragment>
)}
</div>
<div className="border-t border-slate-200 pt-2 mt-2">
<button onClick={insertRow} className="w-full text-left px-2 py-1 text-sm hover:bg-slate-100 rounded flex items-center gap-2">
<Rows className="w-3 h-3" /> Add Row
</button>
<button onClick={insertColumn} className="w-full text-left px-2 py-1 text-sm hover:bg-slate-100 rounded flex items-center gap-2">
<Columns className="w-3 h-3" /> Add Column
</button>
<button onClick={deleteTable} className="w-full text-left px-2 py-1 text-sm hover:bg-red-50 text-red-600 rounded flex items-center gap-2">
<Trash2 className="w-3 h-3" /> Delete Table
</button>
</div>
</div>
)}
</div>
<ToolbarDivider />
<ToolbarButton onClick={() => execCommand('undo')} title="Undo">
<Undo className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton onClick={() => execCommand('redo')} title="Redo">
<Redo className="w-4 h-4" />
</ToolbarButton>
</div>
<div
ref={editorRef}
contentEditable
onBlur={updateValue}
onInput={updateValue}
className="px-3 py-2 text-sm text-slate-700 focus:outline-none prose prose-sm max-w-none"
style={{ minHeight }}
data-placeholder={placeholder}
suppressContentEditableWarning
/>
</div>
);
}