mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat: add menu editor tab with drag-and-drop rows, custom URL buttons, and button configuration
- Add menuLayout.ts API client with normalizeConfig and type definitions - Add MenuEditorTab.tsx with DnD rows, button CRUD, style/emoji/label editors - Replace ButtonsTab with MenuEditorTab on admin settings page - Add menu editor translations for ru, en, zh, fa locales - Loading/error states, useMemo optimizations, proper ref patterns
This commit is contained in:
88
src/api/menuLayout.ts
Normal file
88
src/api/menuLayout.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface MenuButtonConfig {
|
||||
id: string;
|
||||
type: 'builtin' | 'custom';
|
||||
style: 'primary' | 'success' | 'danger' | 'default';
|
||||
icon_custom_emoji_id: string;
|
||||
enabled: boolean;
|
||||
labels: Record<string, string>;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface MenuRowConfig {
|
||||
id: string;
|
||||
max_per_row: number;
|
||||
buttons: MenuButtonConfig[];
|
||||
}
|
||||
|
||||
export interface MenuConfig {
|
||||
rows: MenuRowConfig[];
|
||||
}
|
||||
|
||||
export const BOT_LOCALES = ['ru', 'en', 'ua', 'zh', 'fa'] as const;
|
||||
export type BotLocale = (typeof BOT_LOCALES)[number];
|
||||
|
||||
export const BUILTIN_SECTIONS = [
|
||||
'home',
|
||||
'subscription',
|
||||
'balance',
|
||||
'referral',
|
||||
'support',
|
||||
'info',
|
||||
'admin',
|
||||
'language',
|
||||
] as const;
|
||||
|
||||
export type BuiltinSection = (typeof BUILTIN_SECTIONS)[number];
|
||||
|
||||
export const STYLE_OPTIONS = [
|
||||
{ value: 'default' as const, colorClass: 'bg-dark-500' },
|
||||
{ value: 'primary' as const, colorClass: 'bg-blue-500' },
|
||||
{ value: 'success' as const, colorClass: 'bg-success-500' },
|
||||
{ value: 'danger' as const, colorClass: 'bg-red-500' },
|
||||
];
|
||||
|
||||
const DEFAULT_CONFIG: MenuConfig = { rows: [] };
|
||||
|
||||
const DEFAULT_BUTTON: Omit<MenuButtonConfig, 'id' | 'type'> = {
|
||||
style: 'primary',
|
||||
icon_custom_emoji_id: '',
|
||||
enabled: true,
|
||||
labels: {},
|
||||
url: null,
|
||||
};
|
||||
|
||||
function normalizeConfig(data: MenuConfig): MenuConfig {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
return {
|
||||
rows: (data.rows || []).map((row) => ({
|
||||
id: row.id,
|
||||
max_per_row: row.max_per_row ?? 2,
|
||||
buttons: (row.buttons || []).map((btn) => ({
|
||||
...DEFAULT_BUTTON,
|
||||
...btn,
|
||||
labels: { ...(btn.labels || {}) },
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export const menuLayoutApi = {
|
||||
getConfig: async (): Promise<MenuConfig> => {
|
||||
const response = await apiClient.get<MenuConfig>('/cabinet/admin/menu-layout');
|
||||
return normalizeConfig(response.data);
|
||||
},
|
||||
|
||||
updateConfig: async (config: MenuConfig): Promise<MenuConfig> => {
|
||||
const response = await apiClient.put<MenuConfig>('/cabinet/admin/menu-layout', config);
|
||||
return normalizeConfig(response.data);
|
||||
},
|
||||
|
||||
resetConfig: async (): Promise<MenuConfig> => {
|
||||
const response = await apiClient.post<MenuConfig>('/cabinet/admin/menu-layout/reset');
|
||||
return normalizeConfig(response.data);
|
||||
},
|
||||
};
|
||||
816
src/components/admin/MenuEditorTab.tsx
Normal file
816
src/components/admin/MenuEditorTab.tsx
Normal file
@@ -0,0 +1,816 @@
|
||||
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 {
|
||||
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';
|
||||
|
||||
// ============ Icons ============
|
||||
|
||||
const GripIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TrashIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PlusIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
|
||||
<svg
|
||||
className={`h-3.5 w-3.5 transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const LinkIcon = () => (
|
||||
<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="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.54a4.5 4.5 0 00-1.242-7.244l-4.5-4.5a4.5 4.5 0 00-6.364 6.364L4.03 8.591"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============ Helpers ============
|
||||
|
||||
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: [] };
|
||||
|
||||
// ============ MaxPerRowSelector ============
|
||||
|
||||
interface MaxPerRowSelectorProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function MaxPerRowSelector({ value, onChange }: MaxPerRowSelectorProps) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => onChange(n)}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-lg text-xs font-semibold transition-all ${
|
||||
value === n
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'bg-dark-700/50 text-dark-400 hover:bg-dark-600 hover:text-dark-300'
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ ButtonChip ============
|
||||
|
||||
interface ButtonChipProps {
|
||||
button: MenuButtonConfig;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onUpdate: (updates: Partial<MenuButtonConfig>) => void;
|
||||
onRemove: () => void;
|
||||
isBuiltin: boolean;
|
||||
}
|
||||
|
||||
function ButtonChip({
|
||||
button,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
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 (
|
||||
<div
|
||||
className={`overflow-hidden rounded-xl border transition-colors ${
|
||||
button.enabled
|
||||
? 'border-dark-700/50 bg-dark-800/50'
|
||||
: 'border-dark-700/30 bg-dark-800/30 opacity-60'
|
||||
}`}
|
||||
>
|
||||
{/* Collapsed header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2.5">
|
||||
<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">
|
||||
{displayName}
|
||||
</span>
|
||||
{!isBuiltin && (
|
||||
<span className="text-dark-500" title="URL">
|
||||
<LinkIcon />
|
||||
</span>
|
||||
)}
|
||||
<Toggle checked={button.enabled} onChange={() => onUpdate({ enabled: !button.enabled })} />
|
||||
<button
|
||||
onClick={onToggleExpand}
|
||||
className="rounded-lg p-1 text-dark-400 transition-colors hover:bg-dark-700/50 hover:text-dark-300"
|
||||
>
|
||||
<ChevronIcon expanded={isExpanded} />
|
||||
</button>
|
||||
{!isBuiltin && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="rounded-lg p-1 text-dark-500 transition-colors hover:bg-red-500/10 hover:text-red-400"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded body */}
|
||||
{isExpanded && (
|
||||
<div className="space-y-3 border-t border-dark-700/30 px-3 py-3">
|
||||
{/* Color selector */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-dark-300">
|
||||
{t('admin.buttons.color')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{STYLE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => onUpdate({ style: opt.value })}
|
||||
className={`flex h-7 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-all ${
|
||||
button.style === opt.value
|
||||
? 'border-accent-500 bg-accent-500/10 text-accent-400'
|
||||
: 'border-dark-600 bg-dark-700/50 text-dark-300 hover:border-dark-500'
|
||||
}`}
|
||||
>
|
||||
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${opt.colorClass}`} />
|
||||
{t(`admin.buttons.styles.${opt.value}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Emoji ID */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-dark-300">
|
||||
{t('admin.buttons.emojiId')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={button.icon_custom_emoji_id}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL input (custom buttons only) */}
|
||||
{!isBuiltin && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-dark-300">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={button.url || ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Localized labels */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-dark-300">
|
||||
{t('admin.buttons.customLabels')}
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{BOT_LOCALES.map((locale) => (
|
||||
<div key={locale} className="flex items-center gap-2">
|
||||
<span className="w-7 shrink-0 text-center text-[10px] font-semibold uppercase text-dark-500">
|
||||
{locale}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={button.labels[locale] || ''}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-[10px] text-dark-500">{t('admin.menuEditor.customLabelsHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ SortableRow ============
|
||||
|
||||
interface SortableRowProps {
|
||||
row: MenuRowConfig;
|
||||
rowIndex: number;
|
||||
expandedButtons: Set<string>;
|
||||
onToggleExpand: (buttonId: string) => void;
|
||||
onUpdateRow: (rowId: string, updates: Partial<MenuRowConfig>) => void;
|
||||
onRemoveRow: (rowId: string) => void;
|
||||
onUpdateButton: (rowId: string, buttonId: string, updates: Partial<MenuButtonConfig>) => void;
|
||||
onRemoveButton: (rowId: string, buttonId: string) => void;
|
||||
onAddButtonClick: (rowId: string) => void;
|
||||
}
|
||||
|
||||
function SortableRow({
|
||||
row,
|
||||
rowIndex,
|
||||
expandedButtons,
|
||||
onToggleExpand,
|
||||
onUpdateRow,
|
||||
onRemoveRow,
|
||||
onUpdateButton,
|
||||
onRemoveButton,
|
||||
onAddButtonClick,
|
||||
}: 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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`overflow-hidden rounded-2xl border bg-dark-800/50 transition-all ${
|
||||
isDragging ? 'border-accent-500/50 shadow-xl shadow-accent-500/20' : 'border-dark-700/50'
|
||||
}`}
|
||||
>
|
||||
{/* Row header */}
|
||||
<div className="flex items-center gap-3 border-b border-dark-700/30 px-4 py-3">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="flex-shrink-0 cursor-grab touch-none rounded-lg p-1.5 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing"
|
||||
title={t('admin.menuEditor.dragToReorder')}
|
||||
>
|
||||
<GripIcon />
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-dark-200">
|
||||
{t('admin.menuEditor.row')} {rowIndex + 1}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<MaxPerRowSelector
|
||||
value={row.max_per_row}
|
||||
onChange={(value) => onUpdateRow(row.id, { max_per_row: value })}
|
||||
/>
|
||||
{!allBuiltin && (
|
||||
<button
|
||||
onClick={() => onRemoveRow(row.id)}
|
||||
className="rounded-lg p-1.5 text-dark-500 transition-colors hover:bg-red-500/10 hover:text-red-400"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row body */}
|
||||
<div className="space-y-2 p-3">
|
||||
{row.buttons.map((button) => (
|
||||
<ButtonChip
|
||||
key={button.id}
|
||||
button={button}
|
||||
isExpanded={expandedButtons.has(button.id)}
|
||||
onToggleExpand={() => onToggleExpand(button.id)}
|
||||
onUpdate={(updates) => onUpdateButton(row.id, button.id, updates)}
|
||||
onRemove={() => onRemoveButton(row.id, button.id)}
|
||||
isBuiltin={button.type === 'builtin'}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Add button */}
|
||||
<button
|
||||
onClick={() => onAddButtonClick(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"
|
||||
>
|
||||
<PlusIcon />
|
||||
{t('admin.menuEditor.addButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ AddButtonDialog ============
|
||||
|
||||
interface AddButtonDialogProps {
|
||||
usedBuiltinIds: Set<string>;
|
||||
onAddBuiltin: (sectionId: string) => void;
|
||||
onAddCustom: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function AddButtonDialog({
|
||||
usedBuiltinIds,
|
||||
onAddBuiltin,
|
||||
onAddCustom,
|
||||
onClose,
|
||||
}: AddButtonDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const availableBuiltins = BUILTIN_SECTIONS.filter((id) => !usedBuiltinIds.has(id));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<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">
|
||||
<h3 className="text-sm font-semibold text-dark-100">{t('admin.menuEditor.addButton')}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1 text-dark-400 transition-colors hover:bg-dark-700/50 hover:text-dark-300"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 space-y-1 overflow-y-auto p-3">
|
||||
{/* Available builtins */}
|
||||
{availableBuiltins.length > 0 && (
|
||||
<>
|
||||
<p className="px-2 pb-1 text-xs font-medium text-dark-500">
|
||||
{t('admin.menuEditor.builtinButtons')}
|
||||
</p>
|
||||
{availableBuiltins.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => {
|
||||
onAddBuiltin(id);
|
||||
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"
|
||||
>
|
||||
{t(`admin.buttons.sections.${id}`)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ MenuEditorTab ============
|
||||
|
||||
export function MenuEditorTab() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const notify = useNotify();
|
||||
|
||||
// Fetch config
|
||||
const {
|
||||
data: serverConfig,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ['menu-layout'],
|
||||
queryFn: menuLayoutApi.getConfig,
|
||||
});
|
||||
|
||||
// Draft state
|
||||
const [draftConfig, setDraftConfig] = useState<MenuConfig>(DEFAULT_CONFIG);
|
||||
const [expandedButtons, setExpandedButtons] = useState<Set<string>>(new Set());
|
||||
const [addingToRow, setAddingToRow] = useState<string | null>(null);
|
||||
const savedConfigRef = useRef<MenuConfig>(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: () => {
|
||||
notify.error(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<MenuRowConfig>) => {
|
||||
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<MenuButtonConfig>) => {
|
||||
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,
|
||||
};
|
||||
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: '',
|
||||
};
|
||||
setDraftConfig((prev) => ({
|
||||
...prev,
|
||||
rows: prev.rows.map((r) =>
|
||||
r.id === rowId ? { ...r, buttons: [...r.buttons, newButton] } : r,
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleAddButtonClick = useCallback((rowId: string) => {
|
||||
setAddingToRow(rowId);
|
||||
}, []);
|
||||
|
||||
// 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://'))) {
|
||||
notify.error(t('admin.menuEditor.invalidUrl'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
updateMutation.mutate(currentDraft);
|
||||
}, [updateMutation, notify, t]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setDraftConfig(savedConfigRef.current);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-dark-400">
|
||||
<svg className="mr-2 h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||
{t('common.error')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Drag hint */}
|
||||
<div className="flex items-center gap-2 text-sm text-dark-500">
|
||||
<GripIcon />
|
||||
{t('admin.menuEditor.dragHint')}
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={draftConfig.rows.map((r) => r.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{draftConfig.rows.map((row, index) => (
|
||||
<SortableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
rowIndex={index}
|
||||
expandedButtons={expandedButtons}
|
||||
onToggleExpand={toggleExpand}
|
||||
onUpdateRow={updateRow}
|
||||
onRemoveRow={removeRow}
|
||||
onUpdateButton={updateButton}
|
||||
onRemoveButton={removeButton}
|
||||
onAddButtonClick={handleAddButtonClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* Add row */}
|
||||
<button
|
||||
onClick={addRow}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-dark-700/50 py-4 text-sm font-medium text-dark-500 transition-colors hover:border-dark-600 hover:text-dark-400"
|
||||
>
|
||||
<PlusIcon />
|
||||
{t('admin.menuEditor.addRow')}
|
||||
</button>
|
||||
|
||||
{/* Save / Cancel */}
|
||||
{hasUnsavedChanges && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
className="rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
{updateMutation.isPending ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={updateMutation.isPending}
|
||||
className="rounded-xl bg-dark-700 px-4 py-2 text-sm font-medium text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(t('admin.menuEditor.resetConfirm'))) {
|
||||
resetMutation.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={resetMutation.isPending}
|
||||
className="rounded-xl bg-dark-700 px-4 py-2 text-sm text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||
>
|
||||
{t('admin.buttons.resetAll')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add button dialog */}
|
||||
{addingToRow && (
|
||||
<AddButtonDialog
|
||||
usedBuiltinIds={usedBuiltinIds}
|
||||
onAddBuiltin={(sectionId) => addBuiltinButton(addingToRow, sectionId)}
|
||||
onAddCustom={() => addCustomButton(addingToRow)}
|
||||
onClose={() => setAddingToRow(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1748,7 +1748,8 @@
|
||||
"referral": "Referrals",
|
||||
"support": "Support",
|
||||
"info": "Info",
|
||||
"admin": "Admin Panel"
|
||||
"admin": "Admin Panel",
|
||||
"language": "Language"
|
||||
},
|
||||
"descriptions": {
|
||||
"home": "Personal cabinet button",
|
||||
@@ -1757,7 +1758,8 @@
|
||||
"referral": "Referral program",
|
||||
"support": "Support tickets",
|
||||
"info": "Information section",
|
||||
"admin": "Web admin panel"
|
||||
"admin": "Web admin panel",
|
||||
"language": "Language selection"
|
||||
},
|
||||
"styles": {
|
||||
"default": "No color",
|
||||
@@ -1766,6 +1768,19 @@
|
||||
"danger": "Red"
|
||||
}
|
||||
},
|
||||
"menuEditor": {
|
||||
"dragHint": "Drag rows to reorder them",
|
||||
"dragToReorder": "Drag to reorder",
|
||||
"row": "Row",
|
||||
"addRow": "Add row",
|
||||
"addButton": "Add button",
|
||||
"addUrlButton": "URL button (link)",
|
||||
"builtinButtons": "Built-in buttons",
|
||||
"resetConfirm": "Reset menu layout to defaults?",
|
||||
"buttonTextPlaceholder": "Custom button text",
|
||||
"customLabelsHint": "Empty = default label",
|
||||
"invalidUrl": "Please provide a valid URL (http:// or https://) for all custom buttons"
|
||||
},
|
||||
"apps": {
|
||||
"title": "App Management",
|
||||
"addApp": "Add App",
|
||||
|
||||
@@ -1410,7 +1410,8 @@
|
||||
"referral": "معرفی",
|
||||
"support": "پشتیبانی",
|
||||
"info": "اطلاعات",
|
||||
"admin": "پنل مدیریت"
|
||||
"admin": "پنل مدیریت",
|
||||
"language": "زبان"
|
||||
},
|
||||
"descriptions": {
|
||||
"home": "دکمه کابینت شخصی",
|
||||
@@ -1419,7 +1420,8 @@
|
||||
"referral": "برنامه معرفی",
|
||||
"support": "تیکتهای پشتیبانی",
|
||||
"info": "بخش اطلاعات",
|
||||
"admin": "پنل مدیریت وب"
|
||||
"admin": "پنل مدیریت وب",
|
||||
"language": "انتخاب زبان ربات"
|
||||
},
|
||||
"styles": {
|
||||
"default": "بدون رنگ",
|
||||
@@ -1428,6 +1430,19 @@
|
||||
"danger": "قرمز"
|
||||
}
|
||||
},
|
||||
"menuEditor": {
|
||||
"dragHint": "ردیفها را بکشید تا ترتیب تغییر کند",
|
||||
"dragToReorder": "برای مرتبسازی بکشید",
|
||||
"row": "ردیف",
|
||||
"addRow": "افزودن ردیف",
|
||||
"addButton": "افزودن دکمه",
|
||||
"addUrlButton": "دکمه URL (لینک)",
|
||||
"builtinButtons": "بخشهای داخلی",
|
||||
"resetConfirm": "منو به تنظیمات پیشفرض بازنشانی شود؟ تمام تغییرات از بین میرود.",
|
||||
"buttonTextPlaceholder": "متن دکمه",
|
||||
"customLabelsHint": "متن دکمه برای هر زبان ربات",
|
||||
"invalidUrl": "لطفاً برای تمام دکمههای سفارشی یک URL معتبر وارد کنید (http:// یا https://)"
|
||||
},
|
||||
"apps": {
|
||||
"title": "مدیریت برنامهها",
|
||||
"addApp": "افزودن برنامه",
|
||||
|
||||
@@ -2259,7 +2259,8 @@
|
||||
"referral": "Рефералы",
|
||||
"support": "Поддержка",
|
||||
"info": "Информация",
|
||||
"admin": "Админка"
|
||||
"admin": "Админка",
|
||||
"language": "Язык"
|
||||
},
|
||||
"descriptions": {
|
||||
"home": "Кнопка личного кабинета",
|
||||
@@ -2268,7 +2269,8 @@
|
||||
"referral": "Реферальная программа",
|
||||
"support": "Обращения в поддержку",
|
||||
"info": "Информационный раздел",
|
||||
"admin": "Веб-админка"
|
||||
"admin": "Веб-админка",
|
||||
"language": "Выбор языка"
|
||||
},
|
||||
"styles": {
|
||||
"default": "Без цвета",
|
||||
@@ -2277,6 +2279,19 @@
|
||||
"danger": "Красный"
|
||||
}
|
||||
},
|
||||
"menuEditor": {
|
||||
"dragHint": "Перетаскивайте ряды для изменения порядка",
|
||||
"dragToReorder": "Перетащите для сортировки",
|
||||
"row": "Ряд",
|
||||
"addRow": "Добавить ряд",
|
||||
"addButton": "Добавить кнопку",
|
||||
"addUrlButton": "URL-кнопка (ссылка)",
|
||||
"builtinButtons": "Встроенные кнопки",
|
||||
"resetConfirm": "Сбросить компоновку меню к значениям по умолчанию?",
|
||||
"buttonTextPlaceholder": "Свой текст кнопки",
|
||||
"customLabelsHint": "Пустое поле = название по умолчанию",
|
||||
"invalidUrl": "Укажите корректный URL (http:// или https://) для всех пользовательских кнопок"
|
||||
},
|
||||
"apps": {
|
||||
"title": "Управление приложениями",
|
||||
"addApp": "Добавить приложение",
|
||||
|
||||
@@ -1448,7 +1448,8 @@
|
||||
"referral": "推荐",
|
||||
"support": "支持",
|
||||
"info": "信息",
|
||||
"admin": "管理面板"
|
||||
"admin": "管理面板",
|
||||
"language": "语言"
|
||||
},
|
||||
"descriptions": {
|
||||
"home": "个人中心按钮",
|
||||
@@ -1457,7 +1458,8 @@
|
||||
"referral": "推荐计划",
|
||||
"support": "支持工单",
|
||||
"info": "信息板块",
|
||||
"admin": "网页管理面板"
|
||||
"admin": "网页管理面板",
|
||||
"language": "机器人语言选择"
|
||||
},
|
||||
"styles": {
|
||||
"default": "无颜色",
|
||||
@@ -1466,6 +1468,19 @@
|
||||
"danger": "红色"
|
||||
}
|
||||
},
|
||||
"menuEditor": {
|
||||
"dragHint": "拖拽行以重新排序",
|
||||
"dragToReorder": "拖拽排序",
|
||||
"row": "行",
|
||||
"addRow": "添加行",
|
||||
"addButton": "添加按钮",
|
||||
"addUrlButton": "URL按钮(链接)",
|
||||
"builtinButtons": "内置栏目",
|
||||
"resetConfirm": "将菜单重置为默认设置?所有更改将丢失。",
|
||||
"buttonTextPlaceholder": "按钮文本",
|
||||
"customLabelsHint": "每种语言的按钮文本",
|
||||
"invalidUrl": "请为所有自定义按钮提供有效的URL(http:// 或 https://)"
|
||||
},
|
||||
"apps": {
|
||||
"title": "应用管理",
|
||||
"addApp": "添加应用",
|
||||
|
||||
@@ -9,7 +9,7 @@ import { MENU_SECTIONS, MenuItem, formatSettingKey } from '../components/admin';
|
||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
|
||||
import { BrandingTab } from '../components/admin/BrandingTab';
|
||||
import { ButtonsTab } from '../components/admin/ButtonsTab';
|
||||
import { MenuEditorTab } from '../components/admin/MenuEditorTab';
|
||||
import { ThemeTab } from '../components/admin/ThemeTab';
|
||||
import { FavoritesTab } from '../components/admin/FavoritesTab';
|
||||
import { SettingsTab } from '../components/admin/SettingsTab';
|
||||
@@ -176,7 +176,7 @@ export default function AdminSettings() {
|
||||
case 'theme':
|
||||
return <ThemeTab />;
|
||||
case 'buttons':
|
||||
return <ButtonsTab />;
|
||||
return <MenuEditorTab />;
|
||||
case 'favorites':
|
||||
return (
|
||||
<FavoritesTab
|
||||
|
||||
Reference in New Issue
Block a user