@adens/openwa 0.1.0

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -0
  3. package/README.md +319 -0
  4. package/bin/openwa.js +11 -0
  5. package/favicon.ico +0 -0
  6. package/logo-long.png +0 -0
  7. package/logo-square.png +0 -0
  8. package/package.json +69 -0
  9. package/prisma/schema.prisma +182 -0
  10. package/server/config.js +29 -0
  11. package/server/database/client.js +11 -0
  12. package/server/database/init.js +28 -0
  13. package/server/express/create-app.js +349 -0
  14. package/server/express/openapi.js +853 -0
  15. package/server/index.js +163 -0
  16. package/server/services/api-key-service.js +131 -0
  17. package/server/services/auth-service.js +162 -0
  18. package/server/services/chat-service.js +1014 -0
  19. package/server/services/session-service.js +81 -0
  20. package/server/socket/register.js +127 -0
  21. package/server/utils/avatar.js +34 -0
  22. package/server/utils/paths.js +29 -0
  23. package/server/whatsapp/adapters/mock-adapter.js +47 -0
  24. package/server/whatsapp/adapters/wwebjs-adapter.js +263 -0
  25. package/server/whatsapp/session-manager.js +356 -0
  26. package/web/components/AppHead.js +14 -0
  27. package/web/components/AuthCard.js +170 -0
  28. package/web/components/BrandLogo.js +11 -0
  29. package/web/components/ChatWindow.js +875 -0
  30. package/web/components/ChatWindow.js.tmp +0 -0
  31. package/web/components/ContactList.js +97 -0
  32. package/web/components/ContactsPanel.js +90 -0
  33. package/web/components/EmojiPicker.js +108 -0
  34. package/web/components/MediaPreviewModal.js +146 -0
  35. package/web/components/MessageActionMenu.js +155 -0
  36. package/web/components/SessionSidebar.js +167 -0
  37. package/web/components/SettingsModal.js +266 -0
  38. package/web/components/Skeletons.js +73 -0
  39. package/web/jsconfig.json +10 -0
  40. package/web/lib/api.js +33 -0
  41. package/web/lib/socket.js +9 -0
  42. package/web/pages/_app.js +5 -0
  43. package/web/pages/dashboard.js +541 -0
  44. package/web/pages/index.js +62 -0
  45. package/web/postcss.config.js +10 -0
  46. package/web/public/favicon.ico +0 -0
  47. package/web/public/logo-long.png +0 -0
  48. package/web/public/logo-square.png +0 -0
  49. package/web/store/useAppStore.js +209 -0
  50. package/web/styles/globals.css +52 -0
  51. package/web/tailwind.config.js +36 -0
@@ -0,0 +1,167 @@
1
+ import { BrandLogo } from "@/components/BrandLogo";
2
+
3
+ function SessionStatusBadge({ status }) {
4
+ const colors = {
5
+ ready: "bg-brand-500/15 text-brand-100 ring-1 ring-brand-400/20",
6
+ connecting: "bg-amber-500/15 text-amber-100 ring-1 ring-amber-400/20",
7
+ disconnected: "bg-white/8 text-white/60 ring-1 ring-white/10",
8
+ error: "bg-red-500/15 text-red-100 ring-1 ring-red-400/20"
9
+ };
10
+
11
+ return (
12
+ <span className={`rounded-full px-2.5 py-1 text-[11px] font-medium capitalize tracking-[0.08em] ${colors[status] || colors.disconnected}`}>
13
+ {status}
14
+ </span>
15
+ );
16
+ }
17
+
18
+ function initials(label) {
19
+ return String(label || "?")
20
+ .split(" ")
21
+ .map((part) => part[0])
22
+ .join("")
23
+ .slice(0, 2)
24
+ .toUpperCase();
25
+ }
26
+
27
+ function SessionAvatar({ label }) {
28
+ return <div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#202c33] text-sm font-semibold text-white">{initials(label)}</div>;
29
+ }
30
+
31
+ export function SessionSidebar({
32
+ sessions,
33
+ activeSessionId,
34
+ onSelect,
35
+ onConnect,
36
+ onDisconnect,
37
+ sessionName,
38
+ sessionPhone,
39
+ onSessionNameChange,
40
+ onSessionPhoneChange,
41
+ onCreateSession
42
+ }) {
43
+ const activeSession = sessions.find((session) => session.id === activeSessionId) || sessions[0] || null;
44
+
45
+ return (
46
+ <aside className="flex w-[330px] shrink-0 flex-col border-r border-white/6 bg-[#0b141a]">
47
+ <div className="border-b border-white/6 px-5 py-5">
48
+ <p className="text-[11px] uppercase tracking-[0.28em] text-brand-100/60">Workspace</p>
49
+ <div className="mt-3 flex items-center gap-3">
50
+ <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white p-2 shadow-[0_16px_40px_rgba(0,0,0,0.18)]">
51
+ <BrandLogo variant="square" alt="OpenWA" className="h-full w-full rounded-xl" />
52
+ </div>
53
+ <div>
54
+ <h2 className="text-lg font-semibold text-white">OpenWA Devices</h2>
55
+ <p className="text-sm text-white/45">Manage multiple numbers in one dashboard.</p>
56
+ </div>
57
+ </div>
58
+ </div>
59
+
60
+ <div className="flex-1 overflow-y-auto px-4 py-4">
61
+ <div className="space-y-2.5">
62
+ {sessions.map((session) => (
63
+ <button
64
+ key={session.id}
65
+ type="button"
66
+ className={`w-full rounded-[26px] border px-4 py-3.5 text-left transition ${
67
+ session.id === activeSessionId
68
+ ? "border-brand-500/40 bg-brand-500/10 shadow-panel"
69
+ : "border-white/6 bg-white/[0.03] hover:border-white/12 hover:bg-white/[0.05]"
70
+ }`}
71
+ onClick={() => onSelect(session.id)}
72
+ >
73
+ <div className="flex items-start gap-3">
74
+ <SessionAvatar label={session.name} />
75
+ <div className="min-w-0 flex-1">
76
+ <div className="flex items-center justify-between gap-3">
77
+ <h3 className="truncate font-medium text-white">{session.name}</h3>
78
+ <SessionStatusBadge status={session.status} />
79
+ </div>
80
+ <p className="mt-1 truncate text-sm text-white/45">{session.phoneNumber || "Number will appear after device connects"}</p>
81
+ </div>
82
+ </div>
83
+ </button>
84
+ ))}
85
+
86
+ {sessions.length === 0 ? (
87
+ <div className="rounded-[26px] border border-dashed border-white/10 bg-white/[0.03] px-4 py-7 text-sm leading-6 text-white/45">
88
+ No active sessions. Add a new device to start building your OpenWA workspace.
89
+ </div>
90
+ ) : null}
91
+ </div>
92
+ </div>
93
+
94
+ <div className="border-t border-white/6 px-5 py-5">
95
+ {activeSession ? (
96
+ <div className="mb-4 rounded-[28px] border border-white/8 bg-gradient-to-b from-white/[0.06] to-white/[0.03] p-4 shadow-panel">
97
+ <div className="mb-4 flex items-center justify-between gap-3">
98
+ <div>
99
+ <h3 className="font-semibold text-white">{activeSession.name}</h3>
100
+ <p className="text-sm text-white/45">{activeSession.phoneNumber || "Waiting for WhatsApp pairing"}</p>
101
+ <p className="mt-1 text-xs uppercase tracking-[0.18em] text-white/30">
102
+ Transport: {activeSession.transportType === "mock" ? "Mock" : "WhatsApp Web"}
103
+ </p>
104
+ </div>
105
+ <SessionStatusBadge status={activeSession.status} />
106
+ </div>
107
+
108
+ {activeSession.qrCode ? (
109
+ <div className="rounded-[24px] bg-white p-3">
110
+ <img src={activeSession.qrCode} alt="QR Code" className="mx-auto h-40 w-40 rounded-2xl" />
111
+ </div>
112
+ ) : (
113
+ <div className="rounded-[24px] border border-dashed border-white/10 bg-[#111b21] px-4 py-10 text-center text-sm leading-6 text-white/45">
114
+ QR code will appear here when session starts pairing.
115
+ </div>
116
+ )}
117
+
118
+ {activeSession.lastError ? (
119
+ <div className="mt-3 rounded-2xl border border-red-400/20 bg-red-500/10 px-3 py-2.5 text-sm leading-6 text-red-100">
120
+ {activeSession.lastError}
121
+ </div>
122
+ ) : null}
123
+
124
+ <div className="mt-4 grid grid-cols-2 gap-2">
125
+ <button
126
+ type="button"
127
+ className="rounded-2xl bg-brand-500 px-4 py-3 text-sm font-semibold text-[#10251a] transition hover:bg-brand-600"
128
+ onClick={() => onConnect(activeSession.id)}
129
+ >
130
+ Connect
131
+ </button>
132
+ <button
133
+ type="button"
134
+ className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm font-medium text-white/80 transition hover:bg-white/[0.06]"
135
+ onClick={() => onDisconnect(activeSession.id)}
136
+ >
137
+ Disconnect
138
+ </button>
139
+ </div>
140
+ </div>
141
+ ) : null}
142
+
143
+ <form className="space-y-3" onSubmit={onCreateSession}>
144
+ <div>
145
+ <p className="mb-2 text-[11px] uppercase tracking-[0.26em] text-white/35">Add device</p>
146
+ <input
147
+ className="w-full rounded-2xl border border-white/10 bg-[#202c33] px-4 py-3 text-sm text-white outline-none transition placeholder:text-white/30 focus:border-brand-500"
148
+ placeholder="Session name, e.g. Sales Team"
149
+ value={sessionName}
150
+ onChange={(event) => onSessionNameChange(event.target.value)}
151
+ required
152
+ />
153
+ </div>
154
+ <input
155
+ className="w-full rounded-2xl border border-white/10 bg-[#202c33] px-4 py-3 text-sm text-white outline-none transition placeholder:text-white/30 focus:border-brand-500"
156
+ placeholder="Nomor WhatsApp (opsional)"
157
+ value={sessionPhone}
158
+ onChange={(event) => onSessionPhoneChange(event.target.value)}
159
+ />
160
+ <button type="submit" className="w-full rounded-2xl border border-brand-500/30 bg-brand-500/10 px-4 py-3 text-sm font-semibold text-brand-100 transition hover:bg-brand-500/20">
161
+ Add WhatsApp Session
162
+ </button>
163
+ </form>
164
+ </div>
165
+ </aside>
166
+ );
167
+ }
@@ -0,0 +1,266 @@
1
+ import { useState } from "react";
2
+ import { BrandLogo } from "@/components/BrandLogo";
3
+
4
+ function SessionStatusBadge({ status }) {
5
+ const colors = {
6
+ ready: "bg-brand-500/15 text-brand-100 ring-1 ring-brand-400/20",
7
+ connecting: "bg-amber-500/15 text-amber-100 ring-1 ring-amber-400/20",
8
+ disconnected: "bg-white/8 text-white/60 ring-1 ring-white/10",
9
+ error: "bg-red-500/15 text-red-100 ring-1 ring-red-400/20"
10
+ };
11
+
12
+ return (
13
+ <span className={`rounded-full px-2.5 py-1 text-[11px] font-medium capitalize tracking-[0.08em] ${colors[status] || colors.disconnected}`}>
14
+ {status}
15
+ </span>
16
+ );
17
+ }
18
+
19
+ function initials(label) {
20
+ return String(label || "?")
21
+ .split(" ")
22
+ .map((part) => part[0])
23
+ .join("")
24
+ .slice(0, 2)
25
+ .toUpperCase();
26
+ }
27
+
28
+ function SessionAvatar({ label }) {
29
+ return <div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-[#2e2f2f] text-sm font-semibold text-white">{initials(label)}</div>;
30
+ }
31
+
32
+ function formatDateTime(value) {
33
+ if (!value) {
34
+ return "Never";
35
+ }
36
+
37
+ return new Intl.DateTimeFormat("en-US", {
38
+ day: "2-digit",
39
+ month: "short",
40
+ year: "numeric",
41
+ hour: "2-digit",
42
+ minute: "2-digit"
43
+ }).format(new Date(value));
44
+ }
45
+
46
+ export function SettingsModal({
47
+ open,
48
+ sessions,
49
+ activeSessionId,
50
+ onClose,
51
+ onSelect,
52
+ onConnect,
53
+ onDisconnect,
54
+ sessionName,
55
+ sessionPhone,
56
+ onSessionNameChange,
57
+ onSessionPhoneChange,
58
+ onCreateSession,
59
+ apiKeys,
60
+ apiKeysLoading,
61
+ apiKeyName,
62
+ apiKeySecret,
63
+ onApiKeyNameChange,
64
+ onCreateApiKey,
65
+ onRevokeApiKey
66
+ }) {
67
+ const [copied, setCopied] = useState(false);
68
+
69
+ if (!open) {
70
+ return null;
71
+ }
72
+
73
+ return (
74
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-6 py-8 backdrop-blur-sm">
75
+ <div className="flex h-full max-h-[840px] w-full max-w-[1080px] flex-col overflow-hidden rounded-[32px] bg-[#161717] shadow-[0_40px_120px_rgba(0,0,0,0.5)]">
76
+ <div className="flex items-center justify-between px-6 py-5">
77
+ <div className="flex items-center gap-4">
78
+ <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white p-2 shadow-[0_16px_40px_rgba(0,0,0,0.18)]">
79
+ <BrandLogo variant="square" alt="OpenWA" className="h-full w-full rounded-xl" />
80
+ </div>
81
+ <div>
82
+ <p className="text-[11px] uppercase tracking-[0.26em] text-white/35">Settings</p>
83
+ <h2 className="mt-2 text-xl font-semibold text-white">OpenWA Devices</h2>
84
+ </div>
85
+ </div>
86
+ <button type="button" className="rounded-full bg-[#2e2f2f] px-4 py-2 text-sm text-white/70 transition hover:bg-[#3a3b3b] hover:text-white" onClick={onClose}>
87
+ Close
88
+ </button>
89
+ </div>
90
+
91
+ <div className="grid min-h-0 flex-1 gap-0 md:grid-cols-[1.2fr_0.8fr]">
92
+ <div className="min-h-0 px-5 py-5">
93
+ <div className="h-full overflow-y-auto pr-1">
94
+ <div className="space-y-3">
95
+ {sessions.map((session) => (
96
+ <button
97
+ key={session.id}
98
+ type="button"
99
+ className={`w-full rounded-[18px] px-4 py-4 text-left transition ${
100
+ session.id === activeSessionId
101
+ ? "bg-[#2e2f2f]"
102
+ : "bg-transparent hover:bg-white/[0.04]"
103
+ }`}
104
+ onClick={() => onSelect(session.id)}
105
+ >
106
+ <div className="flex items-start gap-3">
107
+ <SessionAvatar label={session.name} />
108
+ <div className="min-w-0 flex-1">
109
+ <div className="flex items-center justify-between gap-3">
110
+ <h3 className="truncate font-medium text-white">{session.name}</h3>
111
+ <SessionStatusBadge status={session.status} />
112
+ </div>
113
+ <p className="mt-1 text-sm text-white/45">{session.phoneNumber || "Waiting for WhatsApp pairing"}</p>
114
+ <p className="mt-2 text-xs uppercase tracking-[0.16em] text-white/30">
115
+ Transport: {session.transportType === "mock" ? "Mock" : "WhatsApp Web"}
116
+ </p>
117
+ {session.lastError ? <p className="mt-3 rounded-2xl bg-red-500/10 px-3 py-2 text-sm text-red-100">{session.lastError}</p> : null}
118
+ </div>
119
+ </div>
120
+
121
+ <div className="mt-4 flex gap-2">
122
+ <button
123
+ type="button"
124
+ className="rounded-2xl bg-brand-500 px-4 py-2 text-sm font-semibold text-[#10251a]"
125
+ onClick={(event) => {
126
+ event.stopPropagation();
127
+ onConnect(session.id);
128
+ }}
129
+ >
130
+ Connect
131
+ </button>
132
+ <button
133
+ type="button"
134
+ className="rounded-2xl bg-[#2e2f2f] px-4 py-2 text-sm text-white/75"
135
+ onClick={(event) => {
136
+ event.stopPropagation();
137
+ onDisconnect(session.id);
138
+ }}
139
+ >
140
+ Disconnect
141
+ </button>
142
+ </div>
143
+ </button>
144
+ ))}
145
+ </div>
146
+ </div>
147
+ </div>
148
+
149
+ <div className="min-h-0 overflow-y-auto px-6 py-5">
150
+ <div className="rounded-[28px] bg-[#161717] p-4">
151
+ <p className="text-[11px] uppercase tracking-[0.24em] text-white/35">Pairing QR</p>
152
+ {sessions.find((session) => session.id === activeSessionId)?.qrCode ? (
153
+ <div className="mt-4 rounded-[24px] bg-white p-4">
154
+ <img
155
+ src={sessions.find((session) => session.id === activeSessionId)?.qrCode}
156
+ alt="QR Code"
157
+ className="mx-auto h-56 w-56 rounded-2xl"
158
+ />
159
+ </div>
160
+ ) : (
161
+ <div className="mt-4 rounded-[24px] bg-[#2e2f2f] px-4 py-16 text-center text-sm leading-6 text-white/40">
162
+ QR code for pairing will appear here when session is connecting.
163
+ </div>
164
+ )}
165
+ </div>
166
+
167
+ <form className="mt-5 space-y-3 rounded-[28px] bg-[#161717] p-4" onSubmit={onCreateSession}>
168
+ <div>
169
+ <p className="mb-2 text-[11px] uppercase tracking-[0.24em] text-white/35">Add device</p>
170
+ <input
171
+ className="w-full rounded-[22px] bg-[#2e2f2f] px-4 py-3 text-sm text-white outline-none placeholder:text-white/30"
172
+ placeholder="Session name, e.g. Sales Team"
173
+ value={sessionName}
174
+ onChange={(event) => onSessionNameChange(event.target.value)}
175
+ required
176
+ />
177
+ </div>
178
+ <input
179
+ className="w-full rounded-[22px] bg-[#2e2f2f] px-4 py-3 text-sm text-white outline-none placeholder:text-white/30"
180
+ placeholder="WhatsApp number (optional)"
181
+ value={sessionPhone}
182
+ onChange={(event) => onSessionPhoneChange(event.target.value)}
183
+ />
184
+ <button type="submit" className="w-full rounded-2xl bg-brand-500 px-4 py-3 text-sm font-semibold text-[#10251a]">
185
+ Add WhatsApp Session
186
+ </button>
187
+ </form>
188
+
189
+ <div className="mt-5 rounded-[28px] bg-[#161717] p-4">
190
+ <div className="flex items-start justify-between gap-4">
191
+ <div>
192
+ <p className="text-[11px] uppercase tracking-[0.24em] text-white/35">API Access</p>
193
+ <h3 className="mt-2 text-base font-semibold text-white">Generate API key</h3>
194
+ <p className="mt-2 text-sm leading-6 text-white/45">Use with external agents via `X-API-Key` header or `Authorization: Bearer &lt;api-key&gt;`.</p>
195
+ </div>
196
+ </div>
197
+
198
+ {apiKeySecret ? (
199
+ <div className="mt-4 rounded-[22px] bg-[#2e2f2f] p-4">
200
+ <p className="text-[11px] uppercase tracking-[0.22em] text-brand-200/80">Shown once</p>
201
+ <p className="mt-2 break-all font-mono text-sm text-white">{apiKeySecret}</p>
202
+ <button
203
+ type="button"
204
+ className="mt-3 rounded-full bg-brand-500 px-4 py-2 text-sm font-semibold text-[#10251a]"
205
+ onClick={async () => {
206
+ await navigator.clipboard.writeText(apiKeySecret);
207
+ setCopied(true);
208
+ setTimeout(() => setCopied(false), 1500);
209
+ }}
210
+ >
211
+ {copied ? "Copied" : "Copy API key"}
212
+ </button>
213
+ </div>
214
+ ) : null}
215
+
216
+ <form className="mt-4 flex gap-2" onSubmit={onCreateApiKey}>
217
+ <input
218
+ className="w-full rounded-[22px] bg-[#2e2f2f] px-4 py-3 text-sm text-white outline-none placeholder:text-white/30"
219
+ placeholder="Key name, e.g. OpenClaw Agent"
220
+ value={apiKeyName}
221
+ onChange={(event) => onApiKeyNameChange(event.target.value)}
222
+ required
223
+ />
224
+ <button type="submit" className="shrink-0 rounded-[22px] bg-brand-500 px-4 py-3 text-sm font-semibold text-[#10251a]">
225
+ Generate
226
+ </button>
227
+ </form>
228
+
229
+ <div className="mt-4 max-h-[260px] space-y-3 overflow-y-auto pr-1">
230
+ {apiKeysLoading ? <div className="rounded-[22px] bg-[#2e2f2f] px-4 py-6 text-sm text-white/45">Loading API keys...</div> : null}
231
+
232
+ {!apiKeysLoading && !apiKeys.length ? (
233
+ <div className="rounded-[22px] bg-[#2e2f2f] px-4 py-6 text-sm leading-6 text-white/45">
234
+ No API keys yet. Create one for OpenAPI client, AI agents, or external integrations.
235
+ </div>
236
+ ) : null}
237
+
238
+ {apiKeys.map((apiKey) => (
239
+ <div key={apiKey.id} className="rounded-[22px] bg-[#2e2f2f] px-4 py-4">
240
+ <div className="flex items-start justify-between gap-3">
241
+ <div className="min-w-0">
242
+ <h4 className="truncate text-sm font-semibold text-white">{apiKey.name}</h4>
243
+ <p className="mt-1 font-mono text-xs text-white/55">{apiKey.maskedKey}</p>
244
+ </div>
245
+ <button
246
+ type="button"
247
+ className="rounded-full bg-white/5 px-3 py-1.5 text-xs font-medium text-red-200 transition hover:bg-red-500/15"
248
+ onClick={() => onRevokeApiKey(apiKey.id)}
249
+ >
250
+ Revoke
251
+ </button>
252
+ </div>
253
+ <div className="mt-3 grid gap-2 text-xs text-white/40">
254
+ <p>Created: {formatDateTime(apiKey.createdAt)}</p>
255
+ <p>Last used: {formatDateTime(apiKey.lastUsedAt)}</p>
256
+ </div>
257
+ </div>
258
+ ))}
259
+ </div>
260
+ </div>
261
+ </div>
262
+ </div>
263
+ </div>
264
+ </div>
265
+ );
266
+ }
@@ -0,0 +1,73 @@
1
+ export function MessageSkeleton() {
2
+ return (
3
+ <div className="flex justify-start">
4
+ <div className="max-w-[72%] space-y-2 rounded-[18px] bg-[#2e2f2f] px-4 py-3">
5
+ <div className="h-4 w-48 animate-pulse rounded bg-white/10" />
6
+ <div className="h-4 w-40 animate-pulse rounded bg-white/10" />
7
+ <div className="mt-3 flex justify-between">
8
+ <div className="h-3 w-12 animate-pulse rounded bg-white/5" />
9
+ <div className="h-3 w-8 animate-pulse rounded bg-white/5" />
10
+ </div>
11
+ </div>
12
+ </div>
13
+ );
14
+ }
15
+
16
+ export function MessagesSkeletonList() {
17
+ return (
18
+ <div className="space-y-3">
19
+ {Array.from({ length: 5 }).map((_, i) => (
20
+ <MessageSkeleton key={i} />
21
+ ))}
22
+ </div>
23
+ );
24
+ }
25
+
26
+ export function ConversationSkeleton() {
27
+ return (
28
+ <div className="flex items-center gap-3 border-b border-white/5 px-3 py-3 transition hover:bg-white/5 cursor-pointer">
29
+ <div className="h-12 w-12 animate-pulse rounded-2xl bg-white/10" />
30
+ <div className="flex-1 min-w-0">
31
+ <div className="h-4 w-32 animate-pulse rounded bg-white/10 mb-2" />
32
+ <div className="h-3 w-48 animate-pulse rounded bg-white/5" />
33
+ </div>
34
+ <div className="h-3 w-10 animate-pulse rounded bg-white/5" />
35
+ </div>
36
+ );
37
+ }
38
+
39
+ export function ConversationsSkeletonList() {
40
+ return (
41
+ <div>
42
+ {Array.from({ length: 8 }).map((_, i) => (
43
+ <ConversationSkeleton key={i} />
44
+ ))}
45
+ </div>
46
+ );
47
+ }
48
+
49
+ export function ImageGroupSkeleton() {
50
+ return (
51
+ <div className="flex justify-start">
52
+ <div className="max-w-[72%] rounded-[18px] overflow-hidden bg-[#2e2f2f]">
53
+ <div className="grid grid-cols-2 gap-1 p-1">
54
+ {Array.from({ length: 4 }).map((_, i) => (
55
+ <div key={i} className="h-32 w-32 animate-pulse rounded-lg bg-white/10" />
56
+ ))}
57
+ </div>
58
+ <div className="px-4 py-3 border-t border-white/10">
59
+ <div className="h-3 w-20 animate-pulse rounded bg-white/5" />
60
+ </div>
61
+ </div>
62
+ </div>
63
+ );
64
+ }
65
+
66
+ export function SendButtonSpinner() {
67
+ return (
68
+ <svg className="h-5 w-5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
69
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
70
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
71
+ </svg>
72
+ );
73
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "compilerOptions": {
3
+ "baseUrl": ".",
4
+ "paths": {
5
+ "@/*": [
6
+ "./*"
7
+ ]
8
+ }
9
+ }
10
+ }
package/web/lib/api.js ADDED
@@ -0,0 +1,33 @@
1
+ export function getApiBaseUrl() {
2
+ return process.env.NEXT_PUBLIC_API_URL || "http://localhost:55222";
3
+ }
4
+
5
+ export async function apiFetch(url, options = {}) {
6
+ const target = `${getApiBaseUrl()}${url}`;
7
+ const authHeaders = {
8
+ ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
9
+ ...(options.apiKey ? { "X-API-Key": options.apiKey } : {})
10
+ };
11
+ const response = await fetch(target, {
12
+ method: options.method || "GET",
13
+ headers: options.formData
14
+ ? {
15
+ ...authHeaders
16
+ }
17
+ : {
18
+ "Content-Type": "application/json",
19
+ ...authHeaders
20
+ },
21
+ body: options.formData || (options.body ? JSON.stringify(options.body) : undefined)
22
+ });
23
+
24
+ const payload = await response.json().catch(() => ({}));
25
+
26
+ if (!response.ok) {
27
+ const error = new Error(payload.error || "Request failed.");
28
+ error.status = response.status;
29
+ throw error;
30
+ }
31
+
32
+ return payload;
33
+ }
@@ -0,0 +1,9 @@
1
+ import { io } from "socket.io-client";
2
+
3
+ export function createSocket(token) {
4
+ return io(process.env.NEXT_PUBLIC_SOCKET_URL || "http://localhost:55222", {
5
+ auth: {
6
+ token
7
+ }
8
+ });
9
+ }
@@ -0,0 +1,5 @@
1
+ import "@/styles/globals.css";
2
+
3
+ export default function App({ Component, pageProps }) {
4
+ return <Component {...pageProps} />;
5
+ }