Ghost

struxadotcloud/struxa

public
Fork0
CodeIssues0Pull requests0
1branch
struxaappswebsrcapp(admin)adminsettingspage.tsx
fix(notifications): harden senders, bound status worker, surface save errors3830548a month ago1371 lines · 65.2 KB
1"use client";
2
3import { useRef, useState
4import { motion }
5import { useQuery, useMutation
6import { useTranslations }
7import { Check, Upload
8import Link from "next/link
9import { toast }
10import { orpc, queryClient
11import { DiscordPreview, GooglePreview
12import { Select, SelectTrigger
13import { BackupDestinationForm, defaultBackupDestination
14import { ConfirmDialog }
15import type { BackupDestinationInput
16import { SENTINEL }
17
18function invalidateSettings() {
19 void queryClient.invalidateQueries(
20}
21
22function invalidateBackupDestinations() {
23 void queryClient.invalidateQueries(
24}
25
26function 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
38function 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
42function 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
56function 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
69function 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
154function 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
189function 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
197function 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
239const TABS = [
240type Tab
241
242export 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}
,
useEffect
}
from
"
react
"
;
from
"
motion/react
"
;
}
from
"
@tanstack/react-query
"
;
from
"
next-intl
"
;
,
X
,
Github
,
ChevronDown
,
RotateCcw
,
Copy
,
Mail
,
Loader2
,
ChevronRight
,
Send
}
from
"
lucide-react
"
;
"
;
from
"
sonner
"
;
}
from
"
@/utils/orpc
"
;
,
TwitterPreview
,
type
SeoPreviewData
}
from
"
./_components/seo-previews
"
;
,
SelectValue
,
SelectContent
,
SelectItem
}
from
"
@struxa/ui/components/select
"
;
}
from
"
@/components/backup-destination-form
"
;
from
"
@/components/confirm-dialog
"
;
}
from
"
@struxa/api/lib/backup-destinations
"
;
from
"
@struxa/api/lib/notification-constants
"
;
{
queryKey
:
orpc
.
settings
.
key
()
}
)
;
{
queryKey
:
orpc
.
backupDestinations
.
key
()
}
)
;
,
description
,
children
}
:
{
title
:
string
;
description
?
:
string
;
children
:
React
.
ReactNode
})
{
"
>
"
>
"
>
{
title
}
</
h2
>
className
=
"
text-xs text-muted-foreground mt-0.5
"
>
{
description
}
</
p
>
}
"
>
{
children
}
</
div
>
:
boolean
)
{
?
"
opacity-50 cursor-not-allowed
"
:
""
}
`
;
,
onChange
}
:
{
enabled
:
boolean
;
onChange
:
(
v
:
boolean
)
=>
void
})
{
=>
{
e
.
stopPropagation
()
;
onChange
(
!
enabled
)
;
}
}
?
"
bg-foreground
"
:
"
bg-input
"
}
`
}
pointer-events-none block h-4 w-4 rounded-full bg-background ring-0 transition-transform
${
enabled
?
"
translate-x-4
"
:
"
translate-x-0
"
}
`
}
/>
,
disabled
}
:
{
saving
:
boolean
;
disabled
?
:
boolean
})
{
useTranslations
(
"
common
"
)
;
disabled
}
(
"
saving
"
)
:
tc
(
"
save
"
)
}
;
;
;
;
|
null
|
undefined
;
;
|
null
;
;
:
File
)
=>
void
;
=>
void
;
useTranslations
(
"
common
"
)
;
useRef
<
HTMLInputElement
>
(
null
)
;
"
>
"
>
{
label
}
</
p
>
"
>
{
description
}{
aspectHint
?
`
${
aspectHint
}
`
:
""
}
Max
{
maxSizeMb
}
MB.
</
p
>
"
>
"
style
=
{
aspectHint
?
{
width
:
80
,
height
:
45
}
:
{
width
:
48
,
height
:
48
}
}
>
}
}
alt
=
{
label
}
className
=
"
max-h-full max-w-full object-contain
"
/>
{
width
:
80
,
height
:
45
}
:
{
width
:
48
,
height
:
48
}
}
h-4 w-4
"
/>
"
>
"
>
=>
{
e
.
target
.
files
?.
[
0
]
;
file
)
;
inputRef
.
current
.
value
=
""
;
inputRef
.
current
?.
click
()
}
?
"
pointer-events-none opacity-50
"
:
""
}
`
}
h-3.5 w-3.5
"
/>
(
"
uploading
"
)
:
currentUrl
?
tc
(
"
replace
"
)
:
tc
(
"
upload
"
)
}
h-3.5 w-3.5
"
/>
}
className
=
"
text-[11px] text-destructive
"
>
{
error
}
</
p
>
}
,
provider
,
path
}
:
{
appUrl
:
string
;
provider
:
string
;
path
?
:
string
})
{
useTranslations
(
"
admin.settings
"
)
;
useTranslations
(
"
common
"
)
;
setCopied
]
=
useState
(
false
)
;
appUrl
?
`
${
appUrl
}${
path
??
`
/api/auth/callback/
${
provider
}
`
}
`
:
null
;
return
;
writeText
(
url
)
.
then
(
()
=>
{
setCopied
(
false
)
,
2000
)
;
"
>
"
>
{
t
(
"
callbackUrlLabel
"
)
}
</
label
>
"
>
"
>
className
=
"
italic
"
>
{
t
(
"
callbackSetAppUrl
"
)
}
</
span
>
}
className
=
"
h-3 w-3
"
/>
{
tc
(
"
copied
"
)
}
</>
:
<><
Copy
className
=
"
h-3 w-3
"
/>
{
tc
(
"
copy
"
)
}
</>
}
}
:
{
className
?
:
string
})
{
}
viewBox
=
"
0 0 24 24
"
fill
=
"
currentColor
"
aria-hidden
=
"
true
"
>
"
/>
.
ReactNode
;
;
;
:
boolean
)
=>
void
;
;
=>
void
;
.
ReactNode
;
useTranslations
(
"
admin.settings
"
)
;
"
>
"
>
"
>
{
name
}
</
span
>
"
>
{
t
(
"
enabled
"
)
}
</
span
>
"
>
enabled
}
onChange
=
{
onToggle
}
/>
`
h-4 w-4 text-muted-foreground transition-transform
${
expanded
?
"
rotate-180
"
:
""
}
`
}
/>
className
=
"
border-t border-border px-4 py-4
"
>
{
children
}
</
div
>
}
"
branding
"
,
"
seo
"
,
"
auth
"
,
"
email
"
,
"
backups
"
,
"
notifications
"
]
as
const
;
=
(
typeof
TABS
)[
number
]
;
()
{
useTranslations
(
"
admin.settings
"
)
;
useTranslations
(
"
common
"
)
;
setTab
]
=
useState
<
Tab
>
(
"
branding
"
)
;
setNeedsRestart
]
=
useState
(
false
)
;
setGithubExpanded
]
=
useState
(
false
)
;
setDiscordExpanded
]
=
useState
(
false
)
;
isLoading
}
=
useQuery
(
orpc
.
settings
.
getConfig
.
queryOptions
())
;
useMutation
(
orpc
.
settings
.
set
.
mutationOptions
(
{
onSuccess
:
invalidateSettings
}
))
;
useMutation
(
orpc
.
settings
.
removeLogo
.
mutationOptions
(
{
{
invalidateSettings
()
;
toast
.
success
(
tc
(
"
saved
"
))
;
}
,
useMutation
(
orpc
.
settings
.
removeOgBanner
.
mutationOptions
(
{
{
invalidateSettings
()
;
toast
.
success
(
tc
(
"
saved
"
))
;
}
,
setLogoUploading
]
=
useState
(
false
)
;
setLogoError
]
=
useState
<
string
|
null
>
(
null
)
;
setGeneral
]
=
useState
<
{
appName
:
string
;
appUrl
:
string
}
|
null
>
(
null
)
;
setSeo
]
=
useState
<
{
;
metaKeywords
:
string
;
themeColor
:
string
;
;
ogDescription
:
string
;
ogSiteName
:
string
;
ogType
:
string
;
;
twitterSite
:
string
;
twitterCreator
:
string
;
>
(
null
)
;
setSeoPreviewTab
]
=
useState
<
"
discord
"
|
"
google
"
|
"
twitter
"
>
(
"
discord
"
)
;
setBannerUploading
]
=
useState
(
false
)
;
setBannerError
]
=
useState
<
string
|
null
>
(
null
)
;
setRegistration
]
=
useState
<
{
enabled
:
boolean
}
|
null
>
(
null
)
;
setGithub
]
=
useState
<
{
enabled
:
boolean
;
clientId
:
string
;
clientSecret
:
string
}
>
(
{
enabled
:
false
,
clientId
:
""
,
clientSecret
:
""
}
)
;
setDiscord
]
=
useState
<
{
enabled
:
boolean
;
clientId
:
string
;
clientSecret
:
string
}
>
(
{
enabled
:
false
,
clientId
:
""
,
clientSecret
:
""
}
)
;
setGdrive
]
=
useState
<
{
clientId
:
string
;
clientSecret
:
string
}
>
(
{
clientId
:
""
,
clientSecret
:
""
}
)
;
smtpData
,
isLoading
:
smtpLoading
}
=
useQuery
(
orpc
.
email
.
getSmtpConfig
.
queryOptions
())
;
useMutation
(
orpc
.
email
.
saveSmtpConfig
.
mutationOptions
())
;
useMutation
(
orpc
.
email
.
testConnection
.
mutationOptions
())
;
setSmtp
]
=
useState
<
{
;
host
:
string
;
port
:
string
;
user
:
string
;
password
:
string
;
;
fromName
:
string
;
secure
:
boolean
;
>
(
null
)
;
setTestResult
]
=
useState
<
{
ok
:
boolean
;
message
:
string
}
|
null
>
(
null
)
;
setTestModalOpen
]
=
useState
(
false
)
;
setTestEmail
]
=
useState
(
""
)
;
{
return
;
=>
prev
.
clientId
||
prev
.
enabled
?
prev
:
{
enabled
:
data
.
githubEnabled
,
clientId
:
data
.
githubClientId
,
clientSecret
:
""
}
)
;
=>
prev
.
clientId
||
prev
.
enabled
?
prev
:
{
enabled
:
data
.
discordEnabled
,
clientId
:
data
.
discordClientId
,
clientSecret
:
""
}
)
;
=>
prev
.
clientId
?
prev
:
{
clientId
:
data
.
googleDriveClientId
,
clientSecret
:
""
}
)
;
{
smtp
!==
null
)
return
;
,
,
,
,
,
,
,
smtp
])
;
globalDestination
,
isLoading
:
globalDestinationLoading
}
=
useQuery
(
orpc
.
backupDestinations
.
getGlobal
.
queryOptions
())
;
useMutation
(
orpc
.
backupDestinations
.
upsertGlobal
.
mutationOptions
(
{
{
invalidateBackupDestinations
()
;
toast
.
success
(
tc
(
"
saved
"
))
;
}
,
useMutation
(
orpc
.
backupDestinations
.
clearGlobal
.
mutationOptions
(
{
{
invalidateBackupDestinations
()
;
toast
.
success
(
tc
(
"
saved
"
))
;
}
,
setBackupDestination
]
=
useState
<
BackupDestinationInput
|
null
>
(
null
)
;
setBackupResetOpen
]
=
useState
(
false
)
;
{
backupDestination
!==
null
)
return
;
backupDestination
])
;
()
{
return
;
(
backupDestination
)
;
()
{
(
undefined
)
;
notifData
,
isLoading
:
notifLoading
}
=
useQuery
(
orpc
.
notifications
.
getAdminConfig
.
queryOptions
())
;
useMutation
(
orpc
.
notifications
.
saveAdminConfig
.
mutationOptions
(
{
{
void
queryClient
.
invalidateQueries
(
{
queryKey
:
orpc
.
notifications
.
key
()
}
)
;
}
,
useMutation
(
orpc
.
notifications
.
test
.
mutationOptions
())
;
setNotif
]
=
useState
<
{
;
;
;
;
;
;
>
(
null
)
;
setDiscordNotifExpanded
]
=
useState
(
false
)
;
setTelegramNotifExpanded
]
=
useState
(
false
)
;
{
notif
!==
null
)
return
;
,
,
,
,
notif
])
;
()
{
return
;
(
{
,
||
(
notifData
?.
discordWebhookSet
?
SENTINEL
:
""
)
,
=>
(
prev
?
{
...
prev
,
discordWebhookUrl
:
""
}
:
null
))
;
(
"
saved
"
))
;
{
(
"
notifSaveFailed
"
,
{
error
:
err
instanceof
Error
?
err
.
message
:
""
}
))
;
()
{
return
;
(
{
,
||
(
notifData
?.
telegramTokenSet
?
SENTINEL
:
""
)
,
,
=>
(
prev
?
{
...
prev
,
telegramBotToken
:
""
}
:
null
))
;
(
"
saved
"
))
;
{
(
"
notifSaveFailed
"
,
{
error
:
err
instanceof
Error
?
err
.
message
:
""
}
))
;
()
{
return
;
(
{
,
(
"
saved
"
))
;
{
(
"
notifSaveFailed
"
,
{
error
:
err
instanceof
Error
?
err
.
message
:
""
}
))
;
(
channel
:
"
discord
"
|
"
telegram
"
)
{
await
testNotifMutation
.
mutateAsync
(
{
channel
}
)
;
)
toast
.
success
(
t
(
"
notifTestSuccess
"
))
;
t
(
"
notifTestFailed
"
,
{
error
:
result
.
error
??
""
}
))
;
{
(
"
notifTestFailed
"
,
{
error
:
err
instanceof
Error
?
err
.
message
:
""
}
))
;
{
appName
:
data
?.
appName
??
"
Struxa
"
,
appUrl
:
data
?.
appUrl
??
""
}
;
{
??
""
,
??
""
,
??
""
,
??
""
,
??
""
,
??
""
,
??
"
website
"
,
??
"
summary_large_image
"
,
??
""
,
??
""
,
{
enabled
:
data
?.
registrationEnabled
??
true
}
;
()
{
generalForm
()
;
Promise
<
unknown
>
[]
=
[]
;
)
saves
.
push
(
setMutation
.
mutateAsync
(
{
key
:
"
app_name
"
,
value
:
f
.
appName
}
))
;
)
saves
.
push
(
setMutation
.
mutateAsync
(
{
key
:
"
app_url
"
,
value
:
f
.
appUrl
}
))
;
(
saves
)
;
)
setNeedsRestart
(
true
)
;
(
"
saved
"
))
;
()
{
seoForm
()
;
([
key
:
"
meta_description
"
,
value
:
f
.
metaDescription
}
)
,
key
:
"
meta_keywords
"
,
value
:
f
.
metaKeywords
}
)
,
key
:
"
theme_color
"
,
value
:
f
.
themeColor
}
)
,
key
:
"
og_title
"
,
value
:
f
.
ogTitle
}
)
,
key
:
"
og_description
"
,
value
:
f
.
ogDescription
}
)
,
key
:
"
og_site_name
"
,
value
:
f
.
ogSiteName
}
)
,
key
:
"
og_type
"
,
value
:
f
.
ogType
}
)
,
key
:
"
twitter_card
"
,
value
:
f
.
twitterCard
}
)
,
key
:
"
twitter_site
"
,
value
:
f
.
twitterSite
}
)
,
key
:
"
twitter_creator
"
,
value
:
f
.
twitterCreator
}
)
,
(
"
saved
"
))
;
()
{
(
{
key
:
"
registration_enabled
"
,
value
:
String
(
registrationForm
()
.
enabled
)
}
)
;
(
"
saved
"
))
;
()
{
Promise
<
unknown
>
[]
=
[
key
:
"
github_enabled
"
,
value
:
String
(
github
.
enabled
)
}
)
,
key
:
"
github_client_id
"
,
value
:
github
.
clientId
}
)
,
)
saves
.
push
(
setMutation
.
mutateAsync
(
{
key
:
"
github_client_secret
"
,
value
:
github
.
clientSecret
}
))
;
(
saves
)
;
=>
(
{
...
prev
,
clientSecret
:
""
}
))
;
(
"
saved
"
))
;
()
{
Promise
<
unknown
>
[]
=
[
key
:
"
discord_enabled
"
,
value
:
String
(
discord
.
enabled
)
}
)
,
key
:
"
discord_client_id
"
,
value
:
discord
.
clientId
}
)
,
)
saves
.
push
(
setMutation
.
mutateAsync
(
{
key
:
"
discord_client_secret
"
,
value
:
discord
.
clientSecret
}
))
;
(
saves
)
;
=>
(
{
...
prev
,
clientSecret
:
""
}
))
;
(
"
saved
"
))
;
()
{
Promise
<
unknown
>
[]
=
[
key
:
"
google_drive_client_id
"
,
value
:
gdrive
.
clientId
}
)
,
)
saves
.
push
(
setMutation
.
mutateAsync
(
{
key
:
"
google_drive_client_secret
"
,
value
:
gdrive
.
clientSecret
}
))
;
(
saves
)
;
=>
(
{
...
prev
,
clientSecret
:
""
}
))
;
(
"
saved
"
))
;
()
{
return
;
(
{
,
,
,
,
,
,
,
,
=>
prev
?
{
...
prev
,
password
:
""
}
:
null
)
;
(
"
saved
"
))
;
(
email
:
string
)
{
await
testConnectionMutation
.
mutateAsync
(
{
email
}
)
;
)
{
true
,
message
:
t
(
"
testConnectionSuccess
"
,
{
email
:
result
.
email
}
)
}
)
;
false
,
message
:
t
(
"
testConnectionFailed
"
,
{
error
:
result
.
error
??
"
Unknown error
"
}
)
}
)
;
(
:
boolean
)
=>
void
,
:
string
|
null
)
=>
void
,
,
new
FormData
()
;
"
,
file
)
;
await
fetch
(
endpoint
,
{
method
:
"
POST
"
,
body
:
formData
}
)
;
)
{
await
res
.
json
()
.
catch
(
()
=>
(
{}
))
;
{
error
?
:
string
}
)
.
error
??
"
Upload failed.
"
)
;
"
>
"
>
{
tc
(
"
loading
"
)
}
</
p
>
generalForm
()
;
registrationForm
()
;
data
?.
appNameFromEnv
&&
data
?.
appUrlFromEnv
;
"
>
mx-auto flex flex-col gap-4
${
tab
===
"
seo
"
?
"
max-w-5xl
"
:
"
max-w-2xl
"
}
`
}
>
"
>
{
t
(
"
title
"
)
}
</
h1
>
"
>
tab_item
)
=>
(
setTab
(
tab_item
)
}
"
?
t
(
"
tabBranding
"
)
seo
"
?
t
(
"
tabSeo
"
)
auth
"
?
t
(
"
tabAuth
"
)
email
"
?
t
(
"
tabEmail
"
)
backups
"
?
t
(
"
tabBackups
"
)
)
}
"
>
mt-0.5 h-4 w-4 shrink-0 text-amber-500
"
/>
"
>
"
>
{
t
(
"
restartRequired
"
)
}
</
p
>
"
>
}
setNeedsRestart
(
false
)
}
h-4 w-4
"
/>
tab
}
initial
=
{
{
opacity
:
0
}
}
animate
=
{
{
opacity
:
1
}
}
transition
=
{
{
duration
:
0.15
,
ease
:
"
easeOut
"
}
}
className
=
"
flex flex-col gap-4
"
>
"
&&
(
t
(
"
generalTitle
"
)
}
description
=
{
t
(
"
generalDesc
"
)
}
>
"
>
"
>
"
>
{
t
(
"
appNameLabel
"
)
}
</
label
>
?.
appNameFromEnv
)
}
}
}
=>
setGeneral
(
{
...
gf
,
appName
:
e
.
target
.
value
}
)
}
<
p
className
=
"
text-[11px] text-muted-foreground
"
>
{
t
(
"
appNameEnvNote
"
)
}
</
p
>
}
"
>
"
>
{
t
(
"
appUrlLabel
"
)
}
</
label
>
?.
appUrlFromEnv
)
}
appUrlPlaceholder
"
)
}
}
}
=>
setGeneral
(
{
...
gf
,
appUrl
:
e
.
target
.
value
}
)
}
<
p
className
=
"
text-[11px] text-muted-foreground
"
>
{
t
(
"
appUrlEnvNote
"
)
}
</
p
>
}
"
onClick
=
{
saveGeneral
}
>
setMutation
.
isPending
}
/>
t
(
"
logoTitle
"
)
}
description
=
{
t
(
"
logoDesc
"
)
}
>
logoLabel
"
)
}
logoHint
"
)
}
}
=>
handleUpload
(
"
/api/files/upload/logo
"
,
setLogoUploading
,
setLogoError
,
f
)
}
removeLogoMutation
.
mutate
(
undefined
)
}
"
&&
(
()
=>
{
seoForm
()
;
SeoPreviewData
=
{
||
data
?.
appName
||
"
Struxa
"
,
||
sf
.
metaDescription
||
`
${
data
?.
appName
||
"
Struxa
"
}
panel
`
,
||
data
?.
appName
||
"
Struxa
"
,
||
""
,
??
null
,
,
as
"
summary
"
|
"
summary_large_image
"
)
||
"
summary_large_image
"
,
,
"
>
}
"
>
t
(
"
seoBasicTitle
"
)
}
description
=
{
t
(
"
seoBasicDesc
"
)
}
>
"
>
"
>
"
>
{
t
(
"
metaDescLabel
"
)
}
</
label
>
()
}
resize-none
`
}
metaDescPlaceholder
"
)
}
}
=>
setSeo
(
{
...
sf
,
metaDescription
:
e
.
target
.
value
}
)
}
"
>
"
>
{
t
(
"
seoKeywordsLabel
"
)
}
</
label
>
seoKeywordsPlaceholder
"
)
}
}
=>
setSeo
(
{
...
sf
,
metaKeywords
:
e
.
target
.
value
}
)
}
"
>
{
t
(
"
seoKeywordsHint
"
)
}
</
p
>
"
>
"
>
{
t
(
"
seoThemeColorLabel
"
)
}
</
label
>
"
>
||
"
#5865F2
"
}
=>
setSeo
(
{
...
sf
,
themeColor
:
e
.
target
.
value
}
)
}
()
}
flex-1
`
}
seoThemeColorPlaceholder
"
)
}
}
=>
setSeo
(
{
...
sf
,
themeColor
:
e
.
target
.
value
}
)
}
"
>
{
t
(
"
seoThemeColorHint
"
)
}
</
p
>
t
(
"
seoOgSectionTitle
"
)
}
description
=
{
t
(
"
seoOgSectionDesc
"
)
}
>
"
>
"
>
"
>
"
>
{
t
(
"
seoOgTitleLabel
"
)
}
</
label
>
seoOgTitlePlaceholder
"
)
}
}
=>
setSeo
(
{
...
sf
,
ogTitle
:
e
.
target
.
value
}
)
}
"
>
"
>
{
t
(
"
seoOgSiteNameLabel
"
)
}
</
label
>
seoOgSiteNamePlaceholder
"
)
}
}
=>
setSeo
(
{
...
sf
,
ogSiteName
:
e
.
target
.
value
}
)
}
"
>
"
>
{
t
(
"
seoOgDescLabel
"
)
}
</
label
>
()
}
resize-none
`
}
seoOgDescPlaceholder
"
)
}
}
=>
setSeo
(
{
...
sf
,
ogDescription
:
e
.
target
.
value
}
)
}
"
>
"
>
{
t
(
"
seoOgTypeLabel
"
)
}
</
label
>
sf
.
ogType
||
"
website
"
}
onValueChange
=
{
(
v
)
=>
setSeo
(
{
...
sf
,
ogType
:
v
??
"
website
"
}
)
}
>
h-[30px] text-sm
"
>
"
article
"
?
t
(
"
seoOgTypeArticle
"
)
:
sf
.
ogType
===
"
profile
"
?
t
(
"
seoOgTypeProfile
"
)
:
t
(
"
seoOgTypeWebsite
"
)
}
website
"
>
{
t
(
"
seoOgTypeWebsite
"
)
}
</
SelectItem
>
article
"
>
{
t
(
"
seoOgTypeArticle
"
)
}
</
SelectItem
>
profile
"
>
{
t
(
"
seoOgTypeProfile
"
)
}
</
SelectItem
>
"
>
seoOgImageTitle
"
)
}
seoOgImageDesc
"
)
}
bannerAspectHint
"
)
}
}
=>
handleUpload
(
"
/api/files/upload/og-banner
"
,
setBannerUploading
,
setBannerError
,
f
)
}
removeOgBannerMutation
.
mutate
(
undefined
)
}
t
(
"
seoTwitterSectionTitle
"
)
}
description
=
{
t
(
"
seoTwitterSectionDesc
"
)
}
>
"
>
"
>
"
>
{
t
(
"
seoTwitterCardLabel
"
)
}
</
label
>
sf
.
twitterCard
||
"
summary_large_image
"
}
onValueChange
=
{
(
v
)
=>
setSeo
(
{
...
sf
,
twitterCard
:
v
??
"
summary_large_image
"
}
)
}
>
h-[30px] text-sm
"
>
"
summary
"
?
t
(
"
seoTwitterCardSummary
"
)
:
t
(
"
seoTwitterCardLarge
"
)
}
summary_large_image
"
>
{
t
(
"
seoTwitterCardLarge
"
)
}
</
SelectItem
>
summary
"
>
{
t
(
"
seoTwitterCardSummary
"
)
}
</
SelectItem
>
"
>
"
>
"
>
{
t
(
"
seoTwitterSiteLabel
"
)
}
</
label
>
seoTwitterSitePlaceholder
"
)
}
}
=>
setSeo
(
{
...
sf
,
twitterSite
:
e
.
target
.
value
}
)
}
"
>
"
>
{
t
(
"
seoTwitterCreatorLabel
"
)
}
</
label
>
seoTwitterCreatorPlaceholder
"
)
}
}
=>
setSeo
(
{
...
sf
,
twitterCreator
:
e
.
target
.
value
}
)
}
}
>
setMutation
.
isPending
}
/>
}
"
>
"
>
"
>
"
>
{
t
(
"
seoPreviewTitle
"
)
}
</
h2
>
"
>
"
>
"
,
"
twitter
"
]
as
const
)
.
map
(
(
p
)
=>
(
setSeoPreviewTab
(
p
)
}
"
?
t
(
"
seoPreviewDiscord
"
)
:
p
===
"
google
"
?
t
(
"
seoPreviewGoogle
"
)
:
t
(
"
seoPreviewTwitter
"
)
}
"
>
"
&&
<
DiscordPreview
data
=
{
previewData
}
/>
}
"
&&
<
GooglePreview
data
=
{
previewData
}
/>
}
"
&&
<
TwitterPreview
data
=
{
previewData
}
/>
}
"
&&
(
t
(
"
registrationTitle
"
)
}
>
"
>
"
>
{
t
(
"
allowRegistrationLabel
"
)
}
</
p
>
"
>
{
t
(
"
allowRegistrationDesc
"
)
}
</
p
>
rf
.
enabled
}
onChange
=
{
(
v
)
=>
setRegistration
(
{
enabled
:
v
}
)
}
/>
"
onClick
=
{
saveRegistration
}
>
setMutation
.
isPending
}
/>
t
(
"
socialLoginTitle
"
)
}
description
=
{
t
(
"
socialLoginDesc
"
)
}
>
"
>
"
>
setGithubExpanded
(
(
v
)
=>
!
v
)
}
"
>
h-4 w-4 text-foreground
"
/>
"
>
GitHub
</
span
>
||
github
.
enabled
)
&&
(
"
>
{
t
(
"
enabled
"
)
}
</
span
>
"
>
github
.
enabled
}
onChange
=
{
(
v
)
=>
setGithub
(
{
...
github
,
enabled
:
v
}
)
}
/>
`
h-4 w-4 text-muted-foreground transition-transform
${
githubExpanded
?
"
rotate-180
"
:
""
}
`
}
/>
"
>
data
?.
appUrl
??
""
}
provider
=
"
github
"
/>
"
>
"
>
"
>
{
t
(
"
clientIdLabel
"
)
}
</
label
>
}
=>
setGithub
(
{
...
github
,
clientId
:
e
.
target
.
value
}
)
}
"
>
"
>
{
t
(
"
clientSecretLabel
"
)
}
</
label
>
?
t
(
"
clientSecretSet
"
)
:
t
(
"
enterSecret
"
)
}
}
=>
setGithub
(
{
...
github
,
clientSecret
:
e
.
target
.
value
}
)
}
}
>
setMutation
.
isPending
}
/>
"
>
setDiscordExpanded
(
(
v
)
=>
!
v
)
}
"
>
h-4 w-4 text-[#5865F2]
"
/>
"
>
Discord
</
span
>
||
discord
.
enabled
)
&&
(
"
>
{
t
(
"
enabled
"
)
}
</
span
>
"
>
discord
.
enabled
}
onChange
=
{
(
v
)
=>
setDiscord
(
{
...
discord
,
enabled
:
v
}
)
}
/>
`
h-4 w-4 text-muted-foreground transition-transform
${
discordExpanded
?
"
rotate-180
"
:
""
}
`
}
/>
"
>
data
?.
appUrl
??
""
}
provider
=
"
discord
"
/>
"
>
"
>
"
>
{
t
(
"
clientIdLabel
"
)
}
</
label
>
}
=>
setDiscord
(
{
...
discord
,
clientId
:
e
.
target
.
value
}
)
}
"
>
"
>
{
t
(
"
clientSecretLabel
"
)
}
</
label
>
?
t
(
"
clientSecretSet
"
)
:
t
(
"
enterSecret
"
)
}
}
=>
setDiscord
(
{
...
discord
,
clientSecret
:
e
.
target
.
value
}
)
}
}
>
setMutation
.
isPending
}
/>
"
&&
(
t
(
"
smtpTitle
"
)
}
description
=
{
t
(
"
smtpDesc
"
)
}
>
?
(
"
>
{
tc
(
"
loading
"
)
}
</
div
>
"
>
}
"
>
"
>
{
t
(
"
smtpEnabled
"
)
}
</
p
>
"
>
{
t
(
"
smtpEnabledDesc
"
)
}
</
p
>
smtp
.
enabled
}
onChange
=
{
(
v
)
=>
setSmtp
(
{
...
smtp
,
enabled
:
v
}
)
}
/>
(
}
"
>
"
>
"
>
{
t
(
"
smtpHost
"
)
}
</
label
>
}
=>
setSmtp
(
{
...
smtp
,
host
:
e
.
target
.
value
}
)
}
"
>
"
>
{
t
(
"
smtpPort
"
)
}
</
label
>
}
=>
setSmtp
(
{
...
smtp
,
port
:
e
.
target
.
value
}
)
}
}
"
>
"
>
"
>
{
t
(
"
smtpUser
"
)
}
</
label
>
}
=>
setSmtp
(
{
...
smtp
,
user
:
e
.
target
.
value
}
)
}
"
>
"
>
{
t
(
"
smtpPassword
"
)
}
</
label
>
?
t
(
"
smtpPasswordSet
"
)
:
""
}
}
=>
setSmtp
(
{
...
smtp
,
password
:
e
.
target
.
value
}
)
}
}
"
>
"
>
"
>
{
t
(
"
smtpFromEmail
"
)
}
</
label
>
}
=>
setSmtp
(
{
...
smtp
,
fromEmail
:
e
.
target
.
value
}
)
}
"
>
"
>
{
t
(
"
smtpFromName
"
)
}
</
label
>
}
=>
setSmtp
(
{
...
smtp
,
fromName
:
e
.
target
.
value
}
)
}
}
"
>
"
>
{
t
(
"
smtpSecure
"
)
}
</
p
>
"
>
{
t
(
"
smtpSecureDesc
"
)
}
</
p
>
smtp
.
secure
}
onChange
=
{
(
v
)
=>
setSmtp
(
{
...
smtp
,
secure
:
v
}
)
}
/>
}
"
>
}
>
saveSmtpMutation
.
isPending
}
/>
(
{
setTestEmail
(
""
)
;
setTestResult
(
null
)
;
setTestModalOpen
(
true
)
;
}
}
h-3.5 w-3.5
"
/>
{
t
(
"
testConnection
"
)
}
flex items-start gap-2 rounded-lg px-3 py-2 text-sm
${
testResult
.
ok
?
"
bg-green-500/10 border border-green-500/30 text-green-700 dark:text-green-400
"
:
"
bg-destructive/10 border border-destructive/30 text-destructive
"
}
`
}
>
<
Check
className
=
"
mt-0.5 h-4 w-4 shrink-0
"
/>
:
<
X
className
=
"
mt-0.5 h-4 w-4 shrink-0
"
/>
}
t
(
"
templatesTitle
"
)
}
description
=
{
t
(
"
templatesDesc
"
)
}
>
"
>
h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors
"
/>
"
>
{
t
(
"
editTemplates
"
)
}
</
p
>
"
>
{
t
(
"
editTemplatesDesc
"
)
}
</
p
>
h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors shrink-0
"
/>
"
&&
(
t
(
"
backupsGlobalTitle
"
)
}
description
=
{
t
(
"
backupsGlobalDesc
"
)
}
>
"
>
{
tc
(
"
loading
"
)
}
</
div
>
"
>
"
>
{
t
(
"
backupsGlobalNone
"
)
}
</
p
>
defaultBackupDestination
(
"
s3
"
)
}
"
>
}
>
upsertGlobalDestinationMutation
.
isPending
}
/>
setBackupResetOpen
(
true
)
}
}
t
(
"
gdriveTitle
"
)
}
description
=
{
t
(
"
gdriveDesc
"
)
}
>
"
>
data
?.
appUrl
??
""
}
provider
=
"
gdrive
"
path
=
"
/api/account/google-drive/callback
"
/>
"
>
"
>
"
>
{
t
(
"
gdriveClientIdLabel
"
)
}
</
label
>
gdriveClientIdPlaceholder
"
)
}
}
=>
setGdrive
(
{
...
gdrive
,
clientId
:
e
.
target
.
value
}
)
}
"
>
"
>
{
t
(
"
gdriveClientSecretLabel
"
)
}
</
label
>
?
t
(
"
clientSecretSet
"
)
:
t
(
"
enterSecret
"
)
}
}
=>
setGdrive
(
{
...
gdrive
,
clientSecret
:
e
.
target
.
value
}
)
}
}
>
setMutation
.
isPending
}
/>
backupsGlobalReset
"
)
}
backupsGlobalResetDesc
"
)
}
backupsGlobalReset
"
)
}
}
"
&&
(
t
(
"
notifEndpointsTitle
"
)
}
description
=
{
t
(
"
notifEndpointsDesc
"
)
}
>
?
(
"
>
{
tc
(
"
loading
"
)
}
</
div
>
"
>
className
=
"
h-4 w-4 text-[#5865F2]
"
/>
}
}
=>
setNotif
(
{
...
notif
,
discordEnabled
:
v
}
)
}
setDiscordNotifExpanded
(
(
v
)
=>
!
v
)
}
"
>
"
>
"
>
{
t
(
"
notifDiscordUrlLabel
"
)
}
</
label
>
?
t
(
"
clientSecretSet
"
)
:
t
(
"
notifDiscordUrlPlaceholder
"
)
}
}
=>
setNotif
(
{
...
notif
,
discordWebhookUrl
:
e
.
target
.
value
}
)
}
"
>
}
>
saveNotifMutation
.
isPending
}
/>
}
testNotification
(
"
discord
"
)
}
(
h-3.5 w-3.5 animate-spin
"
/>
{
t
(
"
notifTesting
"
)
}
</>
h-3.5 w-3.5
"
/>
{
t
(
"
notifTest
"
)
}
</>
className
=
"
h-4 w-4 text-[#26A5E4]
"
/>
}
}
=>
setNotif
(
{
...
notif
,
telegramEnabled
:
v
}
)
}
setTelegramNotifExpanded
(
(
v
)
=>
!
v
)
}
"
>
"
>
"
>
{
t
(
"
notifTelegramTokenLabel
"
)
}
</
label
>
?
t
(
"
clientSecretSet
"
)
:
t
(
"
notifTelegramTokenPlaceholder
"
)
}
}
=>
setNotif
(
{
...
notif
,
telegramBotToken
:
e
.
target
.
value
}
)
}
"
>
"
>
{
t
(
"
notifTelegramChatIdLabel
"
)
}
</
label
>
notifTelegramChatIdPlaceholder
"
)
}
}
=>
setNotif
(
{
...
notif
,
telegramChatId
:
e
.
target
.
value
}
)
}
"
>
}
>
saveNotifMutation
.
isPending
}
/>
}
testNotification
(
"
telegram
"
)
}
(
h-3.5 w-3.5 animate-spin
"
/>
{
t
(
"
notifTesting
"
)
}
</>
h-3.5 w-3.5
"
/>
{
t
(
"
notifTest
"
)
}
</>
t
(
"
notifUserConfigTitle
"
)
}
description
=
{
t
(
"
notifUserConfigDesc
"
)
}
>
?
(
"
>
{
tc
(
"
loading
"
)
}
</
div
>
"
>
"
>
"
>
{
t
(
"
notifUserConfigEnabled
"
)
}
</
p
>
"
>
{
t
(
"
notifUserConfigEnabledDesc
"
)
}
</
p
>
notif
.
userConfigEnabled
}
onChange
=
{
(
v
)
=>
setNotif
(
{
...
notif
,
userConfigEnabled
:
v
}
)
}
/>
}
>
saveNotifMutation
.
isPending
}
/>
"
onClick
=
{
()
=>
setTestModalOpen
(
false
)
}
>
"
onClick
=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
"
>
"
>
{
t
(
"
testConnectionModalTitle
"
)
}
</
h3
>
"
>
{
t
(
"
testConnectionModalDesc
"
)
}
</
p
>
e
)
=>
{
e
.
preventDefault
()
;
void
testSmtp
(
testEmail
)
;
}
}
>
=>
setTestEmail
(
e
.
target
.
value
)
}
testConnectionEmailPlaceholder
"
)
}
"
>
setTestModalOpen
(
false
)
}
}
||
testConnectionMutation
.
isPending
}
(
h-3.5 w-3.5 animate-spin
"
/>
{
t
(
"
testConnectionTesting
"
)
}
</>
h-3.5 w-3.5
"
/>
{
t
(
"
testConnectionSend
"
)
}
</>