AddNotificationModal.tsx 53 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051
  1. import { useState, useEffect } from 'react';
  2. import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { X, Save, Loader2, Send, CheckCircle, XCircle } from 'lucide-react';
  5. import { api } from '../api/client';
  6. import type { NotificationProvider, NotificationProviderCreate, NotificationProviderUpdate, ProviderType, TelegramVerdictMode } from '../api/client';
  7. import { Button } from './Button';
  8. import { Toggle } from './Toggle';
  9. import { isNotifyPushOnlyTarget, isNotifyPhotoUnsupported } from '../utils/notify';
  10. import { SECRET_PLACEHOLDER, selectPlaceholderOnFocus } from '../utils/secrets';
  11. interface ConfigField {
  12. key: string;
  13. label: string;
  14. type: string;
  15. required: boolean;
  16. placeholder?: string;
  17. help?: string;
  18. options?: { value: string; label: string }[];
  19. showIf?: (config: Record<string, string>) => boolean;
  20. advanced?: boolean;
  21. pattern?: string;
  22. }
  23. const NOTIFY_METRICS = ['progress', 'eta', 'layers', 'nozzle', 'bed', 'chamber'] as const;
  24. type NotifyMetric = (typeof NOTIFY_METRICS)[number];
  25. interface AddNotificationModalProps {
  26. provider?: NotificationProvider | null;
  27. onClose: () => void;
  28. }
  29. const PROVIDER_VALUES: ProviderType[] = ['email', 'telegram', 'discord', 'ntfy', 'pushover', 'bark', 'gotify', 'callmebot', 'webhook', 'homeassistant', 'notify'];
  30. export function AddNotificationModal({ provider, onClose }: AddNotificationModalProps) {
  31. const { t, i18n } = useTranslation();
  32. const queryClient = useQueryClient();
  33. const isEditing = !!provider;
  34. const [name, setName] = useState(provider?.name || '');
  35. const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type || 'email');
  36. const [printerId, setPrinterId] = useState<number | null>(provider?.printer_id || null);
  37. const [attachPhoto, setAttachPhoto] = useState(
  38. provider?.provider_type === 'notify' && isNotifyPhotoUnsupported(String(provider.config.device_id || ''))
  39. ? false
  40. : provider?.attach_photo ?? true
  41. );
  42. const [quietHoursEnabled, setQuietHoursEnabled] = useState(provider?.quiet_hours_enabled || false);
  43. const [quietHoursStart, setQuietHoursStart] = useState(provider?.quiet_hours_start || '22:00');
  44. const [quietHoursEnd, setQuietHoursEnd] = useState(provider?.quiet_hours_end || '07:00');
  45. // Daily digest
  46. const [dailyDigestEnabled, setDailyDigestEnabled] = useState(provider?.daily_digest_enabled || false);
  47. const [dailyDigestTime, setDailyDigestTime] = useState(provider?.daily_digest_time || '08:00');
  48. // Event toggles
  49. const [onPrintStart, setOnPrintStart] = useState(provider?.on_print_start ?? false);
  50. const [onPrintComplete, setOnPrintComplete] = useState(provider?.on_print_complete ?? true);
  51. const [onPrintFailed, setOnPrintFailed] = useState(provider?.on_print_failed ?? true);
  52. const [onPrintStopped, setOnPrintStopped] = useState(provider?.on_print_stopped ?? true);
  53. const [onPrintProgress, setOnPrintProgress] = useState(provider?.on_print_progress ?? false);
  54. const [onBillingChargeFailed, setOnBillingChargeFailed] = useState(provider?.on_billing_charge_failed ?? true);
  55. const [onPrinterOffline, setOnPrinterOffline] = useState(provider?.on_printer_offline ?? false);
  56. const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false);
  57. const [onAiFailureDetection, setOnAiFailureDetection] = useState(provider?.on_ai_failure_detection ?? false);
  58. const [onFilamentLow, setOnFilamentLow] = useState(provider?.on_filament_low ?? false);
  59. const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false);
  60. const [onStockReorderAlert, setOnStockReorderAlert] = useState(provider?.on_stock_reorder_alert ?? false);
  61. const [onStockBreakAlert, setOnStockBreakAlert] = useState(provider?.on_stock_break_alert ?? false);
  62. const [onPlateClearRequired, setOnPlateClearRequired] = useState(provider?.on_plate_clear_required ?? false);
  63. // Post-print outcome confirmation (#1898). Defaults ON — it only fires for
  64. // prints that opted in per-job, so the toggle exists to mute a channel.
  65. const [onPrintConfirmRequest, setOnPrintConfirmRequest] = useState(provider?.on_print_confirm_request ?? true);
  66. // Telegram only (#3046): inline link buttons, a thumbs reaction, or both.
  67. const [telegramVerdictMode, setTelegramVerdictMode] = useState<TelegramVerdictMode>(
  68. provider?.telegram_verdict_mode ?? 'buttons'
  69. );
  70. const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false);
  71. const [onHaSensorAlert, setOnHaSensorAlert] = useState(provider?.on_ha_sensor_alert ?? false);
  72. const [onLocationHaSensorAlert, setOnLocationHaSensorAlert] = useState(
  73. provider?.on_location_ha_sensor_alert ?? false
  74. );
  75. const [onFirstLayerComplete, setOnFirstLayerComplete] = useState(provider?.on_first_layer_complete ?? false);
  76. const [onAppMessage, setOnAppMessage] = useState(provider?.on_app_message ?? false);
  77. const [notifyLiveActivities, setNotifyLiveActivities] = useState(
  78. provider?.config?.live_activities === true && !isNotifyPushOnlyTarget(String(provider?.config?.device_id || ''))
  79. );
  80. const [notifyLockScreenWidgets, setNotifyLockScreenWidgets] = useState(
  81. provider?.config?.lock_screen_widgets === true && !isNotifyPushOnlyTarget(String(provider?.config?.device_id || ''))
  82. );
  83. const [notifyMetrics, setNotifyMetrics] = useState<NotifyMetric[]>(() => {
  84. const metrics = provider?.config?.live_activity_metrics;
  85. return Array.isArray(metrics) ? NOTIFY_METRICS.filter((metric) => metrics.includes(metric)) : [];
  86. });
  87. // Provider-specific config (scalar fields only — event_priorities is split out
  88. // into its own state because it's an object, not a string).
  89. const [config, setConfig] = useState<Record<string, string>>(
  90. provider?.config
  91. ? Object.fromEntries(
  92. Object.entries(provider.config)
  93. .filter(([k]) => k !== 'event_priorities' && k !== 'live_activities' && k !== 'live_activity_metrics' && k !== 'lock_screen_widgets')
  94. .map(([k, v]) => [k, v == null ? '' : String(v)]),
  95. )
  96. : {},
  97. );
  98. // Per-event priority for ntfy (#990) and Gotify (#2743). Map of event key →
  99. // 1-5. Persisted into config.event_priorities on save for those two only.
  100. const initialEventPriorities = (() => {
  101. const raw = provider?.config?.event_priorities;
  102. if (!raw || typeof raw !== 'object') return {} as Record<string, number>;
  103. const out: Record<string, number> = {};
  104. for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
  105. const n = Number(v);
  106. if (Number.isInteger(n) && n >= 1 && n <= 5) out[k] = n;
  107. }
  108. return out;
  109. })();
  110. const [eventPriorities, setEventPriorities] = useState<Record<string, number>>(initialEventPriorities);
  111. const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
  112. const [error, setError] = useState<string | null>(null);
  113. // Fetch printers for linking
  114. const { data: printers } = useQuery({
  115. queryKey: ['printers'],
  116. queryFn: api.getPrinters,
  117. });
  118. // Close on Escape key
  119. useEffect(() => {
  120. const handleKeyDown = (e: KeyboardEvent) => {
  121. if (e.key === 'Escape') onClose();
  122. };
  123. window.addEventListener('keydown', handleKeyDown);
  124. return () => window.removeEventListener('keydown', handleKeyDown);
  125. }, [onClose]);
  126. const notifyPushOnlyTarget = providerType === 'notify' && isNotifyPushOnlyTarget(config.device_id || '');
  127. const notifyPhotosUnsupported = providerType === 'notify' && isNotifyPhotoUnsupported(config.device_id || '');
  128. const configForRequest = () => providerType === 'notify'
  129. ? {
  130. ...config,
  131. live_activities: notifyLiveActivities && !notifyPushOnlyTarget,
  132. lock_screen_widgets: notifyLockScreenWidgets && !notifyPushOnlyTarget,
  133. live_activity_privacy: config.live_activity_privacy === 'true',
  134. live_activity_stage: config.live_activity_stage === 'true',
  135. live_activity_metrics: notifyMetrics,
  136. time_sensitive: config.time_sensitive === 'true',
  137. }
  138. : config;
  139. // Test configuration mutation
  140. const testMutation = useMutation({
  141. mutationFn: () => api.testNotificationConfig({ provider_type: providerType, config: configForRequest(), attach_photo: attachPhoto && !notifyPhotosUnsupported, provider_id: provider?.id }),
  142. onSuccess: (result) => {
  143. setTestResult(result);
  144. setError(null);
  145. },
  146. onError: (err: Error) => {
  147. setTestResult({ success: false, message: err.message });
  148. },
  149. });
  150. // Create mutation
  151. const createMutation = useMutation({
  152. mutationFn: (data: NotificationProviderCreate) => api.createNotificationProvider(data),
  153. onSuccess: () => {
  154. queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
  155. onClose();
  156. },
  157. onError: (err: Error) => {
  158. setError(err.message);
  159. },
  160. });
  161. // Update mutation
  162. const updateMutation = useMutation({
  163. mutationFn: (data: NotificationProviderUpdate) => api.updateNotificationProvider(provider!.id, data),
  164. onSuccess: () => {
  165. queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
  166. onClose();
  167. },
  168. onError: (err: Error) => {
  169. setError(err.message);
  170. },
  171. });
  172. const handleSubmit = (e: React.FormEvent) => {
  173. e.preventDefault();
  174. setError(null);
  175. if (!name.trim()) {
  176. setError(t('notifications.nameRequired'));
  177. return;
  178. }
  179. // Validate provider-specific config
  180. const requiredFields = getRequiredFields(providerType);
  181. for (const field of requiredFields) {
  182. if (!config[field.key]?.trim()) {
  183. setError(t('notifications.fieldRequired', { field: field.label }));
  184. return;
  185. }
  186. }
  187. // HA custom service-data must be a JSON object (#1441)
  188. if (providerType === 'homeassistant' && config.data?.trim()) {
  189. let parsed: unknown;
  190. try {
  191. parsed = JSON.parse(config.data);
  192. } catch {
  193. setError(t('notifications.haDataInvalid'));
  194. return;
  195. }
  196. if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
  197. setError(t('notifications.haDataInvalid'));
  198. return;
  199. }
  200. }
  201. // Telegram forum topic must be a plain integer (#1518) — type="number"
  202. // still lets "1e5" and "-" through, and Telegram would 400 on those.
  203. if (providerType === 'telegram' && config.message_thread_id?.trim()) {
  204. if (!/^\d+$/.test(config.message_thread_id.trim())) {
  205. setError(t('notifications.telegramThreadIdInvalid'));
  206. return;
  207. }
  208. }
  209. const finalConfig: Record<string, unknown> =
  210. (providerType === 'ntfy' || providerType === 'gotify') && Object.keys(eventPriorities).length > 0
  211. ? { ...config, event_priorities: eventPriorities }
  212. : configForRequest();
  213. const data = {
  214. name: name.trim(),
  215. provider_type: providerType,
  216. config: finalConfig,
  217. printer_id: printerId,
  218. attach_photo: attachPhoto && !notifyPhotosUnsupported,
  219. quiet_hours_enabled: quietHoursEnabled,
  220. quiet_hours_start: quietHoursEnabled ? quietHoursStart : null,
  221. quiet_hours_end: quietHoursEnabled ? quietHoursEnd : null,
  222. // Daily digest
  223. daily_digest_enabled: dailyDigestEnabled,
  224. daily_digest_time: dailyDigestEnabled ? dailyDigestTime : null,
  225. // Event toggles
  226. on_print_start: onPrintStart,
  227. on_print_complete: onPrintComplete,
  228. on_print_failed: onPrintFailed,
  229. on_print_stopped: onPrintStopped,
  230. on_print_progress: onPrintProgress,
  231. on_billing_charge_failed: onBillingChargeFailed,
  232. on_printer_offline: onPrinterOffline,
  233. on_printer_error: onPrinterError,
  234. on_ai_failure_detection: onAiFailureDetection,
  235. on_filament_low: onFilamentLow,
  236. on_maintenance_due: onMaintenanceDue,
  237. on_stock_reorder_alert: onStockReorderAlert,
  238. on_stock_break_alert: onStockBreakAlert,
  239. on_plate_clear_required: onPlateClearRequired,
  240. on_print_confirm_request: onPrintConfirmRequest,
  241. telegram_verdict_mode: providerType === 'telegram' ? telegramVerdictMode : 'buttons',
  242. on_bed_cooled: onBedCooled,
  243. on_ha_sensor_alert: onHaSensorAlert,
  244. on_location_ha_sensor_alert: onLocationHaSensorAlert,
  245. on_first_layer_complete: onFirstLayerComplete,
  246. on_app_message: onAppMessage,
  247. };
  248. if (isEditing) {
  249. updateMutation.mutate(data);
  250. } else {
  251. createMutation.mutate(data);
  252. }
  253. };
  254. const isPending = createMutation.isPending || updateMutation.isPending;
  255. // Get config fields for each provider type
  256. const getConfigFields = (type: ProviderType): ConfigField[] => {
  257. switch (type) {
  258. case 'callmebot':
  259. return [
  260. { key: 'phone', label: 'Phone Number', placeholder: '+1234567890', type: 'text', required: true },
  261. { key: 'apikey', label: 'API Key', placeholder: 'Your CallMeBot API key', type: 'password', required: true },
  262. ];
  263. case 'ntfy':
  264. return [
  265. { key: 'server', label: 'Server URL', placeholder: 'https://ntfy.sh', type: 'text', required: false },
  266. { key: 'topic', label: 'Topic', placeholder: 'my-bambuddy', type: 'text', required: true },
  267. { key: 'auth_token', label: 'Auth Token', placeholder: 'Optional authentication', type: 'password', required: false },
  268. ];
  269. case 'pushover':
  270. return [
  271. { key: 'user_key', label: 'User Key', placeholder: 'Your Pushover user key', type: 'password', required: true },
  272. { key: 'app_token', label: 'App Token', placeholder: 'Your Pushover app token', type: 'password', required: true },
  273. { key: 'priority', label: 'Priority', placeholder: '0 (normal)', type: 'number', required: false },
  274. // Emergency priority (2) requires retry/expire — Pushover rejects the
  275. // message otherwise. Only shown when priority is set to 2.
  276. {
  277. key: 'retry',
  278. label: t('notifications.pushoverRetry'),
  279. placeholder: '60',
  280. type: 'number',
  281. required: false,
  282. showIf: (cfg: Record<string, string>) => cfg.priority === '2',
  283. },
  284. {
  285. key: 'expire',
  286. label: t('notifications.pushoverExpire'),
  287. placeholder: '3600',
  288. type: 'number',
  289. required: false,
  290. showIf: (cfg: Record<string, string>) => cfg.priority === '2',
  291. },
  292. ];
  293. case 'notify':
  294. return [
  295. { key: 'device_id', label: t('notifications.notifyDeviceId'), type: 'text', required: true },
  296. { key: 'token', label: t('notifications.notifyToken'), type: 'password', required: true },
  297. { key: 'icon_url', label: t('notifications.notifyIconUrl'), placeholder: 'https://example.com/icon.png', type: 'url', pattern: 'https://.*', required: false },
  298. { key: 'group_type', label: t('notifications.notifyGroupType'), type: 'text', required: false, help: t('notifications.notifyGroupTypeHelp') },
  299. {
  300. key: 'time_sensitive', label: t('notifications.notifyTimeSensitive'), type: 'select', required: false,
  301. options: [{ value: 'false', label: t('common.no') }, { value: 'true', label: t('common.yes') }],
  302. help: t('notifications.notifyTimeSensitiveHelp'),
  303. },
  304. {
  305. key: 'live_activity_privacy', label: t('notifications.notifyPrivacy'), type: 'select', required: false, advanced: true,
  306. options: [{ value: 'false', label: t('common.no') }, { value: 'true', label: t('common.yes') }],
  307. help: t('notifications.notifyPrivacyHelp'),
  308. },
  309. {
  310. key: 'live_activity_stage', label: t('notifications.notifyShowStage'), type: 'select', required: false, advanced: true,
  311. options: [{ value: 'false', label: t('common.no') }, { value: 'true', label: t('common.yes') }],
  312. },
  313. {
  314. key: 'live_activity_style', label: t('notifications.notifyStyle'), type: 'select', required: false, advanced: true,
  315. options: [
  316. { value: 'bar', label: t('notifications.notifyStyleBar') },
  317. { value: 'segments', label: t('notifications.notifyStyleSegments') },
  318. { value: 'none', label: t('notifications.notifyStyleNone') },
  319. ],
  320. },
  321. { key: 'live_activity_button_url', label: t('notifications.notifyDashboardUrl'), type: 'url', pattern: 'https://.*', placeholder: 'https://bambuddy.example.com', required: false, advanced: true },
  322. { key: 'live_activity_symbol', label: t('notifications.notifySymbol'), type: 'text', placeholder: 'printer.fill', required: false, advanced: true },
  323. { key: 'live_activity_tint', label: t('notifications.notifyTint'), type: 'text', placeholder: '#00AE42', pattern: '#[0-9a-fA-F]{6}', required: false, advanced: true },
  324. ];
  325. case 'telegram':
  326. return [
  327. { key: 'bot_token', label: 'Bot Token', placeholder: 'Bot token from @BotFather', type: 'password', required: true },
  328. { key: 'chat_id', label: 'Chat ID', placeholder: 'Your chat or group ID', type: 'text', required: true },
  329. // Optional forum topic (#1518). Left empty, Telegram posts to the
  330. // group's General topic exactly as before.
  331. {
  332. key: 'message_thread_id',
  333. label: t('notifications.telegramThreadId'),
  334. placeholder: '123',
  335. type: 'number',
  336. required: false,
  337. help: t('notifications.telegramThreadIdHelp'),
  338. },
  339. ];
  340. case 'email':
  341. return [
  342. { key: 'smtp_server', label: 'SMTP Server', placeholder: 'smtp.gmail.com', type: 'text', required: true },
  343. { key: 'smtp_port', label: 'SMTP Port', placeholder: '587', type: 'number', required: false },
  344. { key: 'security', label: 'Security', type: 'select', required: false, options: [
  345. { value: 'starttls', label: 'STARTTLS (Port 587)' },
  346. { value: 'ssl', label: 'SSL/TLS (Port 465)' },
  347. { value: 'none', label: 'None (Port 25)' },
  348. ]},
  349. { key: 'auth_enabled', label: 'Authentication', type: 'select', required: false, options: [
  350. { value: 'true', label: 'Enabled' },
  351. { value: 'false', label: 'Disabled' },
  352. ]},
  353. { key: 'username', label: 'Username', placeholder: 'your@email.com', type: 'text', required: false },
  354. { key: 'password', label: 'Password', placeholder: 'App password', type: 'password', required: false },
  355. { key: 'from_email', label: 'From Email', placeholder: 'your@email.com', type: 'text', required: true },
  356. { key: 'to_email', label: 'To Email', placeholder: 'recipient@email.com', type: 'text', required: true },
  357. ];
  358. case 'discord':
  359. return [
  360. { key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://discord.com/api/webhooks/...', type: 'password', required: true },
  361. ];
  362. case 'webhook':
  363. return [
  364. { key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://example.com/webhook', type: 'password', required: true },
  365. { key: 'payload_format', label: 'Payload Format', type: 'select', required: false, options: [
  366. { value: 'generic', label: 'Generic JSON' },
  367. { value: 'slack', label: 'Slack / Mattermost' },
  368. ]},
  369. { key: 'auth_header', label: 'Authorization', placeholder: 'Bearer token (optional)', type: 'password', required: false },
  370. { key: 'field_title', label: 'Title Field Name', placeholder: 'title', type: 'text', required: false, showIf: (cfg: Record<string, string>) => cfg.payload_format !== 'slack' },
  371. { key: 'field_message', label: 'Message Field Name', placeholder: 'message', type: 'text', required: false, showIf: (cfg: Record<string, string>) => cfg.payload_format !== 'slack' },
  372. ];
  373. case 'homeassistant':
  374. return [
  375. {
  376. key: 'service',
  377. label: 'Home Assistant Service',
  378. placeholder: 'notify.mobile_app_myphone',
  379. type: 'text',
  380. required: false,
  381. help: t('notifications.haServiceHelp'),
  382. },
  383. { key: 'data', label: 'Data (JSON, optional)', placeholder: '{"priority": "high", "ttl": 0, "channel": "3D Printing"}', type: 'textarea', required: false },
  384. ];
  385. case 'bark':
  386. return [
  387. { key: 'device_key', label: 'Device Key', placeholder: 'Your Bark device key', type: 'password', required: true },
  388. { key: 'server', label: 'Server URL', placeholder: 'https://api.day.app', type: 'text', required: false },
  389. { key: 'group', label: 'Group', placeholder: 'Bambuddy', type: 'text', required: false },
  390. { key: 'sound', label: 'Sound', placeholder: 'minuet', type: 'text', required: false },
  391. { key: 'level', label: 'Interruption Level', type: 'select', required: false, options: [
  392. { value: '', label: 'Default' },
  393. { value: 'active', label: 'Active' },
  394. { value: 'timeSensitive', label: 'Time Sensitive' },
  395. { value: 'critical', label: 'Critical (bypasses Silent/Focus)' },
  396. { value: 'passive', label: 'Passive (no sound)' },
  397. ]},
  398. ];
  399. case 'gotify':
  400. return [
  401. { key: 'server', label: 'Server URL', placeholder: 'https://gotify.example.com', type: 'text', required: true },
  402. { key: 'app_token', label: 'App Token', placeholder: 'Token of a Gotify application', type: 'password', required: true },
  403. ];
  404. default:
  405. return [];
  406. }
  407. };
  408. const getRequiredFields = (type: ProviderType) => {
  409. return getConfigFields(type).filter(f => f.required);
  410. };
  411. const configFields = getConfigFields(providerType);
  412. const renderConfigFields = (fields: ConfigField[]) =>
  413. fields
  414. .filter((field) => field.showIf?.(config) !== false)
  415. .map((field) => (
  416. <div key={field.key}>
  417. <label htmlFor={`notification-config-${field.key}`} className="block text-sm text-bambu-gray mb-1">
  418. {field.label} {field.required && '*'}
  419. </label>
  420. {field.type === 'select' && field.options ? (
  421. <select
  422. id={`notification-config-${field.key}`}
  423. value={config[field.key] || field.options[0]?.value || ''}
  424. onChange={(e) => {
  425. setConfig({ ...config, [field.key]: e.target.value });
  426. setTestResult(null);
  427. }}
  428. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  429. >
  430. {field.options.map((opt) => (
  431. <option key={opt.value} value={opt.value}>
  432. {opt.label}
  433. </option>
  434. ))}
  435. </select>
  436. ) : field.type === 'textarea' ? (
  437. <textarea
  438. id={`notification-config-${field.key}`}
  439. value={config[field.key] || ''}
  440. onChange={(e) => {
  441. setConfig({ ...config, [field.key]: e.target.value });
  442. setTestResult(null);
  443. }}
  444. placeholder={field.placeholder}
  445. rows={3}
  446. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none font-mono text-sm"
  447. />
  448. ) : (
  449. <input
  450. id={`notification-config-${field.key}`}
  451. type={field.type}
  452. pattern={field.pattern}
  453. value={config[field.key] || ''}
  454. onChange={(e) => {
  455. setConfig({ ...config, [field.key]: e.target.value });
  456. if (providerType === 'notify' && field.key === 'device_id' && isNotifyPushOnlyTarget(e.target.value)) {
  457. setNotifyLiveActivities(false);
  458. setNotifyLockScreenWidgets(false);
  459. }
  460. if (providerType === 'notify' && field.key === 'device_id' && isNotifyPhotoUnsupported(e.target.value)) {
  461. setAttachPhoto(false);
  462. }
  463. setTestResult(null);
  464. }}
  465. onFocus={selectPlaceholderOnFocus}
  466. placeholder={field.placeholder}
  467. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  468. />
  469. )}
  470. {config[field.key] === SECRET_PLACEHOLDER && (
  471. <p className="text-xs text-bambu-gray mt-1">{t('common.secretKept')}</p>
  472. )}
  473. {field.help && (
  474. <p className="text-xs text-bambu-gray mt-1">{field.help}</p>
  475. )}
  476. </div>
  477. ));
  478. return (
  479. <div
  480. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4 overflow-y-auto"
  481. onClick={onClose}
  482. >
  483. <div
  484. className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg my-8 max-h-[90vh] overflow-y-auto"
  485. onClick={(e) => e.stopPropagation()}
  486. >
  487. {/* Header */}
  488. <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
  489. <h2 className="text-lg font-semibold text-white">
  490. {isEditing ? t('notifications.editTitle') : t('notifications.addTitle')}
  491. </h2>
  492. <button
  493. onClick={onClose}
  494. className="text-bambu-gray hover:text-white transition-colors"
  495. >
  496. <X className="w-5 h-5" />
  497. </button>
  498. </div>
  499. {/* Form */}
  500. <form onSubmit={handleSubmit} className="p-6 space-y-4">
  501. {error && (
  502. <div className="p-3 bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/50 rounded-lg text-sm text-red-700 dark:text-red-400">
  503. {error}
  504. </div>
  505. )}
  506. {/* Name */}
  507. <div>
  508. <label className="block text-sm text-bambu-gray mb-1">{t('notifications.nameLabel')}</label>
  509. <input
  510. type="text"
  511. value={name}
  512. onChange={(e) => setName(e.target.value)}
  513. placeholder={t('notifications.namePlaceholder')}
  514. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  515. />
  516. </div>
  517. {/* Provider Type */}
  518. <div>
  519. <label className="block text-sm text-bambu-gray mb-1">{t('notifications.providerTypeLabel')}</label>
  520. <select
  521. value={providerType}
  522. onChange={(e) => {
  523. setProviderType(e.target.value as ProviderType);
  524. setConfig({}); // Reset config when changing type
  525. setNotifyLiveActivities(false);
  526. setNotifyLockScreenWidgets(false);
  527. setNotifyMetrics([]);
  528. setTestResult(null);
  529. }}
  530. disabled={isEditing}
  531. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-50"
  532. >
  533. {/* Sorted by the label shown, so the order holds in every language */}
  534. {PROVIDER_VALUES.map((value) => ({ value, label: t(`notifications.providerTypes.${value}`, value) }))
  535. .sort((a, b) => a.label.localeCompare(b.label, i18n.language, { sensitivity: 'base' }))
  536. .map(({ value, label }) => (
  537. <option key={value} value={value}>
  538. {label}
  539. </option>
  540. ))}
  541. </select>
  542. <p className="text-xs text-bambu-gray mt-1">
  543. {t(`notifications.providerDescriptions.${providerType}`, '')}
  544. </p>
  545. </div>
  546. {/* Provider-specific configuration */}
  547. <div className="space-y-3">
  548. <p className="text-sm text-bambu-gray">{t('notifications.configuration')}</p>
  549. {renderConfigFields(configFields.filter((field) => !field.advanced))}
  550. {providerType === 'notify' && (
  551. <div className="space-y-2">
  552. <div className="flex items-center justify-between gap-3" role="group" aria-label={t('notifications.notifyLiveActivities')}>
  553. <div>
  554. <label className="text-sm text-white">{t('notifications.notifyLiveActivities')}</label>
  555. <p className="text-xs text-bambu-gray">{t('notifications.notifyLiveActivitiesHelp')}</p>
  556. </div>
  557. <Toggle
  558. checked={notifyLiveActivities && !notifyPushOnlyTarget}
  559. disabled={notifyPushOnlyTarget}
  560. onChange={(checked) => {
  561. setNotifyLiveActivities(checked);
  562. setTestResult(null);
  563. setError(null);
  564. }}
  565. />
  566. </div>
  567. {notifyLiveActivities && (
  568. <>
  569. <p className="text-xs text-bambu-gray">{t('notifications.notifyLifecycleHelp')}</p>
  570. <details className="rounded-lg border border-bambu-dark-tertiary p-3">
  571. <summary className="cursor-pointer text-sm text-white">{t('notifications.notifyAppearance')}</summary>
  572. <div className="mt-3 space-y-3">
  573. {renderConfigFields(configFields.filter((field) => field.advanced))}
  574. <fieldset>
  575. <legend className="text-sm text-bambu-gray mb-1">{t('notifications.notifyMetrics')}</legend>
  576. <p className="text-xs text-bambu-gray mb-2">{t('notifications.notifyMetricsHelp')}</p>
  577. <div className="flex flex-wrap gap-3">
  578. {NOTIFY_METRICS.map((metric) => (
  579. <label key={metric} className="flex items-center gap-1 text-sm text-white">
  580. <input
  581. type="checkbox"
  582. checked={notifyMetrics.includes(metric)}
  583. onChange={(e) => {
  584. setNotifyMetrics(e.target.checked
  585. ? [...notifyMetrics, metric]
  586. : notifyMetrics.filter((selected) => selected !== metric));
  587. setTestResult(null);
  588. }}
  589. className="accent-bambu-green"
  590. />
  591. {t(metric === 'progress' ? 'notifications.progress'
  592. : metric === 'eta' ? 'streamOverlay.eta'
  593. : metric === 'layers' ? 'streamOverlay.layer'
  594. : `printers.temperatures.${metric}`)}
  595. </label>
  596. ))}
  597. </div>
  598. </fieldset>
  599. </div>
  600. </details>
  601. </>
  602. )}
  603. <div className="flex items-center justify-between gap-3" role="group" aria-label={t('notifications.notifyLockScreenWidgets')}>
  604. <div>
  605. <label className="text-sm text-white">{t('notifications.notifyLockScreenWidgets')}</label>
  606. <p className="text-xs text-bambu-gray">{t('notifications.notifyLockScreenWidgetsHelp')}</p>
  607. </div>
  608. <Toggle
  609. checked={notifyLockScreenWidgets && !notifyPushOnlyTarget}
  610. disabled={notifyPushOnlyTarget}
  611. onChange={(checked) => {
  612. setNotifyLockScreenWidgets(checked);
  613. setTestResult(null);
  614. setError(null);
  615. }}
  616. />
  617. </div>
  618. {notifyPushOnlyTarget && (
  619. <p className="text-xs text-bambu-gray">{t('notifications.notifyIosFeaturesUnavailable')}</p>
  620. )}
  621. <p className="text-xs text-bambu-gray">{t('notifications.notifyTestHelp')}</p>
  622. </div>
  623. )}
  624. {providerType === 'telegram' && (
  625. <div>
  626. <label htmlFor="telegram-verdict-mode" className="block text-sm text-bambu-gray mb-1">
  627. {t('notifications.telegramVerdictMode')}
  628. </label>
  629. <select
  630. id="telegram-verdict-mode"
  631. value={telegramVerdictMode}
  632. onChange={(e) => setTelegramVerdictMode(e.target.value as TelegramVerdictMode)}
  633. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  634. >
  635. <option value="buttons">{t('notifications.telegramVerdictModeButtons')}</option>
  636. <option value="reactions">{t('notifications.telegramVerdictModeReactions')}</option>
  637. <option value="both">{t('notifications.telegramVerdictModeBoth')}</option>
  638. </select>
  639. <p className="text-xs text-bambu-gray mt-1">{t('notifications.telegramVerdictModeHelp')}</p>
  640. </div>
  641. )}
  642. </div>
  643. {/* Test Button */}
  644. <div className="flex gap-2">
  645. <Button
  646. type="button"
  647. variant="secondary"
  648. onClick={() => {
  649. setTestResult(null);
  650. testMutation.mutate();
  651. }}
  652. disabled={testMutation.isPending || getRequiredFields(providerType).some((field) => !config[field.key]?.trim())}
  653. className="flex-1"
  654. >
  655. {testMutation.isPending ? (
  656. <Loader2 className="w-4 h-4 animate-spin" />
  657. ) : (
  658. <Send className="w-4 h-4" />
  659. )}
  660. {t('notifications.testConfiguration')}
  661. </Button>
  662. </div>
  663. {/* Test Result */}
  664. {testResult && (
  665. <div className={`p-3 rounded-lg flex items-center gap-2 ${
  666. testResult.success
  667. ? 'bg-bambu-green/20 border border-bambu-green/50 text-bambu-green'
  668. : 'bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/50 text-red-700 dark:text-red-400'
  669. }`}>
  670. {testResult.success ? (
  671. <>
  672. <CheckCircle className="w-5 h-5" />
  673. <span>{testResult.message}</span>
  674. </>
  675. ) : (
  676. <>
  677. <XCircle className="w-5 h-5" />
  678. <span>{testResult.message}</span>
  679. </>
  680. )}
  681. </div>
  682. )}
  683. {/* Link to Printer */}
  684. <div>
  685. <label className="block text-sm text-bambu-gray mb-1">{t('notifications.printerFilter')}</label>
  686. <select
  687. value={printerId ?? ''}
  688. onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
  689. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  690. >
  691. <option value="">{t('notifications.allPrinters')}</option>
  692. {printers?.map((p) => (
  693. <option key={p.id} value={p.id}>
  694. {p.name}
  695. </option>
  696. ))}
  697. </select>
  698. <p className="text-xs text-bambu-gray mt-1">
  699. {t('notifications.onlyFromPrinter')}
  700. </p>
  701. </div>
  702. {/* Attach Photo */}
  703. <div className="flex items-center justify-between">
  704. <div>
  705. <label className="text-sm text-white">{t('notifications.attachPhotoLabel')}</label>
  706. <p className="text-xs text-bambu-gray">{t(notifyPhotosUnsupported ? 'notifications.notifyPhotoUnavailable' : 'notifications.attachPhotoDescription')}</p>
  707. </div>
  708. <Toggle
  709. checked={attachPhoto && !notifyPhotosUnsupported}
  710. disabled={notifyPhotosUnsupported}
  711. onChange={setAttachPhoto}
  712. />
  713. </div>
  714. {/* Quiet Hours */}
  715. <div className="space-y-2">
  716. <div className="flex items-center justify-between">
  717. <label className="text-sm text-white">{t('notifications.quietHoursDnd')}</label>
  718. <Toggle
  719. checked={quietHoursEnabled}
  720. onChange={setQuietHoursEnabled}
  721. />
  722. </div>
  723. {quietHoursEnabled && (
  724. <div className="grid grid-cols-2 gap-3">
  725. <div>
  726. <label className="block text-xs text-bambu-gray mb-1">{t('notifications.quietStart')}</label>
  727. <input
  728. type="time"
  729. value={quietHoursStart}
  730. onChange={(e) => setQuietHoursStart(e.target.value)}
  731. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  732. />
  733. </div>
  734. <div>
  735. <label className="block text-xs text-bambu-gray mb-1">{t('notifications.quietEnd')}</label>
  736. <input
  737. type="time"
  738. value={quietHoursEnd}
  739. onChange={(e) => setQuietHoursEnd(e.target.value)}
  740. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  741. />
  742. </div>
  743. </div>
  744. )}
  745. </div>
  746. {/* Daily Digest */}
  747. <div className="space-y-2">
  748. <div className="flex items-center justify-between">
  749. <div>
  750. <label className="text-sm text-white">{t('notifications.dailyDigestLabel')}</label>
  751. <p className="text-xs text-bambu-gray">{t('notifications.batchNotifications')}</p>
  752. </div>
  753. <Toggle
  754. checked={dailyDigestEnabled}
  755. onChange={setDailyDigestEnabled}
  756. />
  757. </div>
  758. {dailyDigestEnabled && (
  759. <div>
  760. <label className="block text-xs text-bambu-gray mb-1">{t('notifications.sendDigestAt')}</label>
  761. <input
  762. type="time"
  763. value={dailyDigestTime}
  764. onChange={(e) => setDailyDigestTime(e.target.value)}
  765. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  766. />
  767. <p className="text-xs text-bambu-gray mt-1">
  768. {t('notifications.digestCollected')}
  769. </p>
  770. </div>
  771. )}
  772. </div>
  773. {/* Event Toggles */}
  774. <div className="space-y-3">
  775. <p className="text-sm text-bambu-gray">{t('notifications.notificationEvents')}</p>
  776. {/* Print Events */}
  777. <div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
  778. <p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">{t('notifications.printEvents')}</p>
  779. <div className="grid grid-cols-2 gap-2">
  780. <div className="flex items-center justify-between">
  781. <span className="text-sm text-white">{t('notifications.start')}</span>
  782. <Toggle checked={onPrintStart} onChange={setOnPrintStart} />
  783. </div>
  784. <div className="flex items-center justify-between">
  785. <span className="text-sm text-white">{t('notifications.complete')}</span>
  786. <Toggle checked={onPrintComplete} onChange={setOnPrintComplete} />
  787. </div>
  788. <div className="flex items-center justify-between">
  789. <span className="text-sm text-white">{t('notifications.failed')}</span>
  790. <Toggle checked={onPrintFailed} onChange={setOnPrintFailed} />
  791. </div>
  792. <div className="flex items-center justify-between">
  793. <span className="text-sm text-white">{t('notifications.stopped')}</span>
  794. <Toggle checked={onPrintStopped} onChange={setOnPrintStopped} />
  795. </div>
  796. <div className="flex items-center justify-between col-span-2">
  797. <div>
  798. <span className="text-sm text-white">{t('notifications.progress')}</span>
  799. <span className="text-xs text-bambu-gray ml-1">{t('notifications.progressPercent')}</span>
  800. </div>
  801. <Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />
  802. </div>
  803. <div className="flex items-center justify-between col-span-2">
  804. <div>
  805. <span className="text-sm text-white">{t('notifications.billingChargeFailedLabel')}</span>
  806. <span className="text-xs text-bambu-gray ml-1">{t('notifications.billingChargeFailedDescription')}</span>
  807. </div>
  808. <Toggle checked={onBillingChargeFailed} onChange={setOnBillingChargeFailed} />
  809. </div>
  810. <div className="flex items-center justify-between col-span-2">
  811. <div>
  812. <span className="text-sm text-white">{t('notifications.plateClearRequired')}</span>
  813. <span className="text-xs text-bambu-gray ml-1">{t('notifications.plateClearRequiredDescription')}</span>
  814. </div>
  815. <Toggle checked={onPlateClearRequired} onChange={setOnPlateClearRequired} />
  816. </div>
  817. <div className="flex items-center justify-between col-span-2">
  818. <div>
  819. <span className="text-sm text-white">{t('notifications.printConfirmRequest')}</span>
  820. <span className="text-xs text-bambu-gray ml-1">{t('notifications.printConfirmRequestDescription')}</span>
  821. </div>
  822. <Toggle checked={onPrintConfirmRequest} onChange={setOnPrintConfirmRequest} />
  823. </div>
  824. <div className="flex items-center justify-between col-span-2">
  825. <div>
  826. <span className="text-sm text-white">{t('notifications.bedCooled')}</span>
  827. <span className="text-xs text-bambu-gray ml-1">{t('notifications.bedCooledAfterPrint')}</span>
  828. </div>
  829. <Toggle checked={onBedCooled} onChange={setOnBedCooled} />
  830. </div>
  831. <div className="flex items-center justify-between col-span-2">
  832. <div>
  833. <span className="text-sm text-white">{t('notifications.firstLayerCompleteLabel')}</span>
  834. <span className="text-xs text-bambu-gray ml-1">{t('notifications.firstLayerCompleteDescription')}</span>
  835. </div>
  836. <Toggle checked={onFirstLayerComplete} onChange={setOnFirstLayerComplete} />
  837. </div>
  838. </div>
  839. </div>
  840. {/* Printer Status Events */}
  841. <div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
  842. <p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">{t('notifications.printerStatus')}</p>
  843. <div className="grid grid-cols-2 gap-2">
  844. <div className="flex items-center justify-between">
  845. <span className="text-sm text-white">{t('notifications.offline')}</span>
  846. <Toggle checked={onPrinterOffline} onChange={setOnPrinterOffline} />
  847. </div>
  848. <div className="flex items-center justify-between col-span-2">
  849. <div>
  850. <span className="text-sm text-white">{t('notifications.haSensorAlert')}</span>
  851. <span className="text-xs text-bambu-gray ml-1">{t('notifications.haSensorAlertDescription')}</span>
  852. </div>
  853. <Toggle checked={onHaSensorAlert} onChange={setOnHaSensorAlert} />
  854. </div>
  855. <div className="flex items-center justify-between col-span-2">
  856. <div>
  857. <span className="text-sm text-white">{t('notifications.locationHaSensorAlert')}</span>
  858. <span className="text-xs text-bambu-gray ml-1">{t('notifications.locationHaSensorAlertDescription')}</span>
  859. </div>
  860. <Toggle checked={onLocationHaSensorAlert} onChange={setOnLocationHaSensorAlert} />
  861. </div>
  862. <div className="flex items-center justify-between">
  863. <span className="text-sm text-white">{t('notifications.error')}</span>
  864. <Toggle checked={onPrinterError} onChange={setOnPrinterError} />
  865. </div>
  866. <div className="flex items-center justify-between">
  867. <span className="text-sm text-white">{t('notifications.aiFailureDetection')}</span>
  868. <Toggle checked={onAiFailureDetection} onChange={setOnAiFailureDetection} />
  869. </div>
  870. <div className="flex items-center justify-between">
  871. <span className="text-sm text-white">{t('notifications.lowFilament')}</span>
  872. <Toggle checked={onFilamentLow} onChange={setOnFilamentLow} />
  873. </div>
  874. <div className="flex items-center justify-between">
  875. <span className="text-sm text-white">{t('notifications.maintenance')}</span>
  876. <Toggle checked={onMaintenanceDue} onChange={setOnMaintenanceDue} />
  877. </div>
  878. </div>
  879. </div>
  880. {/* Inventory Stock Alerts */}
  881. <div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
  882. <p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">{t('notifications.inventoryAlerts')}</p>
  883. <div className="grid grid-cols-1 gap-2">
  884. <div className="flex items-center justify-between">
  885. <div>
  886. <span className="text-sm text-white">{t('notifications.stockReorderAlert')}</span>
  887. <span className="text-xs text-bambu-gray ml-1">{t('notifications.stockReorderAlertDescription')}</span>
  888. </div>
  889. <Toggle checked={onStockReorderAlert} onChange={setOnStockReorderAlert} />
  890. </div>
  891. <div className="flex items-center justify-between">
  892. <div>
  893. <span className="text-sm text-white">{t('notifications.stockBreakAlert')}</span>
  894. <span className="text-xs text-bambu-gray ml-1">{t('notifications.stockBreakAlertDescription')}</span>
  895. </div>
  896. <Toggle checked={onStockBreakAlert} onChange={setOnStockBreakAlert} />
  897. </div>
  898. </div>
  899. </div>
  900. {/* Messages other applications send (POST /notifications/app-message) */}
  901. <div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
  902. <p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">{t('notifications.connectedApps')}</p>
  903. <div className="flex items-center justify-between">
  904. <div>
  905. <span className="text-sm text-white">{t('notifications.appMessages')}</span>
  906. <span className="text-xs text-bambu-gray ml-1">{t('notifications.appMessagesDescription')}</span>
  907. </div>
  908. <Toggle checked={onAppMessage} onChange={setOnAppMessage} />
  909. </div>
  910. </div>
  911. {/* Per-event priority: ntfy (#990), Gotify (#2743) */}
  912. {(providerType === 'ntfy' || providerType === 'gotify') && (() => {
  913. const enabledEvents: Array<{ key: string; label: string }> = [];
  914. if (onPrintStart) enabledEvents.push({ key: 'on_print_start', label: t('notifications.start') });
  915. if (onPrintComplete) enabledEvents.push({ key: 'on_print_complete', label: t('notifications.complete') });
  916. if (onPrintFailed) enabledEvents.push({ key: 'on_print_failed', label: t('notifications.failed') });
  917. if (onPrintStopped) enabledEvents.push({ key: 'on_print_stopped', label: t('notifications.stopped') });
  918. if (onPrintProgress) enabledEvents.push({ key: 'on_print_progress', label: t('notifications.progress') });
  919. if (onBillingChargeFailed) enabledEvents.push({ key: 'on_billing_charge_failed', label: t('notifications.billingChargeFailedLabel') });
  920. if (onPlateClearRequired) enabledEvents.push({ key: 'on_plate_clear_required', label: t('notifications.plateClearRequired') });
  921. if (onPrintConfirmRequest) enabledEvents.push({ key: 'on_print_confirm_request', label: t('notifications.printConfirmRequest') });
  922. if (onBedCooled) enabledEvents.push({ key: 'on_bed_cooled', label: t('notifications.bedCooled') });
  923. if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
  924. if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') });
  925. if (onPrinterError) enabledEvents.push({ key: 'on_printer_error', label: t('notifications.error') });
  926. if (onHaSensorAlert) enabledEvents.push({ key: 'on_ha_sensor_alert', label: t('notifications.haSensorAlert') });
  927. if (onLocationHaSensorAlert) enabledEvents.push({ key: 'on_location_ha_sensor_alert', label: t('notifications.locationHaSensorAlert') });
  928. if (onAiFailureDetection) enabledEvents.push({ key: 'on_ai_failure_detection', label: t('notifications.aiFailureDetection') });
  929. if (onFilamentLow) enabledEvents.push({ key: 'on_filament_low', label: t('notifications.lowFilament') });
  930. if (onMaintenanceDue) enabledEvents.push({ key: 'on_maintenance_due', label: t('notifications.maintenance') });
  931. if (onStockReorderAlert) enabledEvents.push({ key: 'on_stock_reorder_alert', label: t('notifications.stockReorderAlert') });
  932. if (onStockBreakAlert) enabledEvents.push({ key: 'on_stock_break_alert', label: t('notifications.stockBreakAlert') });
  933. if (enabledEvents.length === 0) return null;
  934. return (
  935. <div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
  936. <p className="text-xs text-bambu-gray uppercase tracking-wide mb-1">
  937. {providerType === 'gotify'
  938. ? t('notifications.eventPriority.sectionTitleGotify')
  939. : t('notifications.eventPriority.sectionTitle')}
  940. </p>
  941. <p className="text-xs text-bambu-gray mb-2">
  942. {providerType === 'gotify'
  943. ? t('notifications.eventPriority.helpGotify')
  944. : t('notifications.eventPriority.helpNtfy')}
  945. </p>
  946. <div className="space-y-2">
  947. {enabledEvents.map((ev) => (
  948. <div key={ev.key} className="flex items-center justify-between gap-3">
  949. <span className="text-sm text-white">{ev.label}</span>
  950. <select
  951. value={eventPriorities[ev.key] ?? 3}
  952. onChange={(e) => {
  953. const next = Number(e.target.value);
  954. setEventPriorities((prev) => ({ ...prev, [ev.key]: next }));
  955. }}
  956. className="px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-sm text-white focus:border-bambu-green focus:outline-none"
  957. >
  958. <option value={1}>{t('notifications.eventPriority.min')}</option>
  959. <option value={2}>{t('notifications.eventPriority.low')}</option>
  960. <option value={3}>{t('notifications.eventPriority.default')}</option>
  961. <option value={4}>{t('notifications.eventPriority.high')}</option>
  962. <option value={5}>{t('notifications.eventPriority.urgent')}</option>
  963. </select>
  964. </div>
  965. ))}
  966. </div>
  967. </div>
  968. );
  969. })()}
  970. </div>
  971. {/* Actions */}
  972. <div className="flex gap-3 pt-2">
  973. <Button
  974. type="button"
  975. variant="secondary"
  976. onClick={onClose}
  977. className="flex-1"
  978. >
  979. {t('notifications.cancel')}
  980. </Button>
  981. <Button
  982. type="submit"
  983. disabled={isPending}
  984. className="flex-1"
  985. >
  986. {isPending ? (
  987. <Loader2 className="w-4 h-4 animate-spin" />
  988. ) : (
  989. <Save className="w-4 h-4" />
  990. )}
  991. {isEditing ? t('notifications.save') : t('notifications.add')}
  992. </Button>
  993. </div>
  994. </form>
  995. </div>
  996. </div>
  997. );
  998. }