@naviyra/cloud-panel 0.0.3-beta → 0.0.3-beta3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { useEffect, useState } from "react";
2
+ import { useEffect, useRef, useState } from "react";
3
3
  import {
4
4
  Check,
5
5
  Cloud,
@@ -10,6 +10,8 @@ import {
10
10
  Trash2,
11
11
  } from "lucide-react";
12
12
  import { EmailSkeleton } from "./skeleton";
13
+ import { useConfirm } from "./confirm";
14
+ import { useToast } from "./toast";
13
15
 
14
16
  async function api(url, options = {}) {
15
17
  const response = await fetch(`/api/${url}`, {
@@ -29,9 +31,15 @@ function when(value) {
29
31
  return value ? new Date(value).toLocaleString() : "—";
30
32
  }
31
33
  export function CloudflareConnect({ initial, onReady, canWrite }) {
34
+ const toast = useToast();
35
+ const seen = useRef("");
32
36
  const [progress, setProgress] = useState(initial?.login || { state: "idle" });
33
- const [error, setError] = useState(initial?.error || "");
34
37
  const [busy, setBusy] = useState(false);
38
+ useEffect(() => {
39
+ if (!initial?.error || initial.error === seen.current) return;
40
+ seen.current = initial.error;
41
+ toast.failed(initial.error);
42
+ }, [initial?.error, toast]);
35
43
  useEffect(() => {
36
44
  if (progress.state !== "running") return undefined;
37
45
  const timer = setInterval(async () => {
@@ -42,16 +50,22 @@ export function CloudflareConnect({ initial, onReady, canWrite }) {
42
50
  ]);
43
51
  setProgress(current);
44
52
  if (status.registered) onReady(status);
45
- else if (current.state === "error") setError(current.error);
53
+ else if (current.state === "error" && current.error !== seen.current) {
54
+ seen.current = current.error;
55
+ toast.failed(current.error);
56
+ }
46
57
  } catch (e) {
47
- setError(e.message);
58
+ if (seen.current !== e.message) {
59
+ seen.current = e.message;
60
+ toast.failed(e.message);
61
+ }
48
62
  }
49
63
  }, 1000);
50
64
  return () => clearInterval(timer);
51
- }, [progress.state, onReady]);
65
+ }, [progress.state, onReady, toast]);
52
66
  async function connect() {
53
67
  setBusy(true);
54
- setError("");
68
+ seen.current = "";
55
69
  try {
56
70
  const started = await api("cloudflare/login", { method: "POST" });
57
71
  setProgress(started);
@@ -59,8 +73,9 @@ export function CloudflareConnect({ initial, onReady, canWrite }) {
59
73
  onReady(await api("cloudflare/status"));
60
74
  return;
61
75
  }
76
+ toast.progress("Waiting for Cloudflare approval.");
62
77
  } catch (e) {
63
- setError(e.message);
78
+ toast.failed(e.message);
64
79
  } finally {
65
80
  setBusy(false);
66
81
  }
@@ -96,7 +111,6 @@ export function CloudflareConnect({ initial, onReady, canWrite }) {
96
111
  </li>
97
112
  ))}
98
113
  </ol>
99
- {error && <div className="error">{error}</div>}
100
114
  {progress.log && <pre className="connect-log">{progress.log}</pre>}
101
115
  {progress.url && (
102
116
  <a className="primary connect-link" href={progress.url} target="_blank" rel="noreferrer">
@@ -119,22 +133,20 @@ export function CloudflareConnect({ initial, onReady, canWrite }) {
119
133
  );
120
134
  }
121
135
  export function DnsScreen({ canWrite }) {
136
+ const toast = useToast();
122
137
  const [data, setData] = useState(null);
123
138
  const [detail, setDetail] = useState(null);
124
139
  const [draft, setDraft] = useState(null);
125
- const [error, setError] = useState("");
126
- const [notice, setNotice] = useState("");
127
140
  const [query, setQuery] = useState("");
128
141
  const [type, setType] = useState("all");
129
142
  const [loading, setLoading] = useState(true);
130
143
  const [saving, setSaving] = useState(false);
131
144
  async function load() {
132
145
  setLoading(true);
133
- setError("");
134
146
  try {
135
147
  setData(await api("cloudflare/dns"));
136
148
  } catch (e) {
137
- setError(e.message);
149
+ toast.failed(e.message);
138
150
  } finally {
139
151
  setLoading(false);
140
152
  }
@@ -153,23 +165,19 @@ export function DnsScreen({ canWrite }) {
153
165
  priority: record.priority ?? 10,
154
166
  comment: record.comment || "",
155
167
  });
156
- setNotice("");
157
- setError("");
158
168
  }
159
169
  async function open(id) {
160
170
  setDraft(null);
161
- setError("");
162
171
  try {
163
172
  setDetail(await api(`cloudflare/dns/${id}`));
164
173
  } catch (e) {
165
- setError(e.message);
174
+ toast.failed(e.message);
166
175
  }
167
176
  }
168
177
  async function saveEdit(event) {
169
178
  event.preventDefault();
170
179
  if (!detail) return;
171
180
  setSaving(true);
172
- setError("");
173
181
  try {
174
182
  const saved = await api(`cloudflare/dns/${detail.id}`, {
175
183
  method: "PUT",
@@ -187,9 +195,9 @@ export function DnsScreen({ canWrite }) {
187
195
  }
188
196
  : current,
189
197
  );
190
- setNotice("DNS record updated.");
198
+ toast.success("DNS record updated.");
191
199
  } catch (e) {
192
- setError(e.message);
200
+ toast.failed(e.message);
193
201
  } finally {
194
202
  setSaving(false);
195
203
  }
@@ -216,8 +224,6 @@ export function DnsScreen({ canWrite }) {
216
224
  Refresh
217
225
  </button>
218
226
  </div>
219
- {error && <div className="error">{error}</div>}
220
- {notice && <div className="notice">{notice}</div>}
221
227
  <section className="panel">
222
228
  {loading && data && (
223
229
  <div
@@ -484,9 +490,11 @@ export function DnsScreen({ canWrite }) {
484
490
  );
485
491
  }
486
492
  export function EmailScreen({ canWrite }) {
493
+ const confirm = useConfirm();
494
+ const toast = useToast();
495
+ const seen = useRef("");
496
+ const addressError = useRef("");
487
497
  const [data, setData] = useState(null);
488
- const [error, setError] = useState("");
489
- const [notice, setNotice] = useState("");
490
498
  const [busy, setBusy] = useState(false);
491
499
  const [loading, setLoading] = useState(true);
492
500
  const [authorized, setAuthorized] = useState(false);
@@ -494,11 +502,10 @@ export function EmailScreen({ canWrite }) {
494
502
  const [matcher, setMatcher] = useState("literal");
495
503
  async function load() {
496
504
  setLoading(true);
497
- setError("");
498
505
  try {
499
506
  setData(await api("cloudflare/email"));
500
507
  } catch (e) {
501
- setError(e.message);
508
+ toast.failed(e.message);
502
509
  } finally {
503
510
  setLoading(false);
504
511
  }
@@ -514,7 +521,7 @@ export function EmailScreen({ canWrite }) {
514
521
  })
515
522
  .catch((e) => {
516
523
  if (!stop) {
517
- setError(e.message);
524
+ toast.failed(e.message);
518
525
  setLoading(false);
519
526
  }
520
527
  });
@@ -522,6 +529,12 @@ export function EmailScreen({ canWrite }) {
522
529
  stop = true;
523
530
  };
524
531
  }, []);
532
+ useEffect(() => {
533
+ const message = data?.address_error || "";
534
+ if (message === addressError.current) return;
535
+ addressError.current = message;
536
+ if (message) toast.failed(message);
537
+ }, [data?.address_error, toast]);
525
538
  useEffect(() => {
526
539
  if (apiProgress.state !== "running") return undefined;
527
540
  const timer = setInterval(async () => {
@@ -530,41 +543,45 @@ export function EmailScreen({ canWrite }) {
530
543
  setApiProgress(current);
531
544
  if (current.state === "done" || current.skipped) {
532
545
  setAuthorized(true);
533
- setNotice("Cloudflare API authorized.");
546
+ toast.success("Cloudflare API authorized.");
534
547
  await load();
535
- } else if (current.state === "error") setError(current.error);
548
+ } else if (current.state === "error" && current.error !== seen.current) {
549
+ seen.current = current.error || "";
550
+ if (current.error) toast.failed(current.error);
551
+ }
536
552
  } catch (e) {
537
- setError(e.message);
553
+ if (seen.current !== e.message) {
554
+ seen.current = e.message;
555
+ toast.failed(e.message);
556
+ }
538
557
  }
539
558
  }, 1000);
540
559
  return () => clearInterval(timer);
541
560
  }, [apiProgress.state]);
542
561
  async function authorize() {
543
562
  setBusy(true);
544
- setError("");
563
+ seen.current = "";
545
564
  try {
546
565
  const started = await api("cloudflare/api-login", { method: "POST" });
547
566
  setApiProgress(started);
548
567
  if (started.skipped || started.registered) {
549
568
  setAuthorized(true);
550
- setNotice("Cloudflare API authorized.");
569
+ toast.success("Cloudflare API authorized.");
551
570
  await load();
552
- }
571
+ } else toast.progress("Waiting for Wrangler approval.");
553
572
  } catch (e) {
554
- setError(e.message);
573
+ toast.failed(e.message);
555
574
  } finally {
556
575
  setBusy(false);
557
576
  }
558
577
  }
559
578
  async function run(action) {
560
579
  setBusy(true);
561
- setError("");
562
- setNotice("");
563
580
  try {
564
581
  await action();
565
582
  await load();
566
583
  } catch (e) {
567
- setError(e.message);
584
+ toast.failed(e.message);
568
585
  } finally {
569
586
  setBusy(false);
570
587
  }
@@ -588,7 +605,7 @@ export function EmailScreen({ canWrite }) {
588
605
  });
589
606
  form.reset();
590
607
  setMatcher("literal");
591
- setNotice("Routing rule created.");
608
+ toast.success("Routing rule created.");
592
609
  });
593
610
  }
594
611
  return (
@@ -609,8 +626,6 @@ export function EmailScreen({ canWrite }) {
609
626
  Refresh
610
627
  </button>
611
628
  </div>
612
- {error && <div className="error">{error}</div>}
613
- {notice && <div className="notice">{notice}</div>}
614
629
  {!authorized && (
615
630
  <section className="panel connect-panel">
616
631
  <div className="section-heading">
@@ -673,7 +688,7 @@ export function EmailScreen({ canWrite }) {
673
688
  method: "POST",
674
689
  body: JSON.stringify({ enabled: !data.settings.enabled }),
675
690
  });
676
- setNotice(
691
+ toast.success(
677
692
  data.settings.enabled
678
693
  ? "Email routing disabled."
679
694
  : "Email routing enabled. Cloudflare adds the required MX and SPF records.",
@@ -688,7 +703,6 @@ export function EmailScreen({ canWrite }) {
688
703
  <div className="section-heading">
689
704
  <h2>Destination addresses</h2>
690
705
  </div>
691
- {data.address_error && <div className="error">{data.address_error}</div>}
692
706
  {canWrite && (
693
707
  <form
694
708
  className="inline-form address-form"
@@ -702,7 +716,7 @@ export function EmailScreen({ canWrite }) {
702
716
  body: JSON.stringify({ email }),
703
717
  });
704
718
  form.reset();
705
- setNotice("Verification email sent to that destination.");
719
+ toast.success("Verification email sent to that destination.");
706
720
  });
707
721
  }}
708
722
  >
@@ -735,8 +749,15 @@ export function EmailScreen({ canWrite }) {
735
749
  <button
736
750
  className="text-button danger-text"
737
751
  disabled={busy}
738
- onClick={() => {
739
- if (window.confirm(`Remove ${address.email}?`))
752
+ onClick={async () => {
753
+ if (
754
+ await confirm({
755
+ title: `Remove ${address.email}?`,
756
+ message:
757
+ "This address will stop receiving forwarded mail.",
758
+ confirmLabel: "Remove",
759
+ })
760
+ )
740
761
  run(() =>
741
762
  api(`cloudflare/email/addresses/${address.id}`, {
742
763
  method: "DELETE",
@@ -876,7 +897,7 @@ export function EmailScreen({ canWrite }) {
876
897
  enabled: !rule.enabled,
877
898
  }),
878
899
  });
879
- setNotice(
900
+ toast.success(
880
901
  rule.enabled ? "Rule disabled." : "Rule enabled.",
881
902
  );
882
903
  })
@@ -887,9 +908,13 @@ export function EmailScreen({ canWrite }) {
887
908
  <button
888
909
  className="text-button danger-text"
889
910
  disabled={busy}
890
- onClick={() => {
911
+ onClick={async () => {
891
912
  if (
892
- window.confirm(`Delete “${rule.name}”?`)
913
+ await confirm({
914
+ title: `Delete “${rule.name}”?`,
915
+ message: "This routing rule will be removed.",
916
+ confirmLabel: "Delete",
917
+ })
893
918
  )
894
919
  run(() =>
895
920
  api(
@@ -0,0 +1,109 @@
1
+ "use client";
2
+ import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
3
+
4
+ const ConfirmContext = createContext(null);
5
+
6
+ export function useConfirm() {
7
+ const confirm = useContext(ConfirmContext);
8
+ if (!confirm) throw Error("Confirm dialog is unavailable");
9
+ return confirm;
10
+ }
11
+
12
+ function requestFrom(options) {
13
+ const value = typeof options === "string" ? { message: options } : options || {};
14
+ return {
15
+ title: value.title || "Please confirm",
16
+ message: value.message || "",
17
+ confirmLabel: value.confirmLabel || "OK",
18
+ cancelLabel: value.cancelLabel || "Cancel",
19
+ danger: value.danger !== false,
20
+ };
21
+ }
22
+
23
+ export function ConfirmProvider({ children }) {
24
+ const [request, setRequest] = useState(null);
25
+ const resolver = useRef(null);
26
+ const finish = useCallback((accepted) => {
27
+ resolver.current?.(accepted);
28
+ resolver.current = null;
29
+ setRequest(null);
30
+ }, []);
31
+ const confirm = useCallback((options) => {
32
+ if (resolver.current) resolver.current(false);
33
+ return new Promise((resolve) => {
34
+ resolver.current = resolve;
35
+ setRequest(requestFrom(options));
36
+ });
37
+ }, []);
38
+ return (
39
+ <ConfirmContext.Provider value={confirm}>
40
+ {children}
41
+ {request && <ConfirmDialog request={request} onFinish={finish} />}
42
+ </ConfirmContext.Provider>
43
+ );
44
+ }
45
+
46
+ function ConfirmDialog({ request, onFinish }) {
47
+ const cancelRef = useRef(null);
48
+ const confirmRef = useRef(null);
49
+ useEffect(() => {
50
+ cancelRef.current?.focus();
51
+ const onKey = (event) => {
52
+ if (event.key === "Escape") {
53
+ event.preventDefault();
54
+ onFinish(false);
55
+ return;
56
+ }
57
+ if (event.key !== "Tab") return;
58
+ const items = [cancelRef.current, confirmRef.current].filter(Boolean);
59
+ const first = items[0];
60
+ const last = items.at(-1);
61
+ if (event.shiftKey && document.activeElement === first) {
62
+ event.preventDefault();
63
+ last?.focus();
64
+ } else if (!event.shiftKey && document.activeElement === last) {
65
+ event.preventDefault();
66
+ first?.focus();
67
+ }
68
+ };
69
+ document.addEventListener("keydown", onKey);
70
+ return () => document.removeEventListener("keydown", onKey);
71
+ }, [onFinish]);
72
+ return (
73
+ <div
74
+ className="modal-backdrop alert-backdrop"
75
+ onMouseDown={(event) => {
76
+ if (event.target === event.currentTarget) onFinish(false);
77
+ }}
78
+ >
79
+ <section
80
+ className="modal alert-modal"
81
+ role="alertdialog"
82
+ aria-modal="true"
83
+ aria-labelledby="confirm-title"
84
+ aria-describedby="confirm-message"
85
+ >
86
+ <h2 id="confirm-title">{request.title}</h2>
87
+ {request.message && <p id="confirm-message">{request.message}</p>}
88
+ <div className="modal-actions">
89
+ <button
90
+ ref={cancelRef}
91
+ className="secondary"
92
+ type="button"
93
+ onClick={() => onFinish(false)}
94
+ >
95
+ {request.cancelLabel}
96
+ </button>
97
+ <button
98
+ ref={confirmRef}
99
+ className={request.danger ? "alert-confirm" : "primary"}
100
+ type="button"
101
+ onClick={() => onFinish(true)}
102
+ >
103
+ {request.confirmLabel}
104
+ </button>
105
+ </div>
106
+ </section>
107
+ </div>
108
+ );
109
+ }