ATProto forum built with ESAV
1import { useAtomValue } from "jotai";
2import { useState } from "react";
3import { websocketStatusAtom, logEntriesAtom } from "./atoms";
4import type { LogEntry } from "./types";
5
6
7export function ReconnectingHeader() {
8 const status = useAtomValue(websocketStatusAtom);
9
10 if (status === "open") {
11 return null;
12 }
13
14 const message =
15 status === "connecting"
16 ? "Connecting to ESAV Live..."
17 : "Connection lost. Attempting to reconnect...";
18
19 return (
20 <div
21 style={{
22 position: "sticky",
23 top: 0,
24 left: 0,
25 width: "100%",
26 padding: "8px",
27 backgroundColor: "#ffc107",
28 color: "#333",
29 textAlign: "center",
30 fontWeight: "bold",
31 zIndex: 1000,
32 boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
33 }}
34 >
35 {message}
36 </div>
37 );
38}
39
40const LogEntryItem = ({ entry }: { entry: LogEntry }) => {
41 const { type, timestamp, payload } = entry;
42
43 const typeStyles = {
44 incoming: { icon: "⬇️", color: "#4caf50", name: "Incoming" },
45 outgoing: { icon: "⬆️", color: "#ffeb3b", name: "Outgoing" },
46 status: { icon: "ℹ️", color: "#2196f3", name: "Status" },
47 error: { icon: "❌", color: "#f44336", name: "Error" },
48 };
49
50 const { icon, color, name } = typeStyles[type];
51
52 return (
53 <div
54 style={{
55 borderBottom: "1px solid #444",
56 padding: "8px",
57 fontFamily: "monospace",
58 fontSize: "12px",
59 borderLeft: `4px solid ${color}`,
60 }}
61 >
62 <div style={{ fontWeight: "bold", marginBottom: "4px" }}>
63 <span style={{ marginRight: "8px" }}>{icon}</span>
64 {name}
65 <span style={{ float: "right", color: "#888" }}>
66 {timestamp.toLocaleTimeString()}
67 </span>
68 </div>
69 {typeof payload === "object" ? (
70 <pre
71 style={{
72 margin: 0,
73 padding: "8px",
74 backgroundColor: "rgba(0,0,0,0.2)",
75 borderRadius: "4px",
76 whiteSpace: "pre-wrap",
77 wordBreak: "break-all",
78 fontSize: "11px",
79 maxHeight: "200px",
80 overflowY: "auto",
81 }}
82 >
83 {JSON.stringify(payload, null, 2)}
84 </pre>
85 ) : (
86 <code style={{ color: "#ccc" }}>{String(payload)}</code>
87 )}
88 </div>
89 );
90};
91
92export function DeltaLogViewer() {
93 const [open, setOpen] = useState(false);
94 const log = useAtomValue(logEntriesAtom);
95
96 return (
97 <div
98 style={{
99 position: "fixed",
100 bottom: "10px",
101 right: "10px",
102 width: open ? "min(850px,90dvw)" : "280px",
103 backgroundColor: "#2d2d2d",
104 color: "#f1f1f1",
105 border: "1px solid #555",
106 borderRadius: "8px",
107 boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
108 zIndex: 2000,
109 overflow: "hidden",
110 display: "flex",
111 flexDirection: "column",
112 maxHeight: "600px",
113 transition: "width 0.1s ease",
114 }}
115 >
116 <div
117 style={{
118 display: "flex",
119 justifyContent: "space-between",
120 alignItems: "center",
121 padding: "8px 12px",
122 backgroundColor: "#3c3c3c",
123 borderBottom: "1px solid #555",
124 fontWeight: 700,
125 }}
126 >
127 <span>ESAV Live Log</span>
128 <button
129 onClick={() => setOpen(!open)}
130 style={{
131 background: "transparent",
132 border: "none",
133 color: "#ccc",
134 fontSize: "16px",
135 cursor: "pointer",
136 padding: "4px 8px",
137 borderRadius: "4px",
138 transition: "background 0.01s",
139 }}
140 onMouseEnter={(e) => (e.currentTarget.style.background = "#444")}
141 onMouseLeave={(e) =>
142 (e.currentTarget.style.background = "transparent")
143 }
144 title={open ? "Collapse log" : "Expand log"}
145 >
146 {open ? "close" : "open"}
147 </button>
148 </div>
149 <div
150 style={{
151 flex: 1,
152 overflowY: "auto",
153 display: open ? "flex" : "none",
154 flexDirection: "column",
155 }}
156 >
157 {log.length === 0 ? (
158 <div style={{ padding: "10px", color: "#888" }}>
159 Waiting for events...
160 </div>
161 ) : (
162 log.map((entry) => <LogEntryItem key={entry.id} entry={entry} />)
163 )}
164 </div>
165 </div>
166 );
167}