import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, type DragEndEvent, } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { PiCaretDown } from 'react-icons/pi'; import { GripIcon, TrashIcon, PlusIcon, LinkIcon, ArrowUpIcon, ArrowDownIcon, } from '@/components/icons'; import { menuLayoutApi, type MenuConfig, type MenuRowConfig, type MenuButtonConfig, BUILTIN_SECTIONS, BOT_LOCALES, STYLE_OPTIONS, } from '../../api/menuLayout'; import { Toggle } from './Toggle'; import { useNotify } from '../../platform/hooks/useNotify'; import { useNativeDialog } from '../../platform/hooks/useNativeDialog'; const ChevronIcon = ({ expanded }: { expanded: boolean }) => ( ); function generateId(): string { return `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; } function generateRowId(): string { return `row_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; } function configsEqual(a: MenuConfig, b: MenuConfig): boolean { return JSON.stringify(a) === JSON.stringify(b); } const DEFAULT_CONFIG: MenuConfig = { rows: [] }; interface MaxPerRowSelectorProps { value: number; onChange: (value: number) => void; } function MaxPerRowSelector({ value, onChange }: MaxPerRowSelectorProps) { return (
{[1, 2, 3].map((n) => ( ))}
); } interface ButtonChipProps { button: MenuButtonConfig; isExpanded: boolean; onToggleExpand: () => void; onUpdate: (updates: Partial) => void; onRemove: () => void; onMoveUp: (() => void) | null; onMoveDown: (() => void) | null; isBuiltin: boolean; } function ButtonChip({ button, isExpanded, onToggleExpand, onUpdate, onRemove, onMoveUp, onMoveDown, isBuiltin, }: ButtonChipProps) { const { t } = useTranslation(); const displayName = button.labels.ru || button.labels.en || (isBuiltin ? t(`admin.buttons.sections.${button.id}`) : button.id); const styleOption = STYLE_OPTIONS.find((s) => s.value === button.style); const colorDotClass = styleOption?.colorClass || 'bg-dark-500'; return (
{/* Collapsed header */}
{displayName} {!isBuiltin && ( )} onUpdate({ enabled: !button.enabled })} /> {!isBuiltin && ( )}
{/* Expanded body */} {isExpanded && (
{/* Color selector */}
{STYLE_OPTIONS.map((opt) => ( ))}
{/* Emoji ID */}
onUpdate({ icon_custom_emoji_id: e.target.value })} placeholder={t('admin.buttons.emojiPlaceholder')} className="w-full rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-2 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none" />
{/* URL input + open mode (custom buttons only) */} {!isBuiltin && ( <>
onUpdate({ url: e.target.value || null })} placeholder="https://..." className="w-full rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-2 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none" />
{(['external', 'webapp'] as const).map((mode) => ( ))}
)} {/* Localized labels */}
{BOT_LOCALES.map((locale) => (
{locale} onUpdate({ labels: { ...button.labels, [locale]: e.target.value }, }) } placeholder={t('admin.menuEditor.buttonTextPlaceholder')} maxLength={100} className="w-full rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-1.5 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none" />
))}

{t('admin.menuEditor.customLabelsHint')}

)}
); } interface SortableRowProps { row: MenuRowConfig; rowIndex: number; expandedButtons: Set; usedBuiltinIds: Set; onToggleExpand: (buttonId: string) => void; onUpdateRow: (rowId: string, updates: Partial) => void; onRemoveRow: (rowId: string) => void; onUpdateButton: (rowId: string, buttonId: string, updates: Partial) => void; onRemoveButton: (rowId: string, buttonId: string) => void; onAddBuiltin: (rowId: string, sectionId: string) => void; onAddCustom: (rowId: string) => void; onReorderButton: (rowId: string, buttonIndex: number, direction: 'up' | 'down') => void; } function SortableRow({ row, rowIndex, expandedButtons, usedBuiltinIds, onToggleExpand, onUpdateRow, onRemoveRow, onUpdateButton, onRemoveButton, onAddBuiltin, onAddCustom, onReorderButton, }: SortableRowProps) { const { t } = useTranslation(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: row.id, }); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 50 : undefined, position: isDragging ? 'relative' : undefined, }; const allBuiltin = row.buttons.every((b) => b.type === 'builtin'); return (
{/* Row header */}
{t('admin.menuEditor.row')} {rowIndex + 1}
onUpdateRow(row.id, { max_per_row: value })} /> {!allBuiltin && ( )}
{/* Row body */}
{row.buttons.map((button, btnIndex) => ( onToggleExpand(button.id)} onUpdate={(updates) => onUpdateButton(row.id, button.id, updates)} onRemove={() => onRemoveButton(row.id, button.id)} onMoveUp={btnIndex > 0 ? () => onReorderButton(row.id, btnIndex, 'up') : null} onMoveDown={ btnIndex < row.buttons.length - 1 ? () => onReorderButton(row.id, btnIndex, 'down') : null } isBuiltin={button.type === 'builtin'} /> ))} {/* Inline add button panel */}
); } interface InlineAddPanelProps { rowId: string; usedBuiltinIds: Set; onAddBuiltin: (rowId: string, sectionId: string) => void; onAddCustom: (rowId: string) => void; } function InlineAddPanel({ rowId, usedBuiltinIds, onAddBuiltin, onAddCustom }: InlineAddPanelProps) { const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); const availableBuiltins = BUILTIN_SECTIONS.filter((id) => !usedBuiltinIds.has(id)); if (!isOpen) { return ( ); } return (
{availableBuiltins.length > 0 && ( <>

{t('admin.menuEditor.builtinButtons')}

{availableBuiltins.map((id) => ( ))}
)}
); } export function MenuEditorTab() { const { t } = useTranslation(); const queryClient = useQueryClient(); const notify = useNotify(); const { confirm: confirmDialog } = useNativeDialog(); // Fetch config const { data: serverConfig, isLoading, isError, } = useQuery({ queryKey: ['menu-layout'], queryFn: menuLayoutApi.getConfig, }); // Draft state const [draftConfig, setDraftConfig] = useState(DEFAULT_CONFIG); const [expandedButtons, setExpandedButtons] = useState>(new Set()); const savedConfigRef = useRef(DEFAULT_CONFIG); const draftConfigRef = useRef(draftConfig); draftConfigRef.current = draftConfig; // Sync server data to draft (same pattern as ButtonsTab) useEffect(() => { if (serverConfig) { if ( configsEqual(savedConfigRef.current, draftConfigRef.current) || configsEqual(savedConfigRef.current, DEFAULT_CONFIG) ) { setDraftConfig(serverConfig); savedConfigRef.current = serverConfig; } } }, [serverConfig]); const hasUnsavedChanges = !configsEqual(draftConfig, savedConfigRef.current); // Mutations const updateMutation = useMutation({ mutationFn: menuLayoutApi.updateConfig, onSuccess: (data) => { savedConfigRef.current = data; setDraftConfig(data); queryClient.setQueryData(['menu-layout'], data); }, onError: (err: unknown) => { const error = err as { response?: { data?: { detail?: string } } }; const detail = error.response?.data?.detail; notify.error(detail || t('common.error')); }, }); const resetMutation = useMutation({ mutationFn: menuLayoutApi.resetConfig, onSuccess: (data) => { savedConfigRef.current = data; setDraftConfig(data); queryClient.setQueryData(['menu-layout'], data); }, onError: () => { notify.error(t('common.error')); }, }); // DnD sensors const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); const handleDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event; if (over && active.id !== over.id) { setDraftConfig((prev) => { const oldIndex = prev.rows.findIndex((r) => r.id === active.id); const newIndex = prev.rows.findIndex((r) => r.id === over.id); if (oldIndex === -1 || newIndex === -1) return prev; return { ...prev, rows: arrayMove(prev.rows, oldIndex, newIndex) }; }); } }, []); // Row CRUD const updateRow = useCallback((rowId: string, updates: Partial) => { setDraftConfig((prev) => ({ ...prev, rows: prev.rows.map((r) => (r.id === rowId ? { ...r, ...updates } : r)), })); }, []); const removeRow = useCallback((rowId: string) => { setDraftConfig((prev) => ({ ...prev, rows: prev.rows.filter((r) => r.id !== rowId), })); }, []); const addRow = useCallback(() => { setDraftConfig((prev) => ({ ...prev, rows: [ ...prev.rows, { id: generateRowId(), max_per_row: 2, buttons: [], }, ], })); }, []); // Button CRUD const updateButton = useCallback( (rowId: string, buttonId: string, updates: Partial) => { setDraftConfig((prev) => ({ ...prev, rows: prev.rows.map((r) => r.id === rowId ? { ...r, buttons: r.buttons.map((b) => (b.id === buttonId ? { ...b, ...updates } : b)), } : r, ), })); }, [], ); const removeButton = useCallback((rowId: string, buttonId: string) => { setDraftConfig((prev) => ({ ...prev, rows: prev.rows.map((r) => r.id === rowId ? { ...r, buttons: r.buttons.filter((b) => b.id !== buttonId) } : r, ), })); }, []); const addBuiltinButton = useCallback((rowId: string, sectionId: string) => { const newButton: MenuButtonConfig = { id: sectionId, type: 'builtin', style: 'default', icon_custom_emoji_id: '', enabled: true, labels: {}, url: null, open_in: 'external', }; setDraftConfig((prev) => ({ ...prev, rows: prev.rows.map((r) => r.id === rowId ? { ...r, buttons: [...r.buttons, newButton] } : r, ), })); }, []); const addCustomButton = useCallback((rowId: string) => { const newButton: MenuButtonConfig = { id: generateId(), type: 'custom', style: 'default', icon_custom_emoji_id: '', enabled: true, labels: {}, url: '', open_in: 'external', }; setDraftConfig((prev) => ({ ...prev, rows: prev.rows.map((r) => r.id === rowId ? { ...r, buttons: [...r.buttons, newButton] } : r, ), })); }, []); const reorderButton = useCallback( (rowId: string, buttonIndex: number, direction: 'up' | 'down') => { setDraftConfig((prev) => ({ ...prev, rows: prev.rows.map((r) => { if (r.id !== rowId) return r; const newIndex = direction === 'up' ? buttonIndex - 1 : buttonIndex + 1; if (newIndex < 0 || newIndex >= r.buttons.length) return r; return { ...r, buttons: arrayMove(r.buttons, buttonIndex, newIndex) }; }), })); }, [], ); // Expand/collapse const toggleExpand = useCallback((buttonId: string) => { setExpandedButtons((prev) => { const next = new Set(prev); if (next.has(buttonId)) { next.delete(buttonId); } else { next.add(buttonId); } return next; }); }, []); // Collect used builtin IDs across all rows const usedBuiltinIds = useMemo( () => new Set( draftConfig.rows.flatMap((r) => r.buttons.filter((b) => b.type === 'builtin').map((b) => b.id), ), ), [draftConfig.rows], ); // Handlers const handleSave = useCallback(() => { // Validate custom buttons have valid URLs const currentDraft = draftConfigRef.current; for (const row of currentDraft.rows) { for (const btn of row.buttons) { if (btn.type === 'custom') { if ( !btn.url || (!btn.url.startsWith('http://') && !btn.url.startsWith('https://') && !btn.url.startsWith('tg://')) ) { notify.error(t('admin.menuEditor.invalidUrl')); return; } } } } updateMutation.mutate(currentDraft); }, [updateMutation, notify, t]); const handleCancel = useCallback(() => { setDraftConfig(savedConfigRef.current); }, []); if (isLoading) { return (
{t('common.loading')}
); } if (isError) { return (
{t('common.error')}
); } return (
{/* Drag hint */}
{t('admin.menuEditor.dragHint')}
{/* Rows */} r.id)} strategy={verticalListSortingStrategy} >
{draftConfig.rows.map((row, index) => ( ))}
{/* Add row */} {/* Save / Cancel */} {hasUnsavedChanges && (
)} {/* Reset */}
); }