feat: custom broadcast buttons UI and fix stale mediaType bug

- Add inline custom button editor (no popups): label, type
  (callback/URL), action value with byte-level validation
- Frontend validates callback_data in UTF-8 bytes via TextEncoder
- URLs restricted to https:// and tg:// matching backend
- Form wrapped in <form> for Enter key submit support
- Fix pre-existing stale mediaType bug in file upload handler
- Translations added for ru, en, zh, fa locales
This commit is contained in:
Fringg
2026-03-22 01:54:11 +03:00
parent 3c034d2e70
commit 86d997d01d
6 changed files with 212 additions and 14 deletions

View File

@@ -48,6 +48,12 @@ export interface BroadcastButtonsResponse {
buttons: BroadcastButton[]; buttons: BroadcastButton[];
} }
export interface CustomBroadcastButton {
label: string;
action_type: 'callback' | 'url';
action_value: string;
}
export interface BroadcastMedia { export interface BroadcastMedia {
type: 'photo' | 'video' | 'document'; type: 'photo' | 'video' | 'document';
file_id: string; file_id: string;
@@ -73,6 +79,7 @@ export interface CombinedBroadcastCreateRequest {
// Telegram fields // Telegram fields
message_text?: string; message_text?: string;
selected_buttons?: string[]; selected_buttons?: string[];
custom_buttons?: CustomBroadcastButton[];
media?: BroadcastMedia; media?: BroadcastMedia;
// Email fields // Email fields
email_subject?: string; email_subject?: string;

View File

@@ -1701,7 +1701,14 @@
"enableEmail": "Email", "enableEmail": "Email",
"sendingBoth": "2 broadcasts will be created", "sendingBoth": "2 broadcasts will be created",
"bothCreated": "Broadcasts created", "bothCreated": "Broadcasts created",
"atLeastOneChannel": "Select at least one channel" "atLeastOneChannel": "Select at least one channel",
"customButtons": "Custom buttons",
"addCustomButton": "Add button",
"customButtonLabelPlaceholder": "Button text",
"customButtonTypeCallback": "Callback",
"customButtonTypeUrl": "URL",
"customButtonCallbackPlaceholder": "callback_data (e.g. back_to_menu)",
"customButtonUrlPlaceholder": "https://example.com"
}, },
"pinnedMessages": { "pinnedMessages": {
"title": "Pinned Messages", "title": "Pinned Messages",

View File

@@ -1336,7 +1336,14 @@
"enableEmail": "Email", "enableEmail": "Email",
"sendingBoth": "۲ پیام‌رسانی ایجاد خواهد شد", "sendingBoth": "۲ پیام‌رسانی ایجاد خواهد شد",
"bothCreated": "پیام‌رسانی‌ها ایجاد شدند", "bothCreated": "پیام‌رسانی‌ها ایجاد شدند",
"atLeastOneChannel": "حداقل یک کانال را انتخاب کنید" "atLeastOneChannel": "حداقل یک کانال را انتخاب کنید",
"customButtons": "دکمه‌های سفارشی",
"addCustomButton": "افزودن دکمه",
"customButtonLabelPlaceholder": "متن دکمه",
"customButtonTypeCallback": "Callback",
"customButtonTypeUrl": "لینک",
"customButtonCallbackPlaceholder": "callback_data (مثلاً back_to_menu)",
"customButtonUrlPlaceholder": "https://example.com"
}, },
"pinnedMessages": { "pinnedMessages": {
"title": "Pinned Messages", "title": "Pinned Messages",

View File

@@ -1726,7 +1726,14 @@
"enableEmail": "Email", "enableEmail": "Email",
"sendingBoth": "Будет создано 2 рассылки", "sendingBoth": "Будет создано 2 рассылки",
"bothCreated": "Рассылки созданы", "bothCreated": "Рассылки созданы",
"atLeastOneChannel": "Выберите хотя бы один канал" "atLeastOneChannel": "Выберите хотя бы один канал",
"customButtons": "Кастомные кнопки",
"addCustomButton": "Добавить кнопку",
"customButtonLabelPlaceholder": "Текст кнопки",
"customButtonTypeCallback": "Callback",
"customButtonTypeUrl": "Ссылка",
"customButtonCallbackPlaceholder": "callback_data (например, back_to_menu)",
"customButtonUrlPlaceholder": "https://example.com"
}, },
"pinnedMessages": { "pinnedMessages": {
"title": "Закреплённые сообщения", "title": "Закреплённые сообщения",

View File

@@ -1410,7 +1410,14 @@
"enableEmail": "Email", "enableEmail": "Email",
"sendingBoth": "将创建2个群发", "sendingBoth": "将创建2个群发",
"bothCreated": "群发已创建", "bothCreated": "群发已创建",
"atLeastOneChannel": "请至少选择一个渠道" "atLeastOneChannel": "请至少选择一个渠道",
"customButtons": "自定义按钮",
"addCustomButton": "添加按钮",
"customButtonLabelPlaceholder": "按钮文字",
"customButtonTypeCallback": "回调",
"customButtonTypeUrl": "链接",
"customButtonCallbackPlaceholder": "callback_data例如 back_to_menu",
"customButtonUrlPlaceholder": "https://example.com"
}, },
"pinnedMessages": { "pinnedMessages": {
"title": "置顶消息", "title": "置顶消息",

View File

@@ -7,6 +7,7 @@ import {
BroadcastFilter, BroadcastFilter,
TariffFilter, TariffFilter,
CombinedBroadcastCreateRequest, CombinedBroadcastCreateRequest,
CustomBroadcastButton,
} from '../api/adminBroadcasts'; } from '../api/adminBroadcasts';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
@@ -130,6 +131,11 @@ export default function AdminBroadcastCreate() {
// Telegram-specific state // Telegram-specific state
const [messageText, setMessageText] = useState(''); const [messageText, setMessageText] = useState('');
const [selectedButtons, setSelectedButtons] = useState<string[]>(['home']); const [selectedButtons, setSelectedButtons] = useState<string[]>(['home']);
const [customButtons, setCustomButtons] = useState<CustomBroadcastButton[]>([]);
const [isAddingCustomButton, setIsAddingCustomButton] = useState(false);
const [newButtonLabel, setNewButtonLabel] = useState('');
const [newButtonActionType, setNewButtonActionType] = useState<'callback' | 'url'>('callback');
const [newButtonActionValue, setNewButtonActionValue] = useState('');
const [mediaFile, setMediaFile] = useState<File | null>(null); const [mediaFile, setMediaFile] = useState<File | null>(null);
const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo'); const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo');
const [mediaPreview, setMediaPreview] = useState<string | null>(null); const [mediaPreview, setMediaPreview] = useState<string | null>(null);
@@ -275,28 +281,33 @@ export default function AdminBroadcastCreate() {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
setMediaFile(file); // Determine type locally to avoid stale state in async call
let detectedType: 'photo' | 'video' | 'document';
if (file.type.startsWith('image/')) { if (file.type.startsWith('image/')) {
setMediaType('photo'); detectedType = 'photo';
} else if (file.type.startsWith('video/')) {
detectedType = 'video';
} else {
detectedType = 'document';
}
setMediaFile(file);
setMediaType(detectedType);
if (detectedType === 'photo') {
if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current); if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current);
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
mediaPreviewRef.current = url; mediaPreviewRef.current = url;
setMediaPreview(url); setMediaPreview(url);
} else if (file.type.startsWith('video/')) {
setMediaType('video');
setMediaPreview(null);
} else { } else {
setMediaType('document');
setMediaPreview(null); setMediaPreview(null);
} }
setIsUploading(true); setIsUploading(true);
try { try {
const result = await adminBroadcastsApi.uploadMedia(file, mediaType); const result = await adminBroadcastsApi.uploadMedia(file, detectedType);
setUploadedFileId(result.file_id); setUploadedFileId(result.file_id);
} catch (err) { } catch {
console.error('Upload failed:', err);
setMediaFile(null); setMediaFile(null);
setMediaPreview(null); setMediaPreview(null);
} finally { } finally {
@@ -325,6 +336,39 @@ export default function AdminBroadcastCreate() {
); );
}; };
// Custom button validation
const isNewButtonValid = useMemo(() => {
if (!newButtonLabel.trim() || !newButtonActionValue.trim()) return false;
if (newButtonActionType === 'url') {
return /^https:\/\/|^tg:\/\//.test(newButtonActionValue.trim());
}
if (newButtonActionType === 'callback') {
return new TextEncoder().encode(newButtonActionValue.trim()).length <= 64;
}
return true;
}, [newButtonLabel, newButtonActionType, newButtonActionValue]);
// Custom button handlers
const addCustomButton = () => {
if (!isNewButtonValid) return;
setCustomButtons((prev) => [
...prev,
{
label: newButtonLabel.trim(),
action_type: newButtonActionType,
action_value: newButtonActionValue.trim(),
},
]);
setNewButtonLabel('');
setNewButtonActionValue('');
setNewButtonActionType('callback');
setIsAddingCustomButton(false);
};
const removeCustomButton = (index: number) => {
setCustomButtons((prev) => prev.filter((_, i) => i !== index));
};
// Validate form // Validate form
const isTelegramValid = telegramEnabled && telegramTarget && messageText.trim().length > 0; const isTelegramValid = telegramEnabled && telegramTarget && messageText.trim().length > 0;
const isEmailValid = const isEmailValid =
@@ -350,6 +394,7 @@ export default function AdminBroadcastCreate() {
target: telegramTarget, target: telegramTarget,
message_text: messageText, message_text: messageText,
selected_buttons: selectedButtons, selected_buttons: selectedButtons,
custom_buttons: customButtons.length > 0 ? customButtons : undefined,
}; };
if (uploadedFileId) { if (uploadedFileId) {
data.media = { type: mediaType, file_id: uploadedFileId }; data.media = { type: mediaType, file_id: uploadedFileId };
@@ -377,6 +422,7 @@ export default function AdminBroadcastCreate() {
target: telegramTarget, target: telegramTarget,
message_text: messageText, message_text: messageText,
selected_buttons: selectedButtons, selected_buttons: selectedButtons,
custom_buttons: customButtons.length > 0 ? customButtons : undefined,
}; };
if (uploadedFileId) { if (uploadedFileId) {
telegramData.media = { type: mediaType, file_id: uploadedFileId }; telegramData.media = { type: mediaType, file_id: uploadedFileId };
@@ -655,6 +701,123 @@ export default function AdminBroadcastCreate() {
))} ))}
</div> </div>
</div> </div>
{/* Custom buttons */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.customButtons')}
</label>
{/* Existing custom buttons */}
{customButtons.length > 0 && (
<div className="mb-3 space-y-2">
{customButtons.map((btn, index) => (
<div
key={index}
className="flex items-center justify-between rounded-lg border border-dark-700 bg-dark-800 px-3 py-2"
>
<div className="flex items-center gap-2 overflow-hidden">
<span className="shrink-0 rounded bg-dark-700 px-1.5 py-0.5 text-xs text-dark-400">
{btn.action_type === 'url'
? t('admin.broadcasts.customButtonTypeUrl')
: t('admin.broadcasts.customButtonTypeCallback')}
</span>
<span className="truncate text-sm text-dark-100">{btn.label}</span>
<span className="truncate text-xs text-dark-500">{btn.action_value}</span>
</div>
<button
onClick={() => removeCustomButton(index)}
className="ml-2 shrink-0 rounded p-1 text-dark-400 hover:bg-dark-700 hover:text-error-400"
>
<XIcon />
</button>
</div>
))}
</div>
)}
{/* Inline add form */}
{isAddingCustomButton ? (
<form
onSubmit={(e) => {
e.preventDefault();
addCustomButton();
}}
className="space-y-3 rounded-lg border border-dark-600 bg-dark-800/50 p-3"
>
<input
type="text"
value={newButtonLabel}
onChange={(e) => setNewButtonLabel(e.target.value)}
placeholder={t('admin.broadcasts.customButtonLabelPlaceholder')}
maxLength={64}
className="input"
autoFocus
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => setNewButtonActionType('callback')}
className={`flex-1 rounded-lg px-3 py-2 text-sm transition-colors ${
newButtonActionType === 'callback'
? 'bg-accent-500 text-white'
: 'border border-dark-700 bg-dark-800 text-dark-300 hover:bg-dark-700'
}`}
>
{t('admin.broadcasts.customButtonTypeCallback')}
</button>
<button
type="button"
onClick={() => setNewButtonActionType('url')}
className={`flex-1 rounded-lg px-3 py-2 text-sm transition-colors ${
newButtonActionType === 'url'
? 'bg-accent-500 text-white'
: 'border border-dark-700 bg-dark-800 text-dark-300 hover:bg-dark-700'
}`}
>
{t('admin.broadcasts.customButtonTypeUrl')}
</button>
</div>
<input
type="text"
value={newButtonActionValue}
onChange={(e) => setNewButtonActionValue(e.target.value)}
placeholder={
newButtonActionType === 'url'
? t('admin.broadcasts.customButtonUrlPlaceholder')
: t('admin.broadcasts.customButtonCallbackPlaceholder')
}
maxLength={newButtonActionType === 'callback' ? 64 : 256}
className="input"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
setIsAddingCustomButton(false);
setNewButtonLabel('');
setNewButtonActionValue('');
}}
className="btn-secondary flex-1"
>
{t('common.cancel')}
</button>
<button type="submit" disabled={!isNewButtonValid} className="btn-primary flex-1">
{t('common.add')}
</button>
</div>
</form>
) : (
<button
onClick={() => setIsAddingCustomButton(true)}
disabled={customButtons.length >= 10}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-dark-600 bg-dark-800/50 px-4 py-3 text-sm text-dark-400 transition-colors hover:border-dark-500 hover:bg-dark-800 hover:text-dark-300"
>
<span>+</span>
<span>{t('admin.broadcasts.addCustomButton')}</span>
</button>
)}
</div>
</div> </div>
)} )}