feat: add button reordering within rows and replace modal with inline add panel

- Add up/down arrow buttons to reorder buttons within rows (builtin + custom)
- Replace AddButtonDialog modal (bg-black/50 overlay) with inline expandable panel
- Add moveUp/moveDown i18n keys for all locales (en/ru/zh/fa)
- Add aria-labels for accessibility on reorder buttons
This commit is contained in:
Fringg
2026-03-09 23:26:16 +03:00
parent 23aa86f1a8
commit 082471bf92
5 changed files with 157 additions and 98 deletions

View File

@@ -86,6 +86,30 @@ const LinkIcon = () => (
</svg> </svg>
); );
const ArrowUpIcon = () => (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
</svg>
);
const ArrowDownIcon = () => (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
// ============ Helpers ============ // ============ Helpers ============
function generateId(): string { function generateId(): string {
@@ -137,6 +161,8 @@ interface ButtonChipProps {
onToggleExpand: () => void; onToggleExpand: () => void;
onUpdate: (updates: Partial<MenuButtonConfig>) => void; onUpdate: (updates: Partial<MenuButtonConfig>) => void;
onRemove: () => void; onRemove: () => void;
onMoveUp: (() => void) | null;
onMoveDown: (() => void) | null;
isBuiltin: boolean; isBuiltin: boolean;
} }
@@ -146,6 +172,8 @@ function ButtonChip({
onToggleExpand, onToggleExpand,
onUpdate, onUpdate,
onRemove, onRemove,
onMoveUp,
onMoveDown,
isBuiltin, isBuiltin,
}: ButtonChipProps) { }: ButtonChipProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -168,6 +196,32 @@ function ButtonChip({
> >
{/* Collapsed header */} {/* Collapsed header */}
<div className="flex items-center gap-2 px-3 py-2.5"> <div className="flex items-center gap-2 px-3 py-2.5">
<div className="flex shrink-0 flex-col">
<button
onClick={onMoveUp ?? undefined}
disabled={!onMoveUp}
aria-label={t('admin.menuEditor.moveUp')}
className={`rounded-lg p-1.5 transition-colors ${
onMoveUp
? 'text-dark-400 hover:bg-dark-700/50 hover:text-dark-300'
: 'cursor-default text-dark-700'
}`}
>
<ArrowUpIcon />
</button>
<button
onClick={onMoveDown ?? undefined}
disabled={!onMoveDown}
aria-label={t('admin.menuEditor.moveDown')}
className={`rounded-lg p-1.5 transition-colors ${
onMoveDown
? 'text-dark-400 hover:bg-dark-700/50 hover:text-dark-300'
: 'cursor-default text-dark-700'
}`}
>
<ArrowDownIcon />
</button>
</div>
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${colorDotClass}`} /> <span className={`h-2.5 w-2.5 shrink-0 rounded-full ${colorDotClass}`} />
<span className="min-w-0 flex-1 truncate text-sm font-medium text-dark-100"> <span className="min-w-0 flex-1 truncate text-sm font-medium text-dark-100">
{displayName} {displayName}
@@ -288,24 +342,30 @@ interface SortableRowProps {
row: MenuRowConfig; row: MenuRowConfig;
rowIndex: number; rowIndex: number;
expandedButtons: Set<string>; expandedButtons: Set<string>;
usedBuiltinIds: Set<string>;
onToggleExpand: (buttonId: string) => void; onToggleExpand: (buttonId: string) => void;
onUpdateRow: (rowId: string, updates: Partial<MenuRowConfig>) => void; onUpdateRow: (rowId: string, updates: Partial<MenuRowConfig>) => void;
onRemoveRow: (rowId: string) => void; onRemoveRow: (rowId: string) => void;
onUpdateButton: (rowId: string, buttonId: string, updates: Partial<MenuButtonConfig>) => void; onUpdateButton: (rowId: string, buttonId: string, updates: Partial<MenuButtonConfig>) => void;
onRemoveButton: (rowId: string, buttonId: string) => void; onRemoveButton: (rowId: string, buttonId: string) => void;
onAddButtonClick: (rowId: string) => void; onAddBuiltin: (rowId: string, sectionId: string) => void;
onAddCustom: (rowId: string) => void;
onReorderButton: (rowId: string, buttonIndex: number, direction: 'up' | 'down') => void;
} }
function SortableRow({ function SortableRow({
row, row,
rowIndex, rowIndex,
expandedButtons, expandedButtons,
usedBuiltinIds,
onToggleExpand, onToggleExpand,
onUpdateRow, onUpdateRow,
onRemoveRow, onRemoveRow,
onUpdateButton, onUpdateButton,
onRemoveButton, onRemoveButton,
onAddButtonClick, onAddBuiltin,
onAddCustom,
onReorderButton,
}: SortableRowProps) { }: SortableRowProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
@@ -359,7 +419,7 @@ function SortableRow({
{/* Row body */} {/* Row body */}
<div className="space-y-2 p-3"> <div className="space-y-2 p-3">
{row.buttons.map((button) => ( {row.buttons.map((button, btnIndex) => (
<ButtonChip <ButtonChip
key={button.id} key={button.id}
button={button} button={button}
@@ -367,105 +427,93 @@ function SortableRow({
onToggleExpand={() => onToggleExpand(button.id)} onToggleExpand={() => onToggleExpand(button.id)}
onUpdate={(updates) => onUpdateButton(row.id, button.id, updates)} onUpdate={(updates) => onUpdateButton(row.id, button.id, updates)}
onRemove={() => onRemoveButton(row.id, button.id)} 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'} isBuiltin={button.type === 'builtin'}
/> />
))} ))}
{/* Add button */} {/* Inline add button panel */}
<button <InlineAddPanel
onClick={() => onAddButtonClick(row.id)} rowId={row.id}
className="flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-dark-700/50 py-2.5 text-sm text-dark-500 transition-colors hover:border-dark-600 hover:text-dark-400" usedBuiltinIds={usedBuiltinIds}
> onAddBuiltin={onAddBuiltin}
<PlusIcon /> onAddCustom={onAddCustom}
{t('admin.menuEditor.addButton')} />
</button>
</div> </div>
</div> </div>
); );
} }
// ============ AddButtonDialog ============ // ============ InlineAddPanel ============
interface AddButtonDialogProps { interface InlineAddPanelProps {
rowId: string;
usedBuiltinIds: Set<string>; usedBuiltinIds: Set<string>;
onAddBuiltin: (sectionId: string) => void; onAddBuiltin: (rowId: string, sectionId: string) => void;
onAddCustom: () => void; onAddCustom: (rowId: string) => void;
onClose: () => void;
} }
function AddButtonDialog({ function InlineAddPanel({ rowId, usedBuiltinIds, onAddBuiltin, onAddCustom }: InlineAddPanelProps) {
usedBuiltinIds,
onAddBuiltin,
onAddCustom,
onClose,
}: AddButtonDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const availableBuiltins = BUILTIN_SECTIONS.filter((id) => !usedBuiltinIds.has(id)); const availableBuiltins = BUILTIN_SECTIONS.filter((id) => !usedBuiltinIds.has(id));
return ( if (!isOpen) {
<div return (
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" <button
onClick={onClose} onClick={() => setIsOpen(true)}
> className="flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-dark-700/50 py-2.5 text-sm text-dark-500 transition-colors hover:border-dark-600 hover:text-dark-400"
<div
className="w-full max-w-sm overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-800 shadow-xl"
onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center justify-between border-b border-dark-700/30 px-4 py-3"> <PlusIcon />
<h3 className="text-sm font-semibold text-dark-100">{t('admin.menuEditor.addButton')}</h3> {t('admin.menuEditor.addButton')}
<button </button>
onClick={onClose} );
className="rounded-lg p-1 text-dark-400 transition-colors hover:bg-dark-700/50 hover:text-dark-300" }
>
<svg return (
className="h-5 w-5" <div className="space-y-1 rounded-xl border border-dark-700/50 bg-dark-900/30 p-2">
fill="none" {availableBuiltins.length > 0 && (
viewBox="0 0 24 24" <>
stroke="currentColor" <p className="px-2 pb-0.5 text-xs font-medium text-dark-500">
strokeWidth={2} {t('admin.menuEditor.builtinButtons')}
</p>
{availableBuiltins.map((id) => (
<button
key={id}
onClick={() => {
onAddBuiltin(rowId, id);
setIsOpen(false);
}}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-dark-200 transition-colors hover:bg-dark-700/50"
> >
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> {t(`admin.buttons.sections.${id}`)}
</svg> </button>
</button> ))}
</div> <div className="my-1 border-t border-dark-700/30" />
</>
<div className="max-h-72 space-y-1 overflow-y-auto p-3"> )}
{/* Available builtins */} <button
{availableBuiltins.length > 0 && ( onClick={() => {
<> onAddCustom(rowId);
<p className="px-2 pb-1 text-xs font-medium text-dark-500"> setIsOpen(false);
{t('admin.menuEditor.builtinButtons')} }}
</p> className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-dark-200 transition-colors hover:bg-dark-700/50"
{availableBuiltins.map((id) => ( >
<button <LinkIcon />
key={id} {t('admin.menuEditor.addUrlButton')}
onClick={() => { </button>
onAddBuiltin(id); <button
onClose(); onClick={() => setIsOpen(false)}
}} className="flex w-full items-center justify-center rounded-lg py-1.5 text-xs text-dark-500 transition-colors hover:text-dark-400"
className="flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-left text-sm text-dark-200 transition-colors hover:bg-dark-700/50" >
> {t('common.cancel')}
{t(`admin.buttons.sections.${id}`)} </button>
</button>
))}
<div className="my-2 border-t border-dark-700/30" />
</>
)}
{/* Custom URL button */}
<button
onClick={() => {
onAddCustom();
onClose();
}}
className="flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-left text-sm text-dark-200 transition-colors hover:bg-dark-700/50"
>
<LinkIcon />
{t('admin.menuEditor.addUrlButton')}
</button>
</div>
</div>
</div> </div>
); );
} }
@@ -490,7 +538,6 @@ export function MenuEditorTab() {
// Draft state // Draft state
const [draftConfig, setDraftConfig] = useState<MenuConfig>(DEFAULT_CONFIG); const [draftConfig, setDraftConfig] = useState<MenuConfig>(DEFAULT_CONFIG);
const [expandedButtons, setExpandedButtons] = useState<Set<string>>(new Set()); const [expandedButtons, setExpandedButtons] = useState<Set<string>>(new Set());
const [addingToRow, setAddingToRow] = useState<string | null>(null);
const savedConfigRef = useRef<MenuConfig>(DEFAULT_CONFIG); const savedConfigRef = useRef<MenuConfig>(DEFAULT_CONFIG);
const draftConfigRef = useRef(draftConfig); const draftConfigRef = useRef(draftConfig);
draftConfigRef.current = draftConfig; draftConfigRef.current = draftConfig;
@@ -645,9 +692,20 @@ export function MenuEditorTab() {
})); }));
}, []); }, []);
const handleAddButtonClick = useCallback((rowId: string) => { const reorderButton = useCallback(
setAddingToRow(rowId); (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 // Expand/collapse
const toggleExpand = useCallback((buttonId: string) => { const toggleExpand = useCallback((buttonId: string) => {
@@ -746,12 +804,15 @@ export function MenuEditorTab() {
row={row} row={row}
rowIndex={index} rowIndex={index}
expandedButtons={expandedButtons} expandedButtons={expandedButtons}
usedBuiltinIds={usedBuiltinIds}
onToggleExpand={toggleExpand} onToggleExpand={toggleExpand}
onUpdateRow={updateRow} onUpdateRow={updateRow}
onRemoveRow={removeRow} onRemoveRow={removeRow}
onUpdateButton={updateButton} onUpdateButton={updateButton}
onRemoveButton={removeButton} onRemoveButton={removeButton}
onAddButtonClick={handleAddButtonClick} onAddBuiltin={addBuiltinButton}
onAddCustom={addCustomButton}
onReorderButton={reorderButton}
/> />
))} ))}
</div> </div>
@@ -801,16 +862,6 @@ export function MenuEditorTab() {
{t('admin.buttons.resetAll')} {t('admin.buttons.resetAll')}
</button> </button>
</div> </div>
{/* Add button dialog */}
{addingToRow && (
<AddButtonDialog
usedBuiltinIds={usedBuiltinIds}
onAddBuiltin={(sectionId) => addBuiltinButton(addingToRow, sectionId)}
onAddCustom={() => addCustomButton(addingToRow)}
onClose={() => setAddingToRow(null)}
/>
)}
</div> </div>
); );
} }

View File

@@ -1779,6 +1779,8 @@
"resetConfirm": "Reset menu layout to defaults?", "resetConfirm": "Reset menu layout to defaults?",
"buttonTextPlaceholder": "Custom button text", "buttonTextPlaceholder": "Custom button text",
"customLabelsHint": "Empty = default label", "customLabelsHint": "Empty = default label",
"moveUp": "Move up",
"moveDown": "Move down",
"invalidUrl": "Please provide a valid URL (http:// or https://) for all custom buttons" "invalidUrl": "Please provide a valid URL (http:// or https://) for all custom buttons"
}, },
"apps": { "apps": {

View File

@@ -1441,6 +1441,8 @@
"resetConfirm": "منو به تنظیمات پیش‌فرض بازنشانی شود؟ تمام تغییرات از بین می‌رود.", "resetConfirm": "منو به تنظیمات پیش‌فرض بازنشانی شود؟ تمام تغییرات از بین می‌رود.",
"buttonTextPlaceholder": "متن دکمه", "buttonTextPlaceholder": "متن دکمه",
"customLabelsHint": "متن دکمه برای هر زبان ربات", "customLabelsHint": "متن دکمه برای هر زبان ربات",
"moveUp": "انتقال به بالا",
"moveDown": "انتقال به پایین",
"invalidUrl": "لطفاً برای تمام دکمه‌های سفارشی یک URL معتبر وارد کنید (http:// یا https://)" "invalidUrl": "لطفاً برای تمام دکمه‌های سفارشی یک URL معتبر وارد کنید (http:// یا https://)"
}, },
"apps": { "apps": {

View File

@@ -2290,6 +2290,8 @@
"resetConfirm": "Сбросить компоновку меню к значениям по умолчанию?", "resetConfirm": "Сбросить компоновку меню к значениям по умолчанию?",
"buttonTextPlaceholder": "Свой текст кнопки", "buttonTextPlaceholder": "Свой текст кнопки",
"customLabelsHint": "Пустое поле = название по умолчанию", "customLabelsHint": "Пустое поле = название по умолчанию",
"moveUp": "Переместить вверх",
"moveDown": "Переместить вниз",
"invalidUrl": "Укажите корректный URL (http:// или https://) для всех пользовательских кнопок" "invalidUrl": "Укажите корректный URL (http:// или https://) для всех пользовательских кнопок"
}, },
"apps": { "apps": {

View File

@@ -1479,6 +1479,8 @@
"resetConfirm": "将菜单重置为默认设置?所有更改将丢失。", "resetConfirm": "将菜单重置为默认设置?所有更改将丢失。",
"buttonTextPlaceholder": "按钮文本", "buttonTextPlaceholder": "按钮文本",
"customLabelsHint": "每种语言的按钮文本", "customLabelsHint": "每种语言的按钮文本",
"moveUp": "上移",
"moveDown": "下移",
"invalidUrl": "请为所有自定义按钮提供有效的URLhttp:// 或 https://" "invalidUrl": "请为所有自定义按钮提供有效的URLhttp:// 或 https://"
}, },
"apps": { "apps": {