| 1 | "use client"; |
| 2 | |
| 3 | import { useRef, useState |
| 4 | import { motion } |
| 5 | import { useQuery, useMutation |
| 6 | import { useTranslations } |
| 7 | import { Check, Upload |
| 8 | import Link from "next/link |
| 9 | import { toast } |
| 10 | import { orpc, queryClient |
| 11 | import { DiscordPreview, GooglePreview |
| 12 | import { Select, SelectTrigger |
| 13 | import { BackupDestinationForm, defaultBackupDestination |
| 14 | import { ConfirmDialog } |
| 15 | import type { BackupDestinationInput |
| 16 | import { SENTINEL } |
| 17 | |
| 18 | function invalidateSettings() { |
| 19 | void queryClient.invalidateQueries( |
| 20 | } |
| 21 | |
| 22 | function invalidateBackupDestinations() { |
| 23 | void queryClient.invalidateQueries( |
| 24 | } |
| 25 | |
| 26 | function SectionCard({ title |
| 27 | return ( |
| 28 | <div className="rounded-xl border border-border bg-card |
| 29 | <div className="border-b border-border px-4 py-3 |
| 30 | <h2 className="text-sm font-semibold text-foreground |
| 31 | {description && <p |
| 32 | </div> |
| 33 | <div className="p-4 |
| 34 | </div> |
| 35 | ); |
| 36 | } |
| 37 | |
| 38 | function inputClass(disabled? |
| 39 | return `w-full rounded-lg border border-border bg-background px-3 py-1.5 text-sm text-foreground outline-none placeholder:text-muted-foreground/50 focus:border-ring transition-colors${disabled |
| 40 | } |
| 41 | |
| 42 | function Toggle({ enabled |
| 43 | return ( |
| 44 | <button |
| 45 | type="button" |
| 46 | role="switch" |
| 47 | aria-checked={enabled} |
| 48 | onClick={(e) |
| 49 | className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${enabled |
| 50 | > |
| 51 | <span className={` |
| 52 | </button> |
| 53 | ); |
| 54 | } |
| 55 | |
| 56 | function SaveButton({ saving |
| 57 | const tc = |
| 58 | return ( |
| 59 | <button |
| 60 | type="button" |
| 61 | disabled={saving || |
| 62 | className="flex items-center gap-1.5 rounded-lg bg-foreground px-4 py-1.5 text-sm font-medium text-background transition-opacity hover:opacity-80 disabled:opacity-40" |
| 63 | > |
| 64 | {saving ? tc |
| 65 | </button> |
| 66 | ); |
| 67 | } |
| 68 | |
| 69 | function ImageUploadField({ |
| 70 | label, |
| 71 | description, |
| 72 | accept, |
| 73 | maxSizeMb, |
| 74 | currentUrl, |
| 75 | uploading, |
| 76 | error, |
| 77 | aspectHint, |
| 78 | onUpload, |
| 79 | onRemove, |
| 80 | }: { |
| 81 | label: string |
| 82 | description: string |
| 83 | accept: string |
| 84 | maxSizeMb: number |
| 85 | currentUrl: string |
| 86 | uploading: boolean |
| 87 | error: string |
| 88 | aspectHint?: string |
| 89 | onUpload: (file |
| 90 | onRemove: () |
| 91 | }) { |
| 92 | const tc = |
| 93 | const inputRef = |
| 94 | |
| 95 | return ( |
| 96 | <div className="flex flex-col gap-2 |
| 97 | <div> |
| 98 | <p className="text-xs font-medium text-foreground |
| 99 | <p className="text-[11px] text-muted-foreground mt-0.5 |
| 100 | </div> |
| 101 | <div className="flex items-start gap-3 |
| 102 | {currentUrl ? ( |
| 103 | <div className="flex shrink-0 items-center justify-center rounded-lg border border-border bg-muted overflow-hidden |
| 104 | {/* eslint-disable-next-line @next/next/no-img-element */ |
| 105 | <img src={currentUrl |
| 106 | </div> |
| 107 | ) : ( |
| 108 | <div |
| 109 | className="flex shrink-0 items-center justify-center rounded-lg border border-dashed border-border bg-muted text-muted-foreground" |
| 110 | style={aspectHint ? |
| 111 | > |
| 112 | <Upload className=" |
| 113 | </div> |
| 114 | )} |
| 115 | <div className="flex flex-col gap-1.5 |
| 116 | <div className="flex items-center gap-2 |
| 117 | <input |
| 118 | ref={inputRef} |
| 119 | type="file" |
| 120 | accept={accept} |
| 121 | className="hidden" |
| 122 | disabled={uploading} |
| 123 | onChange={(e) |
| 124 | const file = |
| 125 | if (file) onUpload( |
| 126 | if (inputRef.current) |
| 127 | }} |
| 128 | /> |
| 129 | <label |
| 130 | onClick={() => |
| 131 | className={`flex cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-muted${uploading |
| 132 | > |
| 133 | <Upload className=" |
| 134 | {uploading ? tc |
| 135 | </label> |
| 136 | {currentUrl && ( |
| 137 | <button |
| 138 | type="button" |
| 139 | onClick={onRemove} |
| 140 | className="flex items-center gap-1.5 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-1.5 text-sm font-medium text-destructive transition-opacity hover:opacity-80" |
| 141 | > |
| 142 | <X className=" |
| 143 | {tc("remove") |
| 144 | </button> |
| 145 | )} |
| 146 | </div> |
| 147 | {error && <p |
| 148 | </div> |
| 149 | </div> |
| 150 | </div> |
| 151 | ); |
| 152 | } |
| 153 | |
| 154 | function CallbackUrl({ appUrl |
| 155 | const t = |
| 156 | const tc = |
| 157 | const [copied, |
| 158 | const url = |
| 159 | |
| 160 | function copy() { |
| 161 | if (!url) |
| 162 | void navigator.clipboard. |
| 163 | setCopied(true); |
| 164 | setTimeout(() => |
| 165 | }); |
| 166 | } |
| 167 | |
| 168 | return ( |
| 169 | <div className="flex flex-col gap-1.5 mb-4 |
| 170 | <label className="text-xs font-medium text-foreground |
| 171 | <div className="flex items-center gap-2 |
| 172 | <div className="flex-1 rounded-lg border border-border bg-muted px-3 py-1.5 font-mono text-xs text-muted-foreground truncate select-all |
| 173 | {url ?? <span |
| 174 | </div> |
| 175 | {url && ( |
| 176 | <button |
| 177 | type="button" |
| 178 | onClick={copy} |
| 179 | className="flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted" |
| 180 | > |
| 181 | {copied ? <><Check |
| 182 | </button> |
| 183 | )} |
| 184 | </div> |
| 185 | </div> |
| 186 | ); |
| 187 | } |
| 188 | |
| 189 | function DiscordIcon({ className |
| 190 | return ( |
| 191 | <svg className={className |
| 192 | <path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057c.003.02.015.04.03.052a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z |
| 193 | </svg> |
| 194 | ); |
| 195 | } |
| 196 | |
| 197 | function NotificationEndpointRow({ |
| 198 | icon, |
| 199 | name, |
| 200 | enabled, |
| 201 | onToggle, |
| 202 | expanded, |
| 203 | onExpandToggle, |
| 204 | children, |
| 205 | }: { |
| 206 | icon: React |
| 207 | name: string |
| 208 | enabled: boolean |
| 209 | onToggle: (v |
| 210 | expanded: boolean |
| 211 | onExpandToggle: () |
| 212 | children: React |
| 213 | }) { |
| 214 | const t = |
| 215 | return ( |
| 216 | <div className="rounded-lg border border-border overflow-hidden |
| 217 | <button |
| 218 | type="button" |
| 219 | onClick={onExpandToggle} |
| 220 | className="flex w-full items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors" |
| 221 | > |
| 222 | <div className="flex items-center gap-2.5 |
| 223 | {icon} |
| 224 | <span className="text-sm font-medium text-foreground |
| 225 | {enabled && ( |
| 226 | <span className="rounded-full bg-green-500/10 px-2 py-0.5 text-[11px] font-medium text-green-600 dark:text-green-400 |
| 227 | )} |
| 228 | </div> |
| 229 | <div className="flex items-center gap-2 |
| 230 | <Toggle enabled={ |
| 231 | <ChevronDown className={ |
| 232 | </div> |
| 233 | </button> |
| 234 | {expanded && <div |
| 235 | </div> |
| 236 | ); |
| 237 | } |
| 238 | |
| 239 | const TABS = [ |
| 240 | type Tab |
| 241 | |
| 242 | export default function AdminSettingsPage |
| 243 | const t = |
| 244 | const tc = |
| 245 | const [tab, |
| 246 | const [needsRestart, |
| 247 | const [githubExpanded, |
| 248 | const [discordExpanded, |
| 249 | |
| 250 | const { data, |
| 251 | const setMutation = |
| 252 | const removeLogoMutation = |
| 253 | onSuccess: () => |
| 254 | })); |
| 255 | const removeOgBannerMutation = |
| 256 | onSuccess: () => |
| 257 | })); |
| 258 | |
| 259 | const [logoUploading, |
| 260 | const [logoError, |
| 261 | |
| 262 | const [general, |
| 263 | const [seo, |
| 264 | metaDescription: string |
| 265 | ogTitle: string |
| 266 | twitterCard: string |
| 267 | } | null |
| 268 | const [seoPreviewTab, |
| 269 | const [bannerUploading, |
| 270 | const [bannerError, |
| 271 | const [registration, |
| 272 | const [github, |
| 273 | const [discord, |
| 274 | const [gdrive, |
| 275 | |
| 276 | // Email / SMTP state |
| 277 | const { data: |
| 278 | const saveSmtpMutation = |
| 279 | const testConnectionMutation = |
| 280 | const [smtp, |
| 281 | enabled: boolean |
| 282 | fromEmail: string |
| 283 | } | null |
| 284 | const [testResult, |
| 285 | const [testModalOpen, |
| 286 | const [testEmail, |
| 287 | |
| 288 | useEffect(() => |
| 289 | if (!data) |
| 290 | setGithub((prev) |
| 291 | setDiscord((prev) |
| 292 | setGdrive((prev) |
| 293 | }, [data]); |
| 294 | |
| 295 | useEffect(() => |
| 296 | if (!smtpData || |
| 297 | setSmtp({ |
| 298 | enabled: smtpData.enabled |
| 299 | host: smtpData.host |
| 300 | port: smtpData.port |
| 301 | user: smtpData.user |
| 302 | password: "", |
| 303 | fromEmail: smtpData.fromEmail |
| 304 | fromName: smtpData.fromName |
| 305 | secure: smtpData.secure |
| 306 | }); |
| 307 | }, [smtpData, |
| 308 | |
| 309 | // Backup destination state |
| 310 | const { data: |
| 311 | const upsertGlobalDestinationMutation = |
| 312 | onSuccess: () => |
| 313 | })); |
| 314 | const clearGlobalDestinationMutation = |
| 315 | onSuccess: () => |
| 316 | })); |
| 317 | const [backupDestination, |
| 318 | const [backupResetOpen, |
| 319 | |
| 320 | useEffect(() => |
| 321 | if (!globalDestination || |
| 322 | setBackupDestination(globalDestination); |
| 323 | }, [globalDestination, |
| 324 | |
| 325 | async function saveBackupDestination |
| 326 | if (!backupDestination) |
| 327 | await upsertGlobalDestinationMutation.mutateAsync |
| 328 | } |
| 329 | |
| 330 | async function resetBackupDestination |
| 331 | await clearGlobalDestinationMutation.mutateAsync |
| 332 | setBackupDestination(null); |
| 333 | setBackupResetOpen(false); |
| 334 | } |
| 335 | |
| 336 | const { data: |
| 337 | const saveNotifMutation = |
| 338 | onSuccess: () => |
| 339 | })); |
| 340 | const testNotifMutation = |
| 341 | const [notif, |
| 342 | discordEnabled: boolean |
| 343 | discordWebhookUrl: string |
| 344 | telegramEnabled: boolean |
| 345 | telegramBotToken: string |
| 346 | telegramChatId: string |
| 347 | userConfigEnabled: boolean |
| 348 | } | null |
| 349 | const [discordNotifExpanded, |
| 350 | const [telegramNotifExpanded, |
| 351 | |
| 352 | useEffect(() => |
| 353 | if (!notifData || |
| 354 | setNotif({ |
| 355 | discordEnabled: notifData.discordEnabled |
| 356 | discordWebhookUrl: "", |
| 357 | telegramEnabled: notifData.telegramEnabled |
| 358 | telegramBotToken: "", |
| 359 | telegramChatId: notifData.telegramChatId |
| 360 | userConfigEnabled: notifData.userConfigEnabled |
| 361 | }); |
| 362 | }, [notifData, |
| 363 | |
| 364 | async function saveDiscordNotifications |
| 365 | if (!notif) |
| 366 | try { |
| 367 | await saveNotifMutation.mutateAsync |
| 368 | discordEnabled: notif.discordEnabled |
| 369 | discordWebhookUrl: notif.discordWebhookUrl |
| 370 | }); |
| 371 | setNotif((prev) |
| 372 | toast.success(tc |
| 373 | } catch (err) |
| 374 | toast.error(t |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | async function saveTelegramNotifications |
| 379 | if (!notif) |
| 380 | try { |
| 381 | await saveNotifMutation.mutateAsync |
| 382 | telegramEnabled: notif.telegramEnabled |
| 383 | telegramBotToken: notif.telegramBotToken |
| 384 | telegramChatId: notif.telegramChatId |
| 385 | }); |
| 386 | setNotif((prev) |
| 387 | toast.success(tc |
| 388 | } catch (err) |
| 389 | toast.error(t |
| 390 | } |
| 391 | } |
| 392 | |
| 393 | async function saveUserConfigNotifications |
| 394 | if (!notif) |
| 395 | try { |
| 396 | await saveNotifMutation.mutateAsync |
| 397 | userConfigEnabled: notif.userConfigEnabled |
| 398 | }); |
| 399 | toast.success(tc |
| 400 | } catch (err) |
| 401 | toast.error(t |
| 402 | } |
| 403 | } |
| 404 | |
| 405 | async function testNotification |
| 406 | try { |
| 407 | const result = |
| 408 | if (result.ok |
| 409 | else toast.error( |
| 410 | } catch (err) |
| 411 | toast.error(t |
| 412 | } |
| 413 | } |
| 414 | |
| 415 | function generalForm() { |
| 416 | return general ?? |
| 417 | } |
| 418 | function seoForm() { |
| 419 | return seo ?? |
| 420 | metaDescription: data?.metaDescription |
| 421 | metaKeywords: data?.metaKeywords |
| 422 | themeColor: data?.themeColor |
| 423 | ogTitle: data?.ogTitle |
| 424 | ogDescription: data?.ogDescription |
| 425 | ogSiteName: data?.ogSiteName |
| 426 | ogType: data?.ogType |
| 427 | twitterCard: data?.twitterCard |
| 428 | twitterSite: data?.twitterSite |
| 429 | twitterCreator: data?.twitterCreator |
| 430 | }; |
| 431 | } |
| 432 | function registrationForm() { |
| 433 | return registration ?? |
| 434 | } |
| 435 | |
| 436 | async function saveGeneral |
| 437 | const f = |
| 438 | const saves: |
| 439 | if (!data?.appNameFromEnv |
| 440 | if (!data?.appUrlFromEnv |
| 441 | await Promise.all |
| 442 | if (!data?.appNameFromEnv |
| 443 | toast.success(tc |
| 444 | } |
| 445 | |
| 446 | async function saveSeo |
| 447 | const f = |
| 448 | await Promise.all |
| 449 | setMutation.mutateAsync({ |
| 450 | setMutation.mutateAsync({ |
| 451 | setMutation.mutateAsync({ |
| 452 | setMutation.mutateAsync({ |
| 453 | setMutation.mutateAsync({ |
| 454 | setMutation.mutateAsync({ |
| 455 | setMutation.mutateAsync({ |
| 456 | setMutation.mutateAsync({ |
| 457 | setMutation.mutateAsync({ |
| 458 | setMutation.mutateAsync({ |
| 459 | ]); |
| 460 | toast.success(tc |
| 461 | } |
| 462 | |
| 463 | async function saveRegistration |
| 464 | await setMutation.mutateAsync |
| 465 | toast.success(tc |
| 466 | } |
| 467 | |
| 468 | async function saveGithub |
| 469 | const saves: |
| 470 | setMutation.mutateAsync({ |
| 471 | setMutation.mutateAsync({ |
| 472 | ]; |
| 473 | if (github.clientSecret |
| 474 | await Promise.all |
| 475 | setGithub((prev) |
| 476 | setNeedsRestart(true); |
| 477 | toast.success(tc |
| 478 | } |
| 479 | |
| 480 | async function saveDiscord |
| 481 | const saves: |
| 482 | setMutation.mutateAsync({ |
| 483 | setMutation.mutateAsync({ |
| 484 | ]; |
| 485 | if (discord.clientSecret |
| 486 | await Promise.all |
| 487 | setDiscord((prev) |
| 488 | setNeedsRestart(true); |
| 489 | toast.success(tc |
| 490 | } |
| 491 | |
| 492 | async function saveGdrive |
| 493 | const saves: |
| 494 | setMutation.mutateAsync({ |
| 495 | ]; |
| 496 | if (gdrive.clientSecret |
| 497 | await Promise.all |
| 498 | setGdrive((prev) |
| 499 | toast.success(tc |
| 500 | } |
| 501 | |
| 502 | async function saveSmtp |
| 503 | if (!smtp) |
| 504 | await saveSmtpMutation.mutateAsync |
| 505 | enabled: smtp.enabled |
| 506 | host: smtp.host |
| 507 | port: smtp.port |
| 508 | user: smtp.user |
| 509 | password: smtp.password |
| 510 | fromEmail: smtp.fromEmail |
| 511 | fromName: smtp.fromName |
| 512 | secure: smtp.secure |
| 513 | }); |
| 514 | setSmtp((prev) |
| 515 | toast.success(tc |
| 516 | } |
| 517 | |
| 518 | async function testSmtp |
| 519 | setTestResult(null); |
| 520 | const result = |
| 521 | setTestModalOpen(false); |
| 522 | if (result.ok |
| 523 | setTestResult({ ok: |
| 524 | } else { |
| 525 | setTestResult({ ok: |
| 526 | } |
| 527 | } |
| 528 | |
| 529 | async function handleUpload |
| 530 | endpoint: string, |
| 531 | setUploading: (v |
| 532 | setError: (v |
| 533 | file: File |
| 534 | ) { |
| 535 | setError(null); |
| 536 | setUploading(true); |
| 537 | try { |
| 538 | const formData = |
| 539 | formData.append("file |
| 540 | const res = |
| 541 | if (!res.ok |
| 542 | const body = |
| 543 | setError((body as |
| 544 | } else { |
| 545 | invalidateSettings(); |
| 546 | } |
| 547 | } catch { |
| 548 | setError("Upload failed."); |
| 549 | } finally { |
| 550 | setUploading(false); |
| 551 | } |
| 552 | } |
| 553 | |
| 554 | if (isLoading) { |
| 555 | return ( |
| 556 | <div className="flex flex-1 items-center justify-center |
| 557 | <p className="text-sm text-muted-foreground |
| 558 | </div> |
| 559 | ); |
| 560 | } |
| 561 | |
| 562 | const gf = |
| 563 | const rf = |
| 564 | const bothFromEnv = |
| 565 | |
| 566 | return ( |
| 567 | <div className="flex-1 overflow-auto px-6 py-5 |
| 568 | <div className={` |
| 569 | <h1 className="text-sm font-semibold text-foreground |
| 570 | |
| 571 | {/* Tab bar */} |
| 572 | <div className="flex gap-1 border-b border-border -mb-1 |
| 573 | {TABS.map(( |
| 574 | <button |
| 575 | key={tab_item} |
| 576 | type="button" |
| 577 | onClick={() => |
| 578 | className={`px-3 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${ |
| 579 | tab === tab_item |
| 580 | ? "border-foreground text-foreground" |
| 581 | : "border-transparent text-muted-foreground hover:text-foreground" |
| 582 | }`} |
| 583 | > |
| 584 | {tab_item === "branding |
| 585 | : tab_item === " |
| 586 | : tab_item === " |
| 587 | : tab_item === " |
| 588 | : tab_item === " |
| 589 | : t("tabNotifications" |
| 590 | </button> |
| 591 | ))} |
| 592 | </div> |
| 593 | |
| 594 | {/* Restart banner */} |
| 595 | {needsRestart && ( |
| 596 | <div className="flex items-start gap-3 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 |
| 597 | <RotateCcw className=" |
| 598 | <div className="flex-1 min-w-0 |
| 599 | <p className="text-sm font-medium text-amber-600 dark:text-amber-400 |
| 600 | <p className="text-xs text-amber-600/80 dark:text-amber-400/80 mt-0.5 |
| 601 | {t("restartDesc") |
| 602 | </p> |
| 603 | </div> |
| 604 | <button |
| 605 | type="button" |
| 606 | onClick={() => |
| 607 | className="text-amber-500 hover:text-amber-400 transition-colors" |
| 608 | > |
| 609 | <X className=" |
| 610 | </button> |
| 611 | </div> |
| 612 | )} |
| 613 | |
| 614 | <motion.div key={ |
| 615 | {tab === "branding |
| 616 | <> |
| 617 | <SectionCard title={ |
| 618 | <div className="grid grid-cols-2 gap-3 |
| 619 | <div className="flex flex-col gap-1.5 |
| 620 | <label className="text-xs font-medium text-foreground |
| 621 | <input |
| 622 | className={inputClass(data |
| 623 | value={gf.appName |
| 624 | disabled={data?.appNameFromEnv |
| 625 | onChange={(e) |
| 626 | /> |
| 627 | {data?.appNameFromEnv && |
| 628 | </div> |
| 629 | <div className="flex flex-col gap-1.5 |
| 630 | <label className="text-xs font-medium text-foreground |
| 631 | <input |
| 632 | className={inputClass(data |
| 633 | placeholder={t(" |
| 634 | value={gf.appUrl |
| 635 | disabled={data?.appUrlFromEnv |
| 636 | onChange={(e) |
| 637 | /> |
| 638 | {data?.appUrlFromEnv && |
| 639 | </div> |
| 640 | </div> |
| 641 | {!bothFromEnv && ( |
| 642 | <div className="mt-4 |
| 643 | <SaveButton saving={ |
| 644 | </div> |
| 645 | )} |
| 646 | </SectionCard> |
| 647 | |
| 648 | <SectionCard title={ |
| 649 | <ImageUploadField |
| 650 | label={t(" |
| 651 | description={t(" |
| 652 | accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml" |
| 653 | maxSizeMb={5} |
| 654 | currentUrl={data?.logoUrl |
| 655 | uploading={logoUploading} |
| 656 | error={logoError} |
| 657 | onUpload={(f) |
| 658 | onRemove={() => |
| 659 | /> |
| 660 | </SectionCard> |
| 661 | </> |
| 662 | )} |
| 663 | |
| 664 | {tab === "seo |
| 665 | const sf = |
| 666 | const previewData: |
| 667 | title: sf.ogTitle |
| 668 | description: sf.ogDescription |
| 669 | siteName: sf.ogSiteName |
| 670 | siteUrl: data?.appUrl |
| 671 | imageUrl: data?.ogBannerUrl |
| 672 | themeColor: sf.themeColor |
| 673 | twitterCard: (sf.twitterCard |
| 674 | twitterSite: sf.twitterSite |
| 675 | }; |
| 676 | return ( |
| 677 | <div className="grid grid-cols-1 lg:grid-cols-[1fr_420px] gap-6 items-start |
| 678 | {/* Left: form sections */ |
| 679 | <div className="flex flex-col gap-4 |
| 680 | <SectionCard title={ |
| 681 | <div className="flex flex-col gap-3 |
| 682 | <div className="flex flex-col gap-1.5 |
| 683 | <label className="text-xs font-medium text-foreground |
| 684 | <textarea |
| 685 | rows={2} |
| 686 | className={`${inputClass |
| 687 | placeholder={t(" |
| 688 | value={sf.metaDescription |
| 689 | onChange={(e) |
| 690 | /> |
| 691 | </div> |
| 692 | <div className="flex flex-col gap-1.5 |
| 693 | <label className="text-xs font-medium text-foreground |
| 694 | <input |
| 695 | className={inputClass()} |
| 696 | placeholder={t(" |
| 697 | value={sf.metaKeywords |
| 698 | onChange={(e) |
| 699 | /> |
| 700 | <p className="text-[11px] text-muted-foreground |
| 701 | </div> |
| 702 | <div className="flex flex-col gap-1.5 |
| 703 | <label className="text-xs font-medium text-foreground |
| 704 | <div className="flex items-center gap-2 |
| 705 | <input |
| 706 | type="color" |
| 707 | className="h-8 w-10 cursor-pointer rounded border border-border bg-background p-0.5" |
| 708 | value={sf.themeColor |
| 709 | onChange={(e) |
| 710 | /> |
| 711 | <input |
| 712 | className={`${inputClass |
| 713 | placeholder={t(" |
| 714 | value={sf.themeColor |
| 715 | onChange={(e) |
| 716 | /> |
| 717 | </div> |
| 718 | <p className="text-[11px] text-muted-foreground |
| 719 | </div> |
| 720 | </div> |
| 721 | </SectionCard> |
| 722 | |
| 723 | <SectionCard title={ |
| 724 | <div className="flex flex-col gap-3 |
| 725 | <div className="grid grid-cols-2 gap-3 |
| 726 | <div className="flex flex-col gap-1.5 |
| 727 | <label className="text-xs font-medium text-foreground |
| 728 | <input |
| 729 | className={inputClass()} |
| 730 | placeholder={t(" |
| 731 | value={sf.ogTitle |
| 732 | onChange={(e) |
| 733 | /> |
| 734 | </div> |
| 735 | <div className="flex flex-col gap-1.5 |
| 736 | <label className="text-xs font-medium text-foreground |
| 737 | <input |
| 738 | className={inputClass()} |
| 739 | placeholder={t(" |
| 740 | value={sf.ogSiteName |
| 741 | onChange={(e) |
| 742 | /> |
| 743 | </div> |
| 744 | </div> |
| 745 | <div className="flex flex-col gap-1.5 |
| 746 | <label className="text-xs font-medium text-foreground |
| 747 | <textarea |
| 748 | rows={2} |
| 749 | className={`${inputClass |
| 750 | placeholder={t(" |
| 751 | value={sf.ogDescription |
| 752 | onChange={(e) |
| 753 | /> |
| 754 | </div> |
| 755 | <div className="flex flex-col gap-1.5 |
| 756 | <label className="text-xs font-medium text-foreground |
| 757 | <Select value={ |
| 758 | <SelectTrigger className=" |
| 759 | <SelectValue> |
| 760 | {sf.ogType === |
| 761 | </SelectValue> |
| 762 | </SelectTrigger> |
| 763 | <SelectContent> |
| 764 | <SelectItem value=" |
| 765 | <SelectItem value=" |
| 766 | <SelectItem value=" |
| 767 | </SelectContent> |
| 768 | </Select> |
| 769 | </div> |
| 770 | <div className="pt-1 |
| 771 | <ImageUploadField |
| 772 | label={t(" |
| 773 | description={t(" |
| 774 | accept="image/jpeg,image/png,image/webp" |
| 775 | maxSizeMb={8} |
| 776 | aspectHint={t(" |
| 777 | currentUrl={data?.ogBannerUrl |
| 778 | uploading={bannerUploading} |
| 779 | error={bannerError} |
| 780 | onUpload={(f) |
| 781 | onRemove={() => |
| 782 | /> |
| 783 | </div> |
| 784 | </div> |
| 785 | </SectionCard> |
| 786 | |
| 787 | <SectionCard title={ |
| 788 | <div className="flex flex-col gap-3 |
| 789 | <div className="flex flex-col gap-1.5 |
| 790 | <label className="text-xs font-medium text-foreground |
| 791 | <Select value={ |
| 792 | <SelectTrigger className=" |
| 793 | <SelectValue> |
| 794 | {sf.twitterCard === |
| 795 | </SelectValue> |
| 796 | </SelectTrigger> |
| 797 | <SelectContent> |
| 798 | <SelectItem value=" |
| 799 | <SelectItem value=" |
| 800 | </SelectContent> |
| 801 | </Select> |
| 802 | </div> |
| 803 | <div className="grid grid-cols-2 gap-3 |
| 804 | <div className="flex flex-col gap-1.5 |
| 805 | <label className="text-xs font-medium text-foreground |
| 806 | <input |
| 807 | className={inputClass()} |
| 808 | placeholder={t(" |
| 809 | value={sf.twitterSite |
| 810 | onChange={(e) |
| 811 | /> |
| 812 | </div> |
| 813 | <div className="flex flex-col gap-1.5 |
| 814 | <label className="text-xs font-medium text-foreground |
| 815 | <input |
| 816 | className={inputClass()} |
| 817 | placeholder={t(" |
| 818 | value={sf.twitterCreator |
| 819 | onChange={(e) |
| 820 | /> |
| 821 | </div> |
| 822 | </div> |
| 823 | </div> |
| 824 | </SectionCard> |
| 825 | |
| 826 | <div onClick={saveSeo |
| 827 | <SaveButton saving={ |
| 828 | </div> |
| 829 | </div> |
| 830 | |
| 831 | {/* Right: live preview */ |
| 832 | <div className="lg:sticky lg:top-4 flex flex-col gap-3 |
| 833 | <div className="rounded-xl border border-border bg-card |
| 834 | <div className="border-b border-border px-4 py-3 |
| 835 | <h2 className="text-sm font-semibold text-foreground |
| 836 | </div> |
| 837 | <div className="p-4 flex flex-col gap-3 |
| 838 | <div className="flex gap-1 |
| 839 | {(["discord", "google |
| 840 | <button |
| 841 | key={p} |
| 842 | type="button" |
| 843 | onClick={() => |
| 844 | className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${ |
| 845 | seoPreviewTab === p |
| 846 | ? "bg-foreground text-background" |
| 847 | : "text-muted-foreground hover:text-foreground hover:bg-muted" |
| 848 | }`} |
| 849 | > |
| 850 | {p === "discord |
| 851 | </button> |
| 852 | ))} |
| 853 | </div> |
| 854 | <div className="pt-1 |
| 855 | {seoPreviewTab === "discord |
| 856 | {seoPreviewTab === "google |
| 857 | {seoPreviewTab === "twitter |
| 858 | </div> |
| 859 | </div> |
| 860 | </div> |
| 861 | </div> |
| 862 | </div> |
| 863 | ); |
| 864 | })()} |
| 865 | |
| 866 | {tab === "auth |
| 867 | <> |
| 868 | <SectionCard title={ |
| 869 | <div className="flex items-center justify-between |
| 870 | <div> |
| 871 | <p className="text-sm font-medium text-foreground |
| 872 | <p className="text-xs text-muted-foreground mt-0.5 |
| 873 | </div> |
| 874 | <Toggle enabled={ |
| 875 | </div> |
| 876 | <div className="mt-4 |
| 877 | <SaveButton saving={ |
| 878 | </div> |
| 879 | </SectionCard> |
| 880 | |
| 881 | <SectionCard title={ |
| 882 | <div className="flex flex-col gap-2 |
| 883 | {/* GitHub */} |
| 884 | <div className="rounded-lg border border-border overflow-hidden |
| 885 | <button |
| 886 | type="button" |
| 887 | onClick={() => |
| 888 | className="flex w-full items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors" |
| 889 | > |
| 890 | <div className="flex items-center gap-2.5 |
| 891 | <Github className=" |
| 892 | <span className="text-sm font-medium text-foreground |
| 893 | {(data?.githubEnabled |
| 894 | <span className="rounded-full bg-green-500/10 px-2 py-0.5 text-[11px] font-medium text-green-600 dark:text-green-400 |
| 895 | )} |
| 896 | </div> |
| 897 | <div className="flex items-center gap-2 |
| 898 | <Toggle enabled={ |
| 899 | <ChevronDown className={ |
| 900 | </div> |
| 901 | </button> |
| 902 | {githubExpanded && ( |
| 903 | <div className="border-t border-border px-4 py-4 |
| 904 | <CallbackUrl appUrl={ |
| 905 | <div className="grid grid-cols-2 gap-3 mb-4 |
| 906 | <div className="flex flex-col gap-1.5 |
| 907 | <label className="text-xs font-medium text-foreground |
| 908 | <input |
| 909 | className={inputClass()} |
| 910 | placeholder="Iv1.xxxxxxxxxxxx" |
| 911 | value={github.clientId |
| 912 | onChange={(e) |
| 913 | /> |
| 914 | </div> |
| 915 | <div className="flex flex-col gap-1.5 |
| 916 | <label className="text-xs font-medium text-foreground |
| 917 | <input |
| 918 | className={inputClass()} |
| 919 | type="password" |
| 920 | placeholder={data?.githubClientSecretSet |
| 921 | value={github.clientSecret |
| 922 | onChange={(e) |
| 923 | autoComplete="new-password" |
| 924 | /> |
| 925 | </div> |
| 926 | </div> |
| 927 | <div onClick={saveGithub |
| 928 | <SaveButton saving={ |
| 929 | </div> |
| 930 | </div> |
| 931 | )} |
| 932 | </div> |
| 933 | |
| 934 | {/* Discord */} |
| 935 | <div className="rounded-lg border border-border overflow-hidden |
| 936 | <button |
| 937 | type="button" |
| 938 | onClick={() => |
| 939 | className="flex w-full items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors" |
| 940 | > |
| 941 | <div className="flex items-center gap-2.5 |
| 942 | <DiscordIcon className=" |
| 943 | <span className="text-sm font-medium text-foreground |
| 944 | {(data?.discordEnabled |
| 945 | <span className="rounded-full bg-green-500/10 px-2 py-0.5 text-[11px] font-medium text-green-600 dark:text-green-400 |
| 946 | )} |
| 947 | </div> |
| 948 | <div className="flex items-center gap-2 |
| 949 | <Toggle enabled={ |
| 950 | <ChevronDown className={ |
| 951 | </div> |
| 952 | </button> |
| 953 | {discordExpanded && ( |
| 954 | <div className="border-t border-border px-4 py-4 |
| 955 | <CallbackUrl appUrl={ |
| 956 | <div className="grid grid-cols-2 gap-3 mb-4 |
| 957 | <div className="flex flex-col gap-1.5 |
| 958 | <label className="text-xs font-medium text-foreground |
| 959 | <input |
| 960 | className={inputClass()} |
| 961 | placeholder="000000000000000000" |
| 962 | value={discord.clientId |
| 963 | onChange={(e) |
| 964 | /> |
| 965 | </div> |
| 966 | <div className="flex flex-col gap-1.5 |
| 967 | <label className="text-xs font-medium text-foreground |
| 968 | <input |
| 969 | className={inputClass()} |
| 970 | type="password" |
| 971 | placeholder={data?.discordClientSecretSet |
| 972 | value={discord.clientSecret |
| 973 | onChange={(e) |
| 974 | autoComplete="new-password" |
| 975 | /> |
| 976 | </div> |
| 977 | </div> |
| 978 | <div onClick={saveDiscord |
| 979 | <SaveButton saving={ |
| 980 | </div> |
| 981 | </div> |
| 982 | )} |
| 983 | </div> |
| 984 | </div> |
| 985 | </SectionCard> |
| 986 | </> |
| 987 | )} |
| 988 | |
| 989 | {tab === "email |
| 990 | <> |
| 991 | <SectionCard title={ |
| 992 | {smtpLoading || !smtp |
| 993 | <div className="py-4 text-center text-xs text-muted-foreground |
| 994 | ) : ( |
| 995 | <div className="flex flex-col gap-4 |
| 996 | {/* Enable toggle */ |
| 997 | <div className="flex items-center justify-between |
| 998 | <div> |
| 999 | <p className="text-sm font-medium text-foreground |
| 1000 | <p className="text-xs text-muted-foreground mt-0.5 |
| 1001 | </div> |
| 1002 | <Toggle enabled={ |
| 1003 | </div> |
| 1004 | |
| 1005 | {smtp.enabled && |
| 1006 | <> |
| 1007 | {/* Host + Port */ |
| 1008 | <div className="grid grid-cols-3 gap-3 |
| 1009 | <div className="col-span-2 flex flex-col gap-1.5 |
| 1010 | <label className="text-xs font-medium text-foreground |
| 1011 | <input |
| 1012 | className={inputClass()} |
| 1013 | placeholder="smtp.example.com" |
| 1014 | value={smtp.host |
| 1015 | onChange={(e) |
| 1016 | /> |
| 1017 | </div> |
| 1018 | <div className="flex flex-col gap-1.5 |
| 1019 | <label className="text-xs font-medium text-foreground |
| 1020 | <input |
| 1021 | className={inputClass()} |
| 1022 | placeholder="587" |
| 1023 | value={smtp.port |
| 1024 | onChange={(e) |
| 1025 | /> |
| 1026 | </div> |
| 1027 | </div> |
| 1028 | |
| 1029 | {/* User + Password */ |
| 1030 | <div className="grid grid-cols-2 gap-3 |
| 1031 | <div className="flex flex-col gap-1.5 |
| 1032 | <label className="text-xs font-medium text-foreground |
| 1033 | <input |
| 1034 | className={inputClass()} |
| 1035 | placeholder="user@example.com" |
| 1036 | value={smtp.user |
| 1037 | onChange={(e) |
| 1038 | /> |
| 1039 | </div> |
| 1040 | <div className="flex flex-col gap-1.5 |
| 1041 | <label className="text-xs font-medium text-foreground |
| 1042 | <input |
| 1043 | className={inputClass()} |
| 1044 | type="password" |
| 1045 | placeholder={smtpData?.passwordSet |
| 1046 | value={smtp.password |
| 1047 | onChange={(e) |
| 1048 | autoComplete="new-password" |
| 1049 | /> |
| 1050 | </div> |
| 1051 | </div> |
| 1052 | |
| 1053 | {/* From Email + From Name */ |
| 1054 | <div className="grid grid-cols-2 gap-3 |
| 1055 | <div className="flex flex-col gap-1.5 |
| 1056 | <label className="text-xs font-medium text-foreground |
| 1057 | <input |
| 1058 | className={inputClass()} |
| 1059 | placeholder="noreply@example.com" |
| 1060 | value={smtp.fromEmail |
| 1061 | onChange={(e) |
| 1062 | /> |
| 1063 | </div> |
| 1064 | <div className="flex flex-col gap-1.5 |
| 1065 | <label className="text-xs font-medium text-foreground |
| 1066 | <input |
| 1067 | className={inputClass()} |
| 1068 | placeholder="Struxa" |
| 1069 | value={smtp.fromName |
| 1070 | onChange={(e) |
| 1071 | /> |
| 1072 | </div> |
| 1073 | </div> |
| 1074 | |
| 1075 | {/* TLS toggle */ |
| 1076 | <div className="flex items-center justify-between |
| 1077 | <div> |
| 1078 | <p className="text-sm font-medium text-foreground |
| 1079 | <p className="text-xs text-muted-foreground mt-0.5 |
| 1080 | </div> |
| 1081 | <Toggle enabled={ |
| 1082 | </div> |
| 1083 | </> |
| 1084 | )} |
| 1085 | |
| 1086 | {/* Actions row */ |
| 1087 | <div className="flex items-center gap-3 flex-wrap |
| 1088 | <div onClick={saveSmtp |
| 1089 | <SaveButton saving={ |
| 1090 | </div> |
| 1091 | {smtp.enabled && |
| 1092 | <button |
| 1093 | type="button" |
| 1094 | onClick={() => |
| 1095 | className="flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-muted" |
| 1096 | > |
| 1097 | <Mail className=" |
| 1098 | </button> |
| 1099 | )} |
| 1100 | </div> |
| 1101 | |
| 1102 | {testResult && ( |
| 1103 | <div className={` |
| 1104 | {testResult.ok ? |
| 1105 | {testResult.message} |
| 1106 | </div> |
| 1107 | )} |
| 1108 | </div> |
| 1109 | )} |
| 1110 | </SectionCard> |
| 1111 | |
| 1112 | <SectionCard title={ |
| 1113 | <Link |
| 1114 | href="/admin/settings/email" |
| 1115 | className="flex items-center justify-between rounded-lg border border-border bg-background px-4 py-3 transition-colors hover:bg-muted group" |
| 1116 | > |
| 1117 | <div className="flex items-center gap-3 |
| 1118 | <Mail className=" |
| 1119 | <div> |
| 1120 | <p className="text-sm font-medium text-foreground |
| 1121 | <p className="text-xs text-muted-foreground mt-0.5 |
| 1122 | </div> |
| 1123 | </div> |
| 1124 | <ChevronRight className=" |
| 1125 | </Link> |
| 1126 | </SectionCard> |
| 1127 | </> |
| 1128 | )} |
| 1129 | |
| 1130 | {tab === "backups |
| 1131 | <> |
| 1132 | <SectionCard title={ |
| 1133 | {globalDestinationLoading ? ( |
| 1134 | <div className="py-4 text-center text-xs text-muted-foreground |
| 1135 | ) : ( |
| 1136 | <div className="flex flex-col gap-4 |
| 1137 | {!globalDestination && ( |
| 1138 | <p className="text-xs text-muted-foreground |
| 1139 | )} |
| 1140 | <BackupDestinationForm |
| 1141 | value={backupDestination ?? |
| 1142 | onChange={setBackupDestination} |
| 1143 | /> |
| 1144 | <div className="flex items-center gap-3 |
| 1145 | <div onClick={saveBackupDestination |
| 1146 | <SaveButton saving={ |
| 1147 | </div> |
| 1148 | {globalDestination && ( |
| 1149 | <button |
| 1150 | type="button" |
| 1151 | onClick={() => |
| 1152 | className="flex items-center gap-1.5 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-1.5 text-sm font-medium text-destructive transition-opacity hover:opacity-80" |
| 1153 | > |
| 1154 | {t("backupsGlobalReset") |
| 1155 | </button> |
| 1156 | )} |
| 1157 | </div> |
| 1158 | </div> |
| 1159 | )} |
| 1160 | </SectionCard> |
| 1161 | |
| 1162 | <SectionCard title={ |
| 1163 | <div className="flex flex-col gap-4 |
| 1164 | <CallbackUrl appUrl={ |
| 1165 | <div className="grid grid-cols-2 gap-3 |
| 1166 | <div className="flex flex-col gap-1.5 |
| 1167 | <label className="text-xs font-medium text-foreground |
| 1168 | <input |
| 1169 | className={inputClass()} |
| 1170 | placeholder={t(" |
| 1171 | value={gdrive.clientId |
| 1172 | onChange={(e) |
| 1173 | /> |
| 1174 | </div> |
| 1175 | <div className="flex flex-col gap-1.5 |
| 1176 | <label className="text-xs font-medium text-foreground |
| 1177 | <input |
| 1178 | className={inputClass()} |
| 1179 | type="password" |
| 1180 | placeholder={data?.googleDriveClientSecretSet |
| 1181 | value={gdrive.clientSecret |
| 1182 | onChange={(e) |
| 1183 | autoComplete="new-password" |
| 1184 | /> |
| 1185 | </div> |
| 1186 | </div> |
| 1187 | <div onClick={saveGdrive |
| 1188 | <SaveButton saving={ |
| 1189 | </div> |
| 1190 | </div> |
| 1191 | </SectionCard> |
| 1192 | |
| 1193 | <ConfirmDialog |
| 1194 | open={backupResetOpen} |
| 1195 | onOpenChange={setBackupResetOpen} |
| 1196 | title={t(" |
| 1197 | description={t(" |
| 1198 | confirmLabel={t(" |
| 1199 | destructive |
| 1200 | loading={clearGlobalDestinationMutation.isPending |
| 1201 | onConfirm={resetBackupDestination} |
| 1202 | /> |
| 1203 | </> |
| 1204 | )} |
| 1205 | |
| 1206 | {tab === "notifications |
| 1207 | <> |
| 1208 | <SectionCard title={ |
| 1209 | {notifLoading || !notif |
| 1210 | <div className="py-4 text-center text-xs text-muted-foreground |
| 1211 | ) : ( |
| 1212 | <div className="flex flex-col gap-2 |
| 1213 | <NotificationEndpointRow |
| 1214 | icon={<DiscordIcon |
| 1215 | name="Discord" |
| 1216 | enabled={notif.discordEnabled |
| 1217 | onToggle={(v) |
| 1218 | expanded={discordNotifExpanded} |
| 1219 | onExpandToggle={() => |
| 1220 | > |
| 1221 | <div className="flex flex-col gap-4 |
| 1222 | <div className="flex flex-col gap-1.5 |
| 1223 | <label className="text-xs font-medium text-foreground |
| 1224 | <input |
| 1225 | className={inputClass()} |
| 1226 | type="password" |
| 1227 | placeholder={notifData?.discordWebhookSet |
| 1228 | value={notif.discordWebhookUrl |
| 1229 | onChange={(e) |
| 1230 | autoComplete="new-password" |
| 1231 | /> |
| 1232 | </div> |
| 1233 | <div className="flex items-center gap-3 flex-wrap |
| 1234 | <div onClick={saveDiscordNotifications |
| 1235 | <SaveButton saving={ |
| 1236 | </div> |
| 1237 | <button |
| 1238 | type="button" |
| 1239 | disabled={testNotifMutation.isPending |
| 1240 | onClick={() => |
| 1241 | className="flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-muted disabled:opacity-50" |
| 1242 | > |
| 1243 | {testNotifMutation.isPending ? |
| 1244 | <><Loader2 className=" |
| 1245 | ) : ( |
| 1246 | <><Mail className=" |
| 1247 | )} |
| 1248 | </button> |
| 1249 | </div> |
| 1250 | </div> |
| 1251 | </NotificationEndpointRow> |
| 1252 | |
| 1253 | <NotificationEndpointRow |
| 1254 | icon={<Send |
| 1255 | name="Telegram" |
| 1256 | enabled={notif.telegramEnabled |
| 1257 | onToggle={(v) |
| 1258 | expanded={telegramNotifExpanded} |
| 1259 | onExpandToggle={() => |
| 1260 | > |
| 1261 | <div className="flex flex-col gap-4 |
| 1262 | <div className="flex flex-col gap-1.5 |
| 1263 | <label className="text-xs font-medium text-foreground |
| 1264 | <input |
| 1265 | className={inputClass()} |
| 1266 | type="password" |
| 1267 | placeholder={notifData?.telegramTokenSet |
| 1268 | value={notif.telegramBotToken |
| 1269 | onChange={(e) |
| 1270 | autoComplete="new-password" |
| 1271 | /> |
| 1272 | </div> |
| 1273 | <div className="flex flex-col gap-1.5 |
| 1274 | <label className="text-xs font-medium text-foreground |
| 1275 | <input |
| 1276 | className={inputClass()} |
| 1277 | placeholder={t(" |
| 1278 | value={notif.telegramChatId |
| 1279 | onChange={(e) |
| 1280 | /> |
| 1281 | </div> |
| 1282 | <div className="flex items-center gap-3 flex-wrap |
| 1283 | <div onClick={saveTelegramNotifications |
| 1284 | <SaveButton saving={ |
| 1285 | </div> |
| 1286 | <button |
| 1287 | type="button" |
| 1288 | disabled={testNotifMutation.isPending |
| 1289 | onClick={() => |
| 1290 | className="flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-muted disabled:opacity-50" |
| 1291 | > |
| 1292 | {testNotifMutation.isPending ? |
| 1293 | <><Loader2 className=" |
| 1294 | ) : ( |
| 1295 | <><Mail className=" |
| 1296 | )} |
| 1297 | </button> |
| 1298 | </div> |
| 1299 | </div> |
| 1300 | </NotificationEndpointRow> |
| 1301 | </div> |
| 1302 | )} |
| 1303 | </SectionCard> |
| 1304 | |
| 1305 | <SectionCard title={ |
| 1306 | {notifLoading || !notif |
| 1307 | <div className="py-4 text-center text-xs text-muted-foreground |
| 1308 | ) : ( |
| 1309 | <div className="flex flex-col gap-4 |
| 1310 | <div className="flex items-center justify-between |
| 1311 | <div> |
| 1312 | <p className="text-sm font-medium text-foreground |
| 1313 | <p className="text-xs text-muted-foreground mt-0.5 |
| 1314 | </div> |
| 1315 | <Toggle enabled={ |
| 1316 | </div> |
| 1317 | <div onClick={saveUserConfigNotifications |
| 1318 | <SaveButton saving={ |
| 1319 | </div> |
| 1320 | </div> |
| 1321 | )} |
| 1322 | </SectionCard> |
| 1323 | </> |
| 1324 | )} |
| 1325 | </motion.div> |
| 1326 | </div> |
| 1327 | |
| 1328 | {/* Test connection modal */} |
| 1329 | {testModalOpen && ( |
| 1330 | <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm |
| 1331 | <div className="w-full max-w-sm rounded-xl border border-border bg-card shadow-xl p-5 |
| 1332 | <div className="mb-4 |
| 1333 | <h3 className="text-sm font-semibold text-foreground |
| 1334 | <p className="text-xs text-muted-foreground mt-1 |
| 1335 | </div> |
| 1336 | <form onSubmit={( |
| 1337 | <input |
| 1338 | type="email" |
| 1339 | autoFocus |
| 1340 | value={testEmail} |
| 1341 | onChange={(e) |
| 1342 | placeholder={t(" |
| 1343 | className={inputClass()} |
| 1344 | /> |
| 1345 | <div className="mt-3 flex justify-end gap-2 |
| 1346 | <button |
| 1347 | type="button" |
| 1348 | onClick={() => |
| 1349 | className="rounded-lg border border-border bg-background px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" |
| 1350 | > |
| 1351 | {t("testConnectionCancel") |
| 1352 | </button> |
| 1353 | <button |
| 1354 | type="submit" |
| 1355 | disabled={!testEmail |
| 1356 | className="flex items-center gap-1.5 rounded-lg bg-foreground px-3 py-1.5 text-sm font-medium text-background transition-colors hover:opacity-90 disabled:opacity-50" |
| 1357 | > |
| 1358 | {testConnectionMutation.isPending ? |
| 1359 | <><Loader2 className=" |
| 1360 | ) : ( |
| 1361 | <><Mail className=" |
| 1362 | )} |
| 1363 | </button> |
| 1364 | </div> |
| 1365 | </form> |
| 1366 | </div> |
| 1367 | </div> |
| 1368 | )} |
| 1369 | </div> |
| 1370 | ); |
| 1371 | } |