@alepha/devtools 0.19.1 → 0.19.2
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.
- package/dist/index.d.ts +323 -323
- package/dist/index.js +59 -2
- package/dist/index.js.map +1 -1
- package/package.json +14 -13
- package/src/providers/DevToolsMetadataProvider.ts +1 -1
- package/src/providers/DevToolsProvider.ts +115 -1
- package/src/ui/AppRouter.tsx +18 -0
- package/src/ui/components/DevLayout.tsx +14 -15
- package/src/ui/components/configuration/ConfigEnv.tsx +1 -1
- package/src/ui/components/dashboard/DevDashboard.tsx +31 -12
- package/src/ui/components/database/DatabaseEditor.tsx +5 -2
- package/src/ui/components/emails/DevEmails.tsx +250 -0
- package/src/ui/components/explorer/DevExplorer.tsx +1 -1
- package/src/ui/components/sms/DevSms.tsx +225 -0
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
|
-
ActionButton,
|
|
3
2
|
DarkModeButton,
|
|
4
3
|
DashboardShell,
|
|
5
4
|
Flex,
|
|
6
5
|
OmnibarButton,
|
|
7
6
|
} from "@alepha/ui";
|
|
8
7
|
import {
|
|
9
|
-
IconArrowLeft,
|
|
10
8
|
IconDashboard,
|
|
11
9
|
IconDatabase,
|
|
12
10
|
IconList,
|
|
11
|
+
IconMail,
|
|
12
|
+
IconMessage,
|
|
13
13
|
IconSettings,
|
|
14
14
|
IconSitemap,
|
|
15
15
|
IconTopologyRing,
|
|
@@ -68,6 +68,17 @@ export const DevLayout = () => {
|
|
|
68
68
|
href: "/conf/env",
|
|
69
69
|
},
|
|
70
70
|
{ type: "divider" },
|
|
71
|
+
{
|
|
72
|
+
label: "Emails",
|
|
73
|
+
icon: <IconMail />,
|
|
74
|
+
href: "/emails",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
label: "SMS",
|
|
78
|
+
icon: <IconMessage />,
|
|
79
|
+
href: "/sms",
|
|
80
|
+
},
|
|
81
|
+
{ type: "divider" },
|
|
71
82
|
{
|
|
72
83
|
label: "Graph",
|
|
73
84
|
icon: <IconTopologyRing />,
|
|
@@ -96,19 +107,7 @@ export const DevLayout = () => {
|
|
|
96
107
|
navbarFooter={<Flex />}
|
|
97
108
|
footerHeight={24}
|
|
98
109
|
headerHeight={60}
|
|
99
|
-
navbarHeader={() =>
|
|
100
|
-
<Flex align="center" justify="center" h="100%" w="100%">
|
|
101
|
-
<ActionButton
|
|
102
|
-
href="/"
|
|
103
|
-
target="_self"
|
|
104
|
-
variant="subtle"
|
|
105
|
-
color="gray"
|
|
106
|
-
size="sm"
|
|
107
|
-
>
|
|
108
|
-
<IconArrowLeft size={18} />
|
|
109
|
-
</ActionButton>
|
|
110
|
-
</Flex>
|
|
111
|
-
)}
|
|
110
|
+
navbarHeader={() => <Flex />}
|
|
112
111
|
sidebarProps={{
|
|
113
112
|
collapsed: true,
|
|
114
113
|
items: sidebarItems,
|
|
@@ -80,7 +80,7 @@ const EnvLine = ({ variable }: { variable: EnvVariable }) => {
|
|
|
80
80
|
const sensitive = isSensitive(variable.name);
|
|
81
81
|
|
|
82
82
|
return (
|
|
83
|
-
<Flex>
|
|
83
|
+
<Flex direction="column">
|
|
84
84
|
{/* Description as comment */}
|
|
85
85
|
{variable.description?.split("\n").map((line, i) => (
|
|
86
86
|
<Text key={i} fz={11} ff="monospace" c="dimmed" fs="italic" lh={1.6}>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { devMetadataSchema } from "@alepha/devtools";
|
|
2
|
-
import { Flex, ui } from "@alepha/ui";
|
|
2
|
+
import { ActionButton, Flex, ui } from "@alepha/ui";
|
|
3
3
|
import {
|
|
4
4
|
Badge,
|
|
5
5
|
Card,
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
IconDatabase,
|
|
20
20
|
IconFileText,
|
|
21
21
|
IconPlug,
|
|
22
|
+
IconRefresh,
|
|
22
23
|
IconRoute,
|
|
23
24
|
IconServer,
|
|
24
25
|
IconSettings,
|
|
@@ -93,6 +94,7 @@ export const DevDashboard = () => {
|
|
|
93
94
|
const [metadata, setMetadata] = useState<any>(null);
|
|
94
95
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
95
96
|
const [events, setEvents] = useState<LogEntry[]>([]);
|
|
97
|
+
const [reloading, setReloading] = useState(false);
|
|
96
98
|
|
|
97
99
|
const fetchData = useCallback(async () => {
|
|
98
100
|
if (document.visibilityState !== "visible") return;
|
|
@@ -112,6 +114,19 @@ export const DevDashboard = () => {
|
|
|
112
114
|
}
|
|
113
115
|
}, [http]);
|
|
114
116
|
|
|
117
|
+
const handleReload = useCallback(async () => {
|
|
118
|
+
setReloading(true);
|
|
119
|
+
try {
|
|
120
|
+
await http.fetch("/__devtools/api/reload", { method: "POST" });
|
|
121
|
+
setTimeout(() => {
|
|
122
|
+
fetchData();
|
|
123
|
+
setReloading(false);
|
|
124
|
+
}, 1000);
|
|
125
|
+
} catch {
|
|
126
|
+
setReloading(false);
|
|
127
|
+
}
|
|
128
|
+
}, [http, fetchData]);
|
|
129
|
+
|
|
115
130
|
useEffect(() => {
|
|
116
131
|
fetchData();
|
|
117
132
|
const interval = setInterval(fetchData, 10_000);
|
|
@@ -191,17 +206,21 @@ export const DevDashboard = () => {
|
|
|
191
206
|
<Flex direction="column" gap="lg">
|
|
192
207
|
{/* App Stats */}
|
|
193
208
|
<div>
|
|
194
|
-
<
|
|
195
|
-
order={5}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
209
|
+
<Flex justify="space-between" align="center" mb="sm">
|
|
210
|
+
<Title order={5} c="dimmed" tt="uppercase" fz="xs" fw={600} lts={1}>
|
|
211
|
+
System
|
|
212
|
+
</Title>
|
|
213
|
+
<ActionButton
|
|
214
|
+
size="xs"
|
|
215
|
+
variant="subtle"
|
|
216
|
+
color="gray"
|
|
217
|
+
loading={reloading}
|
|
218
|
+
onClick={handleReload}
|
|
219
|
+
icon={<IconRefresh size={14} />}
|
|
220
|
+
>
|
|
221
|
+
Reload
|
|
222
|
+
</ActionButton>
|
|
223
|
+
</Flex>
|
|
205
224
|
<SimpleGrid cols={{ base: 2, sm: 3, md: 4, lg: 6 }} spacing="sm">
|
|
206
225
|
{system && (
|
|
207
226
|
<>
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
import { jsonSchemaToTypeBox, t } from "alepha";
|
|
17
17
|
import { useInject } from "alepha/react";
|
|
18
18
|
import { useForm } from "alepha/react/form";
|
|
19
|
-
import { useRouter } from "alepha/react/router";
|
|
19
|
+
import { useRouter, useRouterState } from "alepha/react/router";
|
|
20
20
|
import { HttpClient } from "alepha/server";
|
|
21
21
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
22
22
|
import { TreeView, type TreeViewNode } from "../shared/TreeView.tsx";
|
|
@@ -107,12 +107,15 @@ const parseEditorPath = (pathname: string) => {
|
|
|
107
107
|
export const DatabaseEditor = ({ entities }: { entities: any[] }) => {
|
|
108
108
|
const http = useInject(HttpClient);
|
|
109
109
|
const router = useRouter();
|
|
110
|
+
const state = useRouterState();
|
|
110
111
|
const [records, setRecords] = useState<any[]>([]);
|
|
111
112
|
const [loading, setLoading] = useState(false);
|
|
112
113
|
const [search, setSearch] = useState("");
|
|
113
114
|
const [pageInfo, setPageInfo] = useState<any>(null);
|
|
114
115
|
|
|
115
|
-
const { table: selectedEntity, recordId } = parseEditorPath(
|
|
116
|
+
const { table: selectedEntity, recordId } = parseEditorPath(
|
|
117
|
+
state.url.pathname,
|
|
118
|
+
);
|
|
116
119
|
const isNew = recordId === "new";
|
|
117
120
|
|
|
118
121
|
const entity = entities.find((e) => e.name === selectedEntity);
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { Flex, ui } from "@alepha/ui";
|
|
2
|
+
import { Badge, CloseButton, ScrollArea, Text, TextInput } from "@mantine/core";
|
|
3
|
+
import { IconSearch } from "@tabler/icons-react";
|
|
4
|
+
import { useInject } from "alepha/react";
|
|
5
|
+
import { HttpClient } from "alepha/server";
|
|
6
|
+
import { useCallback, useEffect, useState } from "react";
|
|
7
|
+
|
|
8
|
+
interface EmailEntry {
|
|
9
|
+
to: string;
|
|
10
|
+
subject: string;
|
|
11
|
+
body: string;
|
|
12
|
+
sentAt: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const formatDate = (iso: string): string => {
|
|
16
|
+
const d = new Date(iso);
|
|
17
|
+
return d.toLocaleString();
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const formatRelative = (iso: string): string => {
|
|
21
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
22
|
+
if (diff < 1000) return "just now";
|
|
23
|
+
if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`;
|
|
24
|
+
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
|
25
|
+
return `${Math.floor(diff / 3_600_000)}h ago`;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const DevEmails = () => {
|
|
29
|
+
const http = useInject(HttpClient);
|
|
30
|
+
const [emails, setEmails] = useState<EmailEntry[]>([]);
|
|
31
|
+
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
|
32
|
+
const [search, setSearch] = useState("");
|
|
33
|
+
|
|
34
|
+
const fetchEmails = useCallback(async () => {
|
|
35
|
+
if (document.visibilityState !== "visible") return;
|
|
36
|
+
try {
|
|
37
|
+
const res = await http.fetch("/__devtools/api/emails");
|
|
38
|
+
const data = res.data as any;
|
|
39
|
+
setEmails(data?.emails ?? []);
|
|
40
|
+
} catch {
|
|
41
|
+
// silently fail
|
|
42
|
+
}
|
|
43
|
+
}, [http]);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
fetchEmails();
|
|
47
|
+
const interval = setInterval(fetchEmails, 10_000);
|
|
48
|
+
return () => clearInterval(interval);
|
|
49
|
+
}, [fetchEmails]);
|
|
50
|
+
|
|
51
|
+
const filtered = emails.filter((email) => {
|
|
52
|
+
if (!search) return true;
|
|
53
|
+
const q = search.toLowerCase();
|
|
54
|
+
return (
|
|
55
|
+
email.to.toLowerCase().includes(q) ||
|
|
56
|
+
email.subject.toLowerCase().includes(q)
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const selectedEmail = selectedIndex !== null ? filtered[selectedIndex] : null;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Flex style={{ flex: 1, overflow: "hidden" }} direction="column">
|
|
64
|
+
{/* Filter bar */}
|
|
65
|
+
<Flex
|
|
66
|
+
px="md"
|
|
67
|
+
py="xs"
|
|
68
|
+
gap="sm"
|
|
69
|
+
align="center"
|
|
70
|
+
style={{
|
|
71
|
+
borderBottom: `1px solid ${ui.colors.border}`,
|
|
72
|
+
flexShrink: 0,
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
<TextInput
|
|
76
|
+
size="xs"
|
|
77
|
+
placeholder="Search..."
|
|
78
|
+
leftSection={<IconSearch size={14} />}
|
|
79
|
+
value={search}
|
|
80
|
+
onChange={(e) => setSearch(e.currentTarget.value)}
|
|
81
|
+
style={{ flex: 1, minWidth: 150, maxWidth: 300 }}
|
|
82
|
+
/>
|
|
83
|
+
<Badge variant="light" color="gray" size="sm">
|
|
84
|
+
{filtered.length} emails
|
|
85
|
+
</Badge>
|
|
86
|
+
</Flex>
|
|
87
|
+
|
|
88
|
+
{/* Main area: list + detail */}
|
|
89
|
+
<Flex style={{ flex: 1, overflow: "hidden" }}>
|
|
90
|
+
{/* Email list */}
|
|
91
|
+
<Flex direction="column" style={{ flex: 1, overflow: "hidden" }}>
|
|
92
|
+
<ScrollArea style={{ flex: 1 }}>
|
|
93
|
+
{filtered.length === 0 && (
|
|
94
|
+
<Flex align="center" justify="center" py="xl" c="dimmed">
|
|
95
|
+
<Text fz="sm">No emails to display</Text>
|
|
96
|
+
</Flex>
|
|
97
|
+
)}
|
|
98
|
+
{filtered.map((email, i) => {
|
|
99
|
+
const isSelected = selectedIndex === i;
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<Flex
|
|
103
|
+
key={`${email.sentAt}-${i}`}
|
|
104
|
+
direction="column"
|
|
105
|
+
px="md"
|
|
106
|
+
py="xs"
|
|
107
|
+
onClick={() => setSelectedIndex(isSelected ? null : i)}
|
|
108
|
+
style={{
|
|
109
|
+
borderBottom: `1px solid ${ui.colors.border}20`,
|
|
110
|
+
background: isSelected ? ui.colors.elevated : "transparent",
|
|
111
|
+
cursor: "pointer",
|
|
112
|
+
transition: "background 100ms",
|
|
113
|
+
}}
|
|
114
|
+
onMouseEnter={(e) => {
|
|
115
|
+
if (!isSelected) {
|
|
116
|
+
(e.currentTarget as HTMLElement).style.background =
|
|
117
|
+
`${ui.colors.elevated}80`;
|
|
118
|
+
}
|
|
119
|
+
}}
|
|
120
|
+
onMouseLeave={(e) => {
|
|
121
|
+
if (!isSelected) {
|
|
122
|
+
(e.currentTarget as HTMLElement).style.background =
|
|
123
|
+
"transparent";
|
|
124
|
+
}
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
<Flex justify="space-between" align="center">
|
|
128
|
+
<Text fz="sm" truncate>
|
|
129
|
+
{email.to}
|
|
130
|
+
</Text>
|
|
131
|
+
<Text fz={11} c="dimmed" style={{ flexShrink: 0 }}>
|
|
132
|
+
{formatRelative(email.sentAt)}
|
|
133
|
+
</Text>
|
|
134
|
+
</Flex>
|
|
135
|
+
<Text fz="xs" c="dimmed" truncate>
|
|
136
|
+
{email.subject}
|
|
137
|
+
</Text>
|
|
138
|
+
</Flex>
|
|
139
|
+
);
|
|
140
|
+
})}
|
|
141
|
+
</ScrollArea>
|
|
142
|
+
</Flex>
|
|
143
|
+
|
|
144
|
+
{/* Detail panel */}
|
|
145
|
+
{selectedEmail && (
|
|
146
|
+
<Flex
|
|
147
|
+
w={500}
|
|
148
|
+
direction="column"
|
|
149
|
+
style={{
|
|
150
|
+
borderLeft: `1px solid ${ui.colors.border}`,
|
|
151
|
+
flexShrink: 0,
|
|
152
|
+
overflow: "hidden",
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
<Flex
|
|
156
|
+
px="md"
|
|
157
|
+
py="xs"
|
|
158
|
+
align="center"
|
|
159
|
+
justify="space-between"
|
|
160
|
+
style={{
|
|
161
|
+
borderBottom: `1px solid ${ui.colors.border}`,
|
|
162
|
+
flexShrink: 0,
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
<Text fz="xs" fw={600} tt="uppercase" c="dimmed" lts={0.5}>
|
|
166
|
+
Email Detail
|
|
167
|
+
</Text>
|
|
168
|
+
<CloseButton size="xs" onClick={() => setSelectedIndex(null)} />
|
|
169
|
+
</Flex>
|
|
170
|
+
<ScrollArea style={{ flex: 1 }} p="md">
|
|
171
|
+
<Flex direction="column" gap="md">
|
|
172
|
+
{/* To */}
|
|
173
|
+
<Flex direction="column">
|
|
174
|
+
<Text
|
|
175
|
+
fz={10}
|
|
176
|
+
c="dimmed"
|
|
177
|
+
tt="uppercase"
|
|
178
|
+
fw={600}
|
|
179
|
+
lts={0.5}
|
|
180
|
+
mb={4}
|
|
181
|
+
>
|
|
182
|
+
To
|
|
183
|
+
</Text>
|
|
184
|
+
<Text fz="xs">{selectedEmail.to}</Text>
|
|
185
|
+
</Flex>
|
|
186
|
+
|
|
187
|
+
{/* Date */}
|
|
188
|
+
<Flex direction="column">
|
|
189
|
+
<Text
|
|
190
|
+
fz={10}
|
|
191
|
+
c="dimmed"
|
|
192
|
+
tt="uppercase"
|
|
193
|
+
fw={600}
|
|
194
|
+
lts={0.5}
|
|
195
|
+
mb={4}
|
|
196
|
+
>
|
|
197
|
+
Date
|
|
198
|
+
</Text>
|
|
199
|
+
<Text fz="xs">{formatDate(selectedEmail.sentAt)}</Text>
|
|
200
|
+
</Flex>
|
|
201
|
+
|
|
202
|
+
{/* Subject */}
|
|
203
|
+
<Flex direction="column">
|
|
204
|
+
<Text
|
|
205
|
+
fz={10}
|
|
206
|
+
c="dimmed"
|
|
207
|
+
tt="uppercase"
|
|
208
|
+
fw={600}
|
|
209
|
+
lts={0.5}
|
|
210
|
+
mb={4}
|
|
211
|
+
>
|
|
212
|
+
Subject
|
|
213
|
+
</Text>
|
|
214
|
+
<Text fz="xs">{selectedEmail.subject}</Text>
|
|
215
|
+
</Flex>
|
|
216
|
+
|
|
217
|
+
{/* Body */}
|
|
218
|
+
<Flex direction="column">
|
|
219
|
+
<Text
|
|
220
|
+
fz={10}
|
|
221
|
+
c="dimmed"
|
|
222
|
+
tt="uppercase"
|
|
223
|
+
fw={600}
|
|
224
|
+
lts={0.5}
|
|
225
|
+
mb={4}
|
|
226
|
+
>
|
|
227
|
+
Body
|
|
228
|
+
</Text>
|
|
229
|
+
<div
|
|
230
|
+
style={{
|
|
231
|
+
background: "white",
|
|
232
|
+
color: "#333",
|
|
233
|
+
border: "1px solid #ccc",
|
|
234
|
+
borderRadius: 4,
|
|
235
|
+
padding: 12,
|
|
236
|
+
}}
|
|
237
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: devtools renders developer's own email HTML
|
|
238
|
+
dangerouslySetInnerHTML={{ __html: selectedEmail.body }}
|
|
239
|
+
/>
|
|
240
|
+
</Flex>
|
|
241
|
+
</Flex>
|
|
242
|
+
</ScrollArea>
|
|
243
|
+
</Flex>
|
|
244
|
+
)}
|
|
245
|
+
</Flex>
|
|
246
|
+
</Flex>
|
|
247
|
+
);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
export default DevEmails;
|
|
@@ -87,7 +87,7 @@ const buildTree = (metadata: any): TreeNode[] => {
|
|
|
87
87
|
for (const page of metadata.pages) {
|
|
88
88
|
pageNodeMap.set(page.name, {
|
|
89
89
|
id: `page:${page.name}`,
|
|
90
|
-
label: page.
|
|
90
|
+
label: page.name,
|
|
91
91
|
type: "page" as const,
|
|
92
92
|
data: page,
|
|
93
93
|
children: [],
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { Flex, ui } from "@alepha/ui";
|
|
2
|
+
import { Badge, CloseButton, ScrollArea, Text, TextInput } from "@mantine/core";
|
|
3
|
+
import { IconSearch } from "@tabler/icons-react";
|
|
4
|
+
import { useInject } from "alepha/react";
|
|
5
|
+
import { HttpClient } from "alepha/server";
|
|
6
|
+
import { useCallback, useEffect, useState } from "react";
|
|
7
|
+
|
|
8
|
+
interface SmsEntry {
|
|
9
|
+
to: string;
|
|
10
|
+
message: string;
|
|
11
|
+
sentAt: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const formatDate = (iso: string): string => {
|
|
15
|
+
const d = new Date(iso);
|
|
16
|
+
return d.toLocaleString();
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const formatRelative = (iso: string): string => {
|
|
20
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
21
|
+
if (diff < 1000) return "just now";
|
|
22
|
+
if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`;
|
|
23
|
+
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
|
24
|
+
return `${Math.floor(diff / 3_600_000)}h ago`;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const DevSms = () => {
|
|
28
|
+
const http = useInject(HttpClient);
|
|
29
|
+
const [messages, setMessages] = useState<SmsEntry[]>([]);
|
|
30
|
+
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
|
31
|
+
const [search, setSearch] = useState("");
|
|
32
|
+
|
|
33
|
+
const fetchMessages = useCallback(async () => {
|
|
34
|
+
if (document.visibilityState !== "visible") return;
|
|
35
|
+
try {
|
|
36
|
+
const res = await http.fetch("/__devtools/api/sms");
|
|
37
|
+
const data = res.data as any;
|
|
38
|
+
setMessages(data?.messages ?? []);
|
|
39
|
+
} catch {
|
|
40
|
+
// silently fail
|
|
41
|
+
}
|
|
42
|
+
}, [http]);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
fetchMessages();
|
|
46
|
+
const interval = setInterval(fetchMessages, 10_000);
|
|
47
|
+
return () => clearInterval(interval);
|
|
48
|
+
}, [fetchMessages]);
|
|
49
|
+
|
|
50
|
+
const filtered = messages.filter((sms) => {
|
|
51
|
+
if (!search) return true;
|
|
52
|
+
const q = search.toLowerCase();
|
|
53
|
+
return (
|
|
54
|
+
sms.to.toLowerCase().includes(q) || sms.message.toLowerCase().includes(q)
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const selectedSms = selectedIndex !== null ? filtered[selectedIndex] : null;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Flex style={{ flex: 1, overflow: "hidden" }} direction="column">
|
|
62
|
+
{/* Filter bar */}
|
|
63
|
+
<Flex
|
|
64
|
+
px="md"
|
|
65
|
+
py="xs"
|
|
66
|
+
gap="sm"
|
|
67
|
+
align="center"
|
|
68
|
+
style={{
|
|
69
|
+
borderBottom: `1px solid ${ui.colors.border}`,
|
|
70
|
+
flexShrink: 0,
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<TextInput
|
|
74
|
+
size="xs"
|
|
75
|
+
placeholder="Search..."
|
|
76
|
+
leftSection={<IconSearch size={14} />}
|
|
77
|
+
value={search}
|
|
78
|
+
onChange={(e) => setSearch(e.currentTarget.value)}
|
|
79
|
+
style={{ flex: 1, minWidth: 150, maxWidth: 300 }}
|
|
80
|
+
/>
|
|
81
|
+
<Badge variant="light" color="gray" size="sm">
|
|
82
|
+
{filtered.length} messages
|
|
83
|
+
</Badge>
|
|
84
|
+
</Flex>
|
|
85
|
+
|
|
86
|
+
{/* Main area: list + detail */}
|
|
87
|
+
<Flex style={{ flex: 1, overflow: "hidden" }}>
|
|
88
|
+
{/* SMS list */}
|
|
89
|
+
<Flex direction="column" style={{ flex: 1, overflow: "hidden" }}>
|
|
90
|
+
<ScrollArea style={{ flex: 1 }}>
|
|
91
|
+
{filtered.length === 0 && (
|
|
92
|
+
<Flex align="center" justify="center" py="xl" c="dimmed">
|
|
93
|
+
<Text fz="sm">No messages to display</Text>
|
|
94
|
+
</Flex>
|
|
95
|
+
)}
|
|
96
|
+
{filtered.map((sms, i) => {
|
|
97
|
+
const isSelected = selectedIndex === i;
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<Flex
|
|
101
|
+
key={`${sms.sentAt}-${i}`}
|
|
102
|
+
direction="column"
|
|
103
|
+
px="md"
|
|
104
|
+
py="xs"
|
|
105
|
+
onClick={() => setSelectedIndex(isSelected ? null : i)}
|
|
106
|
+
style={{
|
|
107
|
+
borderBottom: `1px solid ${ui.colors.border}20`,
|
|
108
|
+
background: isSelected ? ui.colors.elevated : "transparent",
|
|
109
|
+
cursor: "pointer",
|
|
110
|
+
transition: "background 100ms",
|
|
111
|
+
}}
|
|
112
|
+
onMouseEnter={(e) => {
|
|
113
|
+
if (!isSelected) {
|
|
114
|
+
(e.currentTarget as HTMLElement).style.background =
|
|
115
|
+
`${ui.colors.elevated}80`;
|
|
116
|
+
}
|
|
117
|
+
}}
|
|
118
|
+
onMouseLeave={(e) => {
|
|
119
|
+
if (!isSelected) {
|
|
120
|
+
(e.currentTarget as HTMLElement).style.background =
|
|
121
|
+
"transparent";
|
|
122
|
+
}
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
<Flex justify="space-between" align="center">
|
|
126
|
+
<Text fz="sm" truncate>
|
|
127
|
+
{sms.to}
|
|
128
|
+
</Text>
|
|
129
|
+
<Text fz={11} c="dimmed" style={{ flexShrink: 0 }}>
|
|
130
|
+
{formatRelative(sms.sentAt)}
|
|
131
|
+
</Text>
|
|
132
|
+
</Flex>
|
|
133
|
+
<Text fz="xs" c="dimmed" truncate>
|
|
134
|
+
{sms.message}
|
|
135
|
+
</Text>
|
|
136
|
+
</Flex>
|
|
137
|
+
);
|
|
138
|
+
})}
|
|
139
|
+
</ScrollArea>
|
|
140
|
+
</Flex>
|
|
141
|
+
|
|
142
|
+
{/* Detail panel */}
|
|
143
|
+
{selectedSms && (
|
|
144
|
+
<Flex
|
|
145
|
+
w={400}
|
|
146
|
+
direction="column"
|
|
147
|
+
style={{
|
|
148
|
+
borderLeft: `1px solid ${ui.colors.border}`,
|
|
149
|
+
flexShrink: 0,
|
|
150
|
+
overflow: "hidden",
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
<Flex
|
|
154
|
+
px="md"
|
|
155
|
+
py="xs"
|
|
156
|
+
align="center"
|
|
157
|
+
justify="space-between"
|
|
158
|
+
style={{
|
|
159
|
+
borderBottom: `1px solid ${ui.colors.border}`,
|
|
160
|
+
flexShrink: 0,
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
<Text fz="xs" fw={600} tt="uppercase" c="dimmed" lts={0.5}>
|
|
164
|
+
Message Detail
|
|
165
|
+
</Text>
|
|
166
|
+
<CloseButton size="xs" onClick={() => setSelectedIndex(null)} />
|
|
167
|
+
</Flex>
|
|
168
|
+
<ScrollArea style={{ flex: 1 }} p="md">
|
|
169
|
+
<Flex direction="column" gap="md">
|
|
170
|
+
{/* To */}
|
|
171
|
+
<Flex direction="column">
|
|
172
|
+
<Text
|
|
173
|
+
fz={10}
|
|
174
|
+
c="dimmed"
|
|
175
|
+
tt="uppercase"
|
|
176
|
+
fw={600}
|
|
177
|
+
lts={0.5}
|
|
178
|
+
mb={4}
|
|
179
|
+
>
|
|
180
|
+
To
|
|
181
|
+
</Text>
|
|
182
|
+
<Text fz="xs">{selectedSms.to}</Text>
|
|
183
|
+
</Flex>
|
|
184
|
+
|
|
185
|
+
{/* Date */}
|
|
186
|
+
<Flex direction="column">
|
|
187
|
+
<Text
|
|
188
|
+
fz={10}
|
|
189
|
+
c="dimmed"
|
|
190
|
+
tt="uppercase"
|
|
191
|
+
fw={600}
|
|
192
|
+
lts={0.5}
|
|
193
|
+
mb={4}
|
|
194
|
+
>
|
|
195
|
+
Date
|
|
196
|
+
</Text>
|
|
197
|
+
<Text fz="xs">{formatDate(selectedSms.sentAt)}</Text>
|
|
198
|
+
</Flex>
|
|
199
|
+
|
|
200
|
+
{/* Message */}
|
|
201
|
+
<Flex direction="column">
|
|
202
|
+
<Text
|
|
203
|
+
fz={10}
|
|
204
|
+
c="dimmed"
|
|
205
|
+
tt="uppercase"
|
|
206
|
+
fw={600}
|
|
207
|
+
lts={0.5}
|
|
208
|
+
mb={4}
|
|
209
|
+
>
|
|
210
|
+
Message
|
|
211
|
+
</Text>
|
|
212
|
+
<Text fz="xs" style={{ whiteSpace: "pre-wrap" }}>
|
|
213
|
+
{selectedSms.message}
|
|
214
|
+
</Text>
|
|
215
|
+
</Flex>
|
|
216
|
+
</Flex>
|
|
217
|
+
</ScrollArea>
|
|
218
|
+
</Flex>
|
|
219
|
+
)}
|
|
220
|
+
</Flex>
|
|
221
|
+
</Flex>
|
|
222
|
+
);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
export default DevSms;
|