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

View File

@@ -1701,7 +1701,14 @@
"enableEmail": "Email",
"sendingBoth": "2 broadcasts will be 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": {
"title": "Pinned Messages",

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import {
BroadcastFilter,
TariffFilter,
CombinedBroadcastCreateRequest,
CustomBroadcastButton,
} from '../api/adminBroadcasts';
import { AdminBackButton } from '../components/admin';
@@ -130,6 +131,11 @@ export default function AdminBroadcastCreate() {
// Telegram-specific state
const [messageText, setMessageText] = useState('');
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 [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo');
const [mediaPreview, setMediaPreview] = useState<string | null>(null);
@@ -275,28 +281,33 @@ export default function AdminBroadcastCreate() {
const file = e.target.files?.[0];
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/')) {
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);
const url = URL.createObjectURL(file);
mediaPreviewRef.current = url;
setMediaPreview(url);
} else if (file.type.startsWith('video/')) {
setMediaType('video');
setMediaPreview(null);
} else {
setMediaType('document');
setMediaPreview(null);
}
setIsUploading(true);
try {
const result = await adminBroadcastsApi.uploadMedia(file, mediaType);
const result = await adminBroadcastsApi.uploadMedia(file, detectedType);
setUploadedFileId(result.file_id);
} catch (err) {
console.error('Upload failed:', err);
} catch {
setMediaFile(null);
setMediaPreview(null);
} 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
const isTelegramValid = telegramEnabled && telegramTarget && messageText.trim().length > 0;
const isEmailValid =
@@ -350,6 +394,7 @@ export default function AdminBroadcastCreate() {
target: telegramTarget,
message_text: messageText,
selected_buttons: selectedButtons,
custom_buttons: customButtons.length > 0 ? customButtons : undefined,
};
if (uploadedFileId) {
data.media = { type: mediaType, file_id: uploadedFileId };
@@ -377,6 +422,7 @@ export default function AdminBroadcastCreate() {
target: telegramTarget,
message_text: messageText,
selected_buttons: selectedButtons,
custom_buttons: customButtons.length > 0 ? customButtons : undefined,
};
if (uploadedFileId) {
telegramData.media = { type: mediaType, file_id: uploadedFileId };
@@ -655,6 +701,123 @@ export default function AdminBroadcastCreate() {
))}
</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>
)}